package com.weEat.models import com.raquo.laminar.api.L._ import io.laminext.syntax.core._ import com.weEat.modules.{Overlay,Typeahead} import com.weEat.shared.models.UnitType._ import com.weEat.shared.models._ import gov.usda.nal.fdc.models.DataType._ import com.tflucke.typeahead._ import com.tflucke.typeahead.Dataset.Templates import com.tflucke.sortable.{Sortable, SortableOptions, SortableEvent} import com.weEat.controllers.{FoodController,USDAController} import scala.util.{Try,Success,Failure} import org.scalajs.dom.document import org.scalajs.dom.{Event,KeyboardEvent,HTMLInputElement,HTMLElement} import scala.concurrent.Future case class RecipeVar(recipe: Option[RecipeNode]) extends VarRepresentationOf[RecipeNodeNoId] { implicit val ec = com.weEat.shared.ctx private def _defaultRecipe = recipe.getOrElse(RecipeNodeNoId( "New Recipe", 400.0f, 100.0f, defaultUnitType = UnitType.MASS, Seq(), Seq(), None, None, None, None )) private val _nameIn = input(idAttr := "name", cls := "form-control", required := true, defaultValue := _defaultRecipe.name ) val name: Signal[String] = _nameIn.value private val _servingsIn = input(idAttr := "serv", typ := "number", cls := "form-control", defaultValue := (_defaultRecipe.stdQties / _defaultRecipe.stdQtiesPServing) .toString, stepAttr := "0.1", required := true ) private val _servings = _servingsIn.value.map(_.toFloat) // private val _discreetIn = input(idAttr := "discreet", // typ := "checkbox", // cls := "form-check-input", // defaultChecked := (_defaultRecipe.defaultUnitType match { // case NUMBER => true // case MASS => _defaultRecipe.massPerUnit.isDefined // case VOLUME => _defaultRecipe.massPerUnit // .zip(_defaultRecipe.density) // .isDefined // }) // ) // private val _discreet = _discreetIn.checked // TODO: Try autopopulate ingredients.sum(_.massPerUnit) private val _massPerUnitIn = input(idAttr := "massP", cls := "form-control", typ := "number", minAttr := "1", stepAttr := "0.1", defaultValue := _defaultRecipe.massPerUnit.map(_.toString).getOrElse("") ) val massPerUnit = _massPerUnitIn.value.transitions.map({ case (None, "") => "100" case (Some(""), "") => "100" case (Some(_), str) => str case (None, str) => str }).map(_.toFloatOption) private val _volumeUnit = Var[MeasureUnit](Milliliter) private val _volumeUnitIn = select(cls := "custom-select", MeasureUnit.units.zipWithIndex .filter({ case (u, _) => u.typ == VOLUME }) .map({ case (unit, idx) => // TODO: default selected dynamic option( value := idx.toString, selected := unit.abr == Milliliter.abr, unit.abr ) }), onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> _volumeUnit ) // TODO: Try autopopulate ingredients.averge(_.density) private val _volumePerServIn = input(idAttr := "volP", cls := "form-control", typ := "number", minAttr := "0", stepAttr := "0.1", defaultValue := (_defaultRecipe.defaultUnitType match { case VOLUME => (_defaultRecipe.stdQtiesPServing * UnitType.standardQuanity(VOLUME)) .toString case MASS => _defaultRecipe.density .map((d) => (_defaultRecipe.stdQtiesPServing / d * UnitType.standardQuanity(MASS)).toString).getOrElse("") case NUMBER => _defaultRecipe.massPerUnit.zip(_defaultRecipe.density) .map { case (mass, density) => (mass / density * UnitType.standardQuanity(NUMBER)).toString } getOrElse("") }) ) private val _volumePerServ = _volumePerServIn.value.map(_.toFloatOption) .combineWithFn(_volumeUnit) { case (Some(num), unit) => Some((num * unit.conversionRatio).toFloat) case (None, _) => None } private val _sourceIn = input(idAttr := "source", cls := "form-control", defaultValue := _defaultRecipe.source.getOrElse("") ) val source: Signal[Option[String]] = _sourceIn.value.map((s) => Option.when(s.size > 0)(s)) private val internalFoodDS = Dataset( { _ => Nil}, Some({ (str: String) => FoodController.query(str.toLowerCase)() }), display = { (food: FoodNodeId) => food.name } ) private val usdaFoodDS = Dataset( { _ => Nil}, Some({ str: String => USDAController.getFoodsSearch(str, Seq( Branded, Foundation, SRLegacy ).map(_.toString))().map(_.foods.map(USDANodeNoId.fromSearchResult)) }), templates = Some(Templates({(x: USDANodeNoId) => x.name}).copy( header = Some({ (_: String, _: Seq[USDANodeNoId], _: String) => val html = document.createElement("div").asInstanceOf[HTMLElement] html.innerText = "Unoffical Foods" html }) )), display = {(x: USDANodeNoId) => x.name} ) private val _ingredients = Var[Seq[Ingredient]](_defaultRecipe.ingredients) val ingredients = _ingredients.signal private val _ingredientSearch = _ingredientInput() { (e) => e.selectable.map(_.data).foreach({ (node) => // TODO: default unit _editIngredient(Ingredient.fromFoodNode(node, 0, Gram)) { (in) => _ingredients.update(_ :+ in) e.target.asInstanceOf[HTMLInputElement].value = "" Future.successful(()) } }) } private def _ingredientInput(defaul: Signal[Option[FoodNode]] = Val(None))(onSelect: (CursorEvent[FoodNode]) => Unit) = input(typ := "text", cls := "form-control input-sm", value <-- defaul.optionMap(_.name).withDefault(""), onMountCallback({(ctx) => val elm = ctx.thisNode TypeaheadElement[FoodNode]( elm.ref.asInstanceOf[HTMLInputElement], minLength = 3 )(Seq(internalFoodDS, usdaFoodDS)) elm.amend(Typeahead.onSelected[FoodNode] --> onSelect) }) ) private def _editIngredient(ing: Ingredient)( callback: (Ingredient => Future[_]) ) = { val id = Var[Ingredient.IngredientId](ing.id) val amount = Var[Float](ing.amount) val unit = Var[MeasureUnit](ing.unit) Overlay.confirmFuture(ing.food.map({ (food) => val amountIn = input(typ := "number", cls := "form-control input-sm", minAttr := "0", defaultValue := ing.amount.toString, onInput.mapToValue.map(_.toFloat) --> amount ) val unitIn = select(cls := "custom-select input-group-append", MeasureUnit.units.zipWithIndex.map({ case (unit, idx) => // TODO: default selected dynamic option(value := idx.toString, selected := unit == ing.unit, unit.name ) }), onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> unit ) div(cls := "row", div(cls := "col-12 input-group", _ingredientInput(Signal.fromFuture(ing.food)) { (e) => e.selectable.map(_.data).foreach({ (node) => id.set(Ingredient.IngredientId.fromFoodNode(node)) }) }, amountIn, unitIn ) ) }), id.signal.recoverToTry.map(_.isSuccess) && amount.signal.recoverToTry.map(_.isSuccess) && unit.signal.recoverToTry.map(_.isSuccess) ) { () => callback(ing.copy( id = id.now(), amount = amount.now(), unit = unit.now() )) } } private def _presentFoodNode(idx: Int)(ingredientSig: Signal[Ingredient]) = li(children <-- ingredientSig.flatMap { (ingredient: Ingredient) => Signal.fromFuture(ingredient.food).optionMap { (food) => Seq( span(cls := "ui-icon ui-icon-pencil", onClick --> { (e: Event) => _editIngredient(ingredient) { (in) => _ingredients.update(_.updated(idx, in)) Future.successful(()) } } ), span(cls := "ui-icon ui-icon-close", onClick --> { (e: Event) => _ingredients.update(_.filterNot(_ == ingredient)) } ), span(f"${ingredient.amount}%.02f${ingredient.unit.abr} ${food.name}") ) } withDefault(Nil) }) private val _ENTER_KEY_CODE = 13 private val _steps = Var[Seq[String]](_defaultRecipe.steps) val steps = _steps.signal private val _stepIn = input(idAttr := "step", cls := "form-control", onKeyPress --> { (e: KeyboardEvent) => if (e.keyCode == _ENTER_KEY_CODE) { val elm = e.target.asInstanceOf[HTMLInputElement] _steps.update(_ :+ elm.value) elm.value = "" } } ) private def _removeFromList[T](s: Seq[T], idx: Int) = s.take(idx) ++ s.drop(idx + 1) private val _stepList = ol( listStyleType := "none", paddingLeft := "0", children <-- _steps.signal.map(_.zipWithIndex.map((_presentStep _).tupled)), onMountCallback({(ctx) => Sortable.create(ctx.thisNode.ref, SortableOptions.onUpdate({ (event: SortableEvent) => _steps.update({ steps => (event.oldIndex.toOption, event.newIndex.toOption) match { case (Some(old), Some(ne)) if (old < ne) => moveBackwards(steps, old, ne) case (Some(old), Some(ne)) => moveBackwards( steps.reverse, steps.size - old - 1, steps.size - ne - 1 ).reverse case (None, Some(ne)) => val (first, second) = steps.splitAt(ne) (first :+ event.item.innerText) ++ second case (Some(old), None) => _removeFromList(steps, old) } }) // If the added element is still in the parent, remove it since the // Rx.update will have generated a new one. Option(event.item.parentElement).map(_.removeChild(event.item)) def moveBackwards(steps: Seq[String], old: Int, ne: Int) = (steps.take(old) ++ steps.take(ne+1).drop(old + 1) :+ steps(old)) ++ steps.drop(ne+1) })) }) ) private def _presentStep(step: String, idx: Int) = li( span(cls := "ui-icon ui-icon-close", onClick --> { (e: Event) => _steps.update(_removeFromList(_, idx)) } ), step ) private val _srvAmtXunit = massPerUnit.combineWithFn(_volumePerServ, /* _discreet */ Val(false))({ case (_, _, true) => Success((1.0f, NUMBER)) case (Some(mass), _, _) => Success((mass, MASS)) case (_, Some(volume), _) => Success((volume, VOLUME)) case (None, None, false) => Failure(new IllegalStateException("None of mass, density, or count are enabled.")) }).throwFailure val defaultUnitType = _srvAmtXunit.map(_._2) val stdQtiesPServing = _srvAmtXunit.map((x) => x._1 / x._2.standardQuanity) val stdQties = _servings.combineWithFn(stdQtiesPServing)(_ * _) val density = massPerUnit.combineWithFn(_volumePerServ)(_.zip(_).map { case (m, v) => (m/v).toFloat }) val newInstance: Signal[Try[RecipeNodeNoId]] = name.combineWithFn(stdQties, stdQtiesPServing, defaultUnitType, ingredients, steps, density, massPerUnit.signal, source) { case ( name, stdQties, stdQtiesPServing, defaultUnitType, ingredients, steps, density, massPerUnit, source ) => Success(RecipeNodeNoId( name, stdQties, stdQtiesPServing, defaultUnitType, ingredients, steps, density, massPerUnit, source, _defaultRecipe match { case node: RecipeNodeId => Some(node.vid) case _ => None } )) } protected def renderEditPane(): Node = form(cls := "form-group", div(cls := "container", div(cls := "row", div(cls := "col-md-12 input-group", div(cls := "input-group-prepend", label(cls := "input-group-text", "Name") ), _nameIn ) ), div(cls := "row", div(cls := "col-md-2 form-check", /* div(cls := "form-check", _discreetIn, label(cls := "form-check-label", forId := "discreet", "Descrete Servings" ) )*/ ), div(cls := "col-md-3 input-group", _servingsIn, div(cls := "input-group-append", label(cls := "input-group-text", "Servings") ) ), div(cls := "col-md-3 input-group", _massPerUnitIn, div(cls := "input-group-append", label(cls := "input-group-text", "g/Serving") ) ), div(cls := "col-md-4 input-group", _volumePerServIn, _volumeUnitIn, div(cls := "input-group-append", label(cls := "input-group-text", "/Serving"), ), ) ), div(cls := "row", div(cls := "col-md-6", h2("Ingredients"), _ingredientSearch, ul( listStyleType := "none", paddingLeft := "0", children <-- ingredients.splitByIndex { case (idx, _, ingredientStream) => _presentFoodNode(idx)(ingredientStream) } ) ), div(cls := "col-md-6", h2("Steps"), _stepIn, _stepList ) ) ) ) }