diff --git a/TODO b/TODO index 6630ebb..34d2f76 100644 --- a/TODO +++ b/TODO @@ -1,5 +1,6 @@ MAIN TASKS: General/Framework: + ☐ create app icon ☐ show indicator and make all fields readonly if user somehow gets to a deleted record detail view ☐ clean up controllers (dispose method of each stateful widget) ☐ account for deleted/disabled elements in dropdowns @@ -8,14 +9,9 @@ MAIN TASKS: ☐ implement component for durations ☐ change placement of delete and floating button because its very easy to accidentally hit delete Recipe: - ✔ add model for recipe @done(21-12-11 02:23) - ✔ add model for ingredient (relation betweeen recipe and meal) @done(21-12-11 02:23) - ☐ recipe list screen - ☐ recipe detail screen + ✔ recipe list screen @done(21-12-11 22:01) + ✔ recipe detail screen @done(21-12-11 22:01) ☐ add functionality to create a meal from a recipe - Log Entry: - ✔ give option to specify quantity @done(21-12-11 01:28) - ✔ give option to pick meal from a different log entry (that doesn't have an associated bolus yet and within certain time span) @done(21-12-11 02:22) Event Types: ☐ add colors as indicators for log entries (and later graphs in reports) Settings: @@ -31,12 +27,14 @@ FUTURE TASKS: General/Framework: ☐ setup objectbox sync server ☐ add explanations to each section - ✔ find a better way to work with multiple glucose measurements @done(21-12-11 02:23) ☐ evaluate if some fields should be readonly instead of completely hidden ☐ alternate languages ☐ log hba1c Reports: ☐ evaluate what type of reports there should be + ☐ meal tweaking + ☐ bolus tweaking + ☐ daily graph (showing glucose curve, events, boli and meals) Log Overview: ☐ add pagination ☐ add filters @@ -50,6 +48,11 @@ FUTURE TASKS: ☐ option to switch theme Archive: + ✔ add model for recipe @done(21-12-11 02:23) @project(MAIN TASKS.Recipe) + ✔ add model for ingredient (relation betweeen recipe and meal) @done(21-12-11 02:23) @project(MAIN TASKS.Recipe) + ✔ give option to specify quantity @done(21-12-11 01:28) @project(MAIN TASKS.Log Entry) + ✔ give option to pick meal from a different log entry (that doesn't have an associated bolus yet and within certain time span) @done(21-12-11 02:22) @project(MAIN TASKS.Log Entry) + ✔ find a better way to work with multiple glucose measurements @done(21-12-11 02:23) @project(FUTURE TASKS.General/Framework) ✔ make components rounder/nicer/closer to new material style @done(21-12-10 04:10) @project(MAIN TASKS.Layout) ✔ make sure 'null' isn't shown in text fields @done(21-12-10 04:23) @project(MAIN TASKS.General/Framework) ✔ hide details like accuracies etc when picking meals @done(21-12-10 06:12) @project(MAIN TASKS.General/Framework) diff --git a/lib/main.dart b/lib/main.dart index 4eacab3..645000d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,8 @@ import 'package:diameter/screens/meal/meal_portion_type_detail.dart'; import 'package:diameter/screens/meal/meal_portion_type_list.dart'; import 'package:diameter/screens/meal/meal_source_detail.dart'; import 'package:diameter/screens/meal/meal_source_list.dart'; +import 'package:diameter/screens/recipe/recipe_detail.dart'; +import 'package:diameter/screens/recipe/recipe_list.dart'; import 'package:diameter/settings.dart'; import 'package:flutter/material.dart'; import 'package:diameter/screens/accuracy_list.dart'; @@ -61,6 +63,8 @@ Future main() async { Routes.accuracy: (context) => const AccuracyDetailScreen(), Routes.meals: (context) => const MealListScreen(), Routes.meal: (context) => const MealDetailScreen(), + Routes.recipes: (context) => const RecipeListScreen(), + Routes.recipe: (context) => const RecipeDetailScreen(), Routes.mealCategories: (context) => const MealCategoryListScreen(), Routes.mealCategory: (context) => const MealCategoryDetailScreen(), Routes.mealPortionTypes: (context) => diff --git a/lib/models/ingredient.dart b/lib/models/ingredient.dart index 7131e7c..486a178 100644 --- a/lib/models/ingredient.dart +++ b/lib/models/ingredient.dart @@ -1,6 +1,7 @@ import 'package:diameter/main.dart'; import 'package:diameter/models/meal.dart'; import 'package:diameter/models/recipe.dart'; +import 'package:diameter/utils/utils.dart'; import 'package:objectbox/objectbox.dart'; import 'package:diameter/objectbox.g.dart' show Ingredient_, Recipe_; @@ -25,9 +26,54 @@ class Ingredient { required this.amount, }); + // methods + static Ingredient? get(int id) => box.get(id); + static void put(Ingredient ingredient) => box.put(ingredient); + static void putMany(List ingredients) => box.putMany(ingredients); + static List getAllForRecipe(int id) { - QueryBuilder builder = box.query(Ingredient_.deleted.equals(false)); + QueryBuilder builder = + box.query(Ingredient_.deleted.equals(false)); builder.link(Ingredient_.recipe, Recipe_.id.equals(id)); return builder.build().find(); } -} \ No newline at end of file + + static double? getCarbsRatioForRecipe(int id) { + double carbsSum = 0.0; + double totalWeight = 0.0; + + List ingredients = getAllForRecipe(id); + + for (Ingredient ingredient in ingredients) { + if ((ingredient.ingredient.target?.carbsRatio ?? 0) <= 0) { + return null; + } + totalWeight += ingredient.amount; + carbsSum += + Utils.calculateCarbs(ingredient.ingredient.target!.carbsRatio!, ingredient.amount); + } + return totalWeight > 0 + ? Utils.calculateCarbsRatio(carbsSum, totalWeight) + : null; + } + + static double? getTotalWeightForRecipe(int id) { + double totalWeight = 0.0; + + List ingredients = getAllForRecipe(id); + + for (Ingredient ingredient in ingredients) { + if (ingredient.ingredient.target?.carbsRatio == null) { + return null; + } + totalWeight += ingredient.amount; + } + + return totalWeight; + } + + @override + String toString() { + return ingredient.target?.value ?? ''; + } +} diff --git a/lib/models/recipe.dart b/lib/models/recipe.dart index 6f29aa1..24b8bc6 100644 --- a/lib/models/recipe.dart +++ b/lib/models/recipe.dart @@ -1,5 +1,7 @@ import 'package:diameter/main.dart'; +import 'package:diameter/models/ingredient.dart'; import 'package:diameter/models/meal.dart'; +import 'package:diameter/utils/utils.dart'; import 'package:objectbox/objectbox.dart'; import 'package:diameter/objectbox.g.dart' show Recipe_; @@ -12,38 +14,43 @@ class Recipe { int id; bool deleted; String name; - double? carbsRatio; - double? portionSize; - double? carbsPerPortion; - int? delayedBolusDuration; - double? delayedBolusPercentage; + double? servings; String? notes; // relations final portion = ToOne(); - + // constructor Recipe({ this.id = 0, this.deleted = false, this.name = '', - this.carbsRatio, - this.portionSize, - this.carbsPerPortion, - this.delayedBolusDuration, - this.delayedBolusPercentage, + this.servings, this.notes, }); // methods static Recipe? get(int id) => box.get(id); static void put(Recipe recipe) => box.put(recipe); - + static List getAll() { - QueryBuilder builder = box.query(Recipe_.deleted.equals(false))..order(Recipe_.name); + QueryBuilder builder = box.query(Recipe_.deleted.equals(false)) + ..order(Recipe_.name); return builder.build().find(); } - + + static double? getCarbsPerPortion(int id) { + final servings = Recipe.get(id)?.servings; + final totalWeight = Ingredient.getTotalWeightForRecipe(id); + final carbsRatio = Ingredient.getCarbsRatioForRecipe(id); + + if (servings != null && totalWeight != null && carbsRatio != null) { + final portionSize = totalWeight / servings; + return Utils.calculateCarbs(carbsRatio, portionSize); + } + return null; + } + static void remove(int id) { final item = box.get(id); if (item != null) { diff --git a/lib/navigation.dart b/lib/navigation.dart index 3f2995c..cbe5b4e 100644 --- a/lib/navigation.dart +++ b/lib/navigation.dart @@ -21,6 +21,8 @@ import 'package:diameter/screens/meal/meal_portion_type_detail.dart'; import 'package:diameter/screens/meal/meal_portion_type_list.dart'; import 'package:diameter/screens/meal/meal_source_detail.dart'; import 'package:diameter/screens/meal/meal_source_list.dart'; +import 'package:diameter/screens/recipe/recipe_detail.dart'; +import 'package:diameter/screens/recipe/recipe_list.dart'; import 'package:diameter/settings.dart'; import 'package:flutter/material.dart'; @@ -45,6 +47,10 @@ class Routes { static const List logEventTypeRoutes = [logEventType, logEventTypes]; static const String events = LogEventListScreen.routeName; + static const String recipe = RecipeDetailScreen.routeName; + static const String recipes = RecipeListScreen.routeName; + static const List recipeRoutes = [recipe, recipes]; + static const String meal = MealDetailScreen.routeName; static const String meals = MealListScreen.routeName; static const List mealRoutes = [meal, meals]; @@ -107,9 +113,17 @@ class _NavigationState extends State { }, selected: widget.currentLocation == Routes.events, ), + ListTile( + title: const Text('Recipes'), + leading: const Icon(Icons.local_dining), + onTap: () { + selectDestination(Routes.recipes); + }, + selected: Routes.recipeRoutes.contains(widget.currentLocation), + ), ListTile( title: const Text('Meals'), - leading: const Icon(Icons.restaurant), + leading: const Icon(Icons.dinner_dining), onTap: () { selectDestination(Routes.meals); }, diff --git a/lib/objectbox-model.json b/lib/objectbox-model.json index 542512e..e0a1e35 100644 --- a/lib/objectbox-model.json +++ b/lib/objectbox-model.json @@ -943,7 +943,7 @@ }, { "id": "18:6497942314956341514", - "lastPropertyId": "10:4370359747396560337", + "lastPropertyId": "11:8488657312300528492", "name": "Recipe", "flags": 2, "properties": [ @@ -963,31 +963,6 @@ "name": "name", "type": 9 }, - { - "id": "4:241621230513128588", - "name": "carbsRatio", - "type": 8 - }, - { - "id": "5:4678123663117222609", - "name": "portionSize", - "type": 8 - }, - { - "id": "6:780211923138281722", - "name": "carbsPerPortion", - "type": 8 - }, - { - "id": "7:763575433624979013", - "name": "delayedBolusDuration", - "type": 6 - }, - { - "id": "8:1225271130099322691", - "name": "delayedBolusPercentage", - "type": 8 - }, { "id": "9:8593446427752839266", "name": "notes", @@ -1000,6 +975,11 @@ "flags": 520, "indexId": "29:5110151182694376118", "relationTarget": "Meal" + }, + { + "id": "11:8488657312300528492", + "name": "servings", + "type": 8 } ], "relations": [] @@ -1085,7 +1065,12 @@ 3282706593658092097, 596980591281311896, 3633551763915044903, - 2215708755581938580 + 2215708755581938580, + 241621230513128588, + 4678123663117222609, + 780211923138281722, + 763575433624979013, + 1225271130099322691 ], "retiredRelationUids": [], "version": 1 diff --git a/lib/objectbox.g.dart b/lib/objectbox.g.dart index ccfa2d9..ce823fb 100644 --- a/lib/objectbox.g.dart +++ b/lib/objectbox.g.dart @@ -930,7 +930,7 @@ final _entities = [ ModelEntity( id: const IdUid(18, 6497942314956341514), name: 'Recipe', - lastPropertyId: const IdUid(10, 4370359747396560337), + lastPropertyId: const IdUid(11, 8488657312300528492), flags: 2, properties: [ ModelProperty( @@ -948,31 +948,6 @@ final _entities = [ name: 'name', type: 9, flags: 0), - ModelProperty( - id: const IdUid(4, 241621230513128588), - name: 'carbsRatio', - type: 8, - flags: 0), - ModelProperty( - id: const IdUid(5, 4678123663117222609), - name: 'portionSize', - type: 8, - flags: 0), - ModelProperty( - id: const IdUid(6, 780211923138281722), - name: 'carbsPerPortion', - type: 8, - flags: 0), - ModelProperty( - id: const IdUid(7, 763575433624979013), - name: 'delayedBolusDuration', - type: 6, - flags: 0), - ModelProperty( - id: const IdUid(8, 1225271130099322691), - name: 'delayedBolusPercentage', - type: 8, - flags: 0), ModelProperty( id: const IdUid(9, 8593446427752839266), name: 'notes', @@ -984,7 +959,12 @@ final _entities = [ type: 11, flags: 520, indexId: const IdUid(29, 5110151182694376118), - relationTarget: 'Meal') + relationTarget: 'Meal'), + ModelProperty( + id: const IdUid(11, 8488657312300528492), + name: 'servings', + type: 8, + flags: 0) ], relations: [], backlinks: []), @@ -1080,7 +1060,12 @@ ModelDefinition getObjectBoxModel() { 3282706593658092097, 596980591281311896, 3633551763915044903, - 2215708755581938580 + 2215708755581938580, + 241621230513128588, + 4678123663117222609, + 780211923138281722, + 763575433624979013, + 1225271130099322691 ], retiredRelationUids: const [], modelVersion: 5, @@ -1922,17 +1907,13 @@ ModelDefinition getObjectBoxModel() { final nameOffset = fbb.writeString(object.name); final notesOffset = object.notes == null ? null : fbb.writeString(object.notes!); - fbb.startTable(11); + fbb.startTable(12); fbb.addInt64(0, object.id); fbb.addBool(1, object.deleted); fbb.addOffset(2, nameOffset); - fbb.addFloat64(3, object.carbsRatio); - fbb.addFloat64(4, object.portionSize); - fbb.addFloat64(5, object.carbsPerPortion); - fbb.addInt64(6, object.delayedBolusDuration); - fbb.addFloat64(7, object.delayedBolusPercentage); fbb.addOffset(8, notesOffset); fbb.addInt64(9, object.portion.targetId); + fbb.addFloat64(10, object.servings); fbb.finish(fbb.endTable()); return object.id; }, @@ -1946,16 +1927,8 @@ ModelDefinition getObjectBoxModel() { const fb.BoolReader().vTableGet(buffer, rootOffset, 6, false), name: const fb.StringReader().vTableGet(buffer, rootOffset, 8, ''), - carbsRatio: const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 10), - portionSize: const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 12), - carbsPerPortion: const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 14), - delayedBolusDuration: const fb.Int64Reader() - .vTableGetNullable(buffer, rootOffset, 16), - delayedBolusPercentage: const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 18), + servings: const fb.Float64Reader() + .vTableGetNullable(buffer, rootOffset, 24), notes: const fb.StringReader() .vTableGetNullable(buffer, rootOffset, 20)); object.portion.targetId = @@ -2609,32 +2582,16 @@ class Recipe_ { /// see [Recipe.name] static final name = QueryStringProperty(_entities[16].properties[2]); - /// see [Recipe.carbsRatio] - static final carbsRatio = - QueryDoubleProperty(_entities[16].properties[3]); - - /// see [Recipe.portionSize] - static final portionSize = - QueryDoubleProperty(_entities[16].properties[4]); - - /// see [Recipe.carbsPerPortion] - static final carbsPerPortion = - QueryDoubleProperty(_entities[16].properties[5]); - - /// see [Recipe.delayedBolusDuration] - static final delayedBolusDuration = - QueryIntegerProperty(_entities[16].properties[6]); - - /// see [Recipe.delayedBolusPercentage] - static final delayedBolusPercentage = - QueryDoubleProperty(_entities[16].properties[7]); - /// see [Recipe.notes] - static final notes = QueryStringProperty(_entities[16].properties[8]); + static final notes = QueryStringProperty(_entities[16].properties[3]); /// see [Recipe.portion] static final portion = - QueryRelationToOne(_entities[16].properties[9]); + QueryRelationToOne(_entities[16].properties[4]); + + /// see [Recipe.servings] + static final servings = + QueryDoubleProperty(_entities[16].properties[5]); } /// [Ingredient] entity fields to define ObjectBox queries. diff --git a/lib/screens/log/log_entry/log_entry.dart b/lib/screens/log/log_entry/log_entry.dart index 81d31cc..ba53236 100644 --- a/lib/screens/log/log_entry/log_entry.dart +++ b/lib/screens/log/log_entry/log_entry.dart @@ -211,7 +211,7 @@ class _LogEntryScreenState extends State { context: context, isNew: _isNew, onSave: handleSaveAction, - onDiscard: (context) => Navigator.pushReplacementNamed(context, '/log'), + onDiscard: (context) => Navigator.pop(context), ); } else { Navigator.pop(context); diff --git a/lib/screens/log/log_entry/log_meal_detail.dart b/lib/screens/log/log_entry/log_meal_detail.dart index 7f0c01b..9a04d00 100644 --- a/lib/screens/log/log_entry/log_meal_detail.dart +++ b/lib/screens/log/log_entry/log_meal_detail.dart @@ -392,7 +392,7 @@ class _LogMealDetailScreenState extends State { controller: _amountController, label: 'Amount', suffix: _mealPortionType?.value, - min: 1, + min: 0, onChanged: updateAmount, ), Row( diff --git a/lib/screens/log/log_event/log_event_list.dart b/lib/screens/log/log_event/log_event_list.dart index b460f8e..a81d3b8 100644 --- a/lib/screens/log/log_event/log_event_list.dart +++ b/lib/screens/log/log_event/log_event_list.dart @@ -158,8 +158,8 @@ class _LogEventListScreenState extends State { const SizedBox(width: 24), Expanded( child: Text( - event.title ?? event.eventType.target?.value ?? '', - // style: Theme.of(context).textTheme.subtitle2, + (event.title ?? event.eventType.target?.value ?? '').toUpperCase(), + style: Theme.of(context).textTheme.subtitle2, ), ), ], diff --git a/lib/screens/recipe/recipe_detail.dart b/lib/screens/recipe/recipe_detail.dart new file mode 100644 index 0000000..963c92d --- /dev/null +++ b/lib/screens/recipe/recipe_detail.dart @@ -0,0 +1,328 @@ +import 'package:diameter/components/detail.dart'; +import 'package:diameter/components/dialogs.dart'; +import 'package:diameter/components/dropdown.dart'; +import 'package:diameter/components/forms.dart'; +import 'package:diameter/models/ingredient.dart'; +import 'package:diameter/models/meal.dart'; +import 'package:diameter/models/recipe.dart'; +import 'package:diameter/models/settings.dart'; +import 'package:diameter/navigation.dart'; +import 'package:diameter/screens/meal/meal_detail.dart'; +import 'package:flutter/material.dart'; + +class RecipeDetailScreen extends StatefulWidget { + static const String routeName = '/recipe'; + final int id; + + const RecipeDetailScreen({Key? key, this.id = 0}) : super(key: key); + + @override + _RecipeDetailScreenState createState() => _RecipeDetailScreenState(); +} + +class _RecipeDetailScreenState extends State { + Recipe? _recipe; + List _ingredients = []; + + bool _isNew = true; + bool _isSaving = false; + + final GlobalKey _recipeForm = GlobalKey(); + final ScrollController _scrollController = ScrollController(); + + final _nameController = TextEditingController(text: ''); + final _servingsController = TextEditingController(text: ''); + final _notesController = TextEditingController(text: ''); + + final List _ingredientControllers = []; + final List _ingredientAmountControllers = []; + + List _meals = []; + + @override + void initState() { + super.initState(); + + reload(); + + _meals = Meal.getAll(); + + if (_recipe != null) { + _nameController.text = _recipe!.name; + _servingsController.text = (_recipe!.servings ?? '').toString(); + _notesController.text = _recipe!.notes ?? ''; + + if (_ingredients.isNotEmpty) { + for (Ingredient ingredient in _ingredients) { + _ingredientControllers.add( + TextEditingController(text: ingredient.ingredient.target?.value)); + _ingredientAmountControllers + .add(TextEditingController(text: ingredient.amount.toString())); + } + } + } + } + + void reload({String? message}) { + if (widget.id != 0) { + setState(() { + _recipe = Recipe.get(widget.id); + _ingredients = Ingredient.getAllForRecipe(widget.id); + }); + } + _isNew = _recipe == null; + + setState(() { + if (message != null) { + var snackBar = SnackBar( + content: Text(message), + duration: const Duration(seconds: 2), + ); + ScaffoldMessenger.of(context) + ..removeCurrentSnackBar() + ..showSnackBar(snackBar); + } + }); + } + + void onAddIngredient() { + final newIngredient = Ingredient(amount: 0); + setState(() { + newIngredient.recipe.target = _recipe; + _ingredients.add(newIngredient); + _ingredientControllers.add(TextEditingController(text: '')); + _ingredientAmountControllers.add(TextEditingController(text: '')); + }); + } + + void handleSaveAction({bool close = false}) async { + setState(() { + _isSaving = true; + }); + if (_recipeForm.currentState!.validate()) { + Recipe recipe = Recipe( + id: widget.id, + name: _nameController.text, + servings: double.tryParse(_servingsController.text), + notes: _notesController.text, + ); + Recipe.put(recipe); + List ingredients = _ingredients.map((ingredient) { + if (ingredient.id != 0 && + (!ingredient.ingredient.hasValue || ingredient.amount == 0)) { + ingredient.deleted = true; + } + return ingredient; + }).toList(); + ingredients.retainWhere((ingredient) { + return ingredient.id != 0 || + (ingredient.amount > 0 && ingredient.ingredient.hasValue); + }); + Ingredient.putMany(ingredients); + + if (close) { + Navigator.pop(context, ['${_isNew ? 'New' : ''} Recipe Saved', recipe]); + } else { + if (_isNew) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RecipeDetailScreen(id: recipe.id), + ), + ).then((result) => Navigator.pop(context, result)); + } else { + reload(message: 'Recipe saved'); + } + } + + setState(() { + _isSaving = false; + }); + } + } + + void handleCancelAction() { + if (Settings.get().showConfirmationDialogOnCancel && + ((_isNew && + (_nameController.text != '' || + _servingsController.text != '' || + _notesController.text != '')) || + (!_isNew && + (_nameController.text != _recipe!.name || + _servingsController.text != + (_recipe!.servings ?? '').toString() || + _notesController.text != (_recipe!.notes ?? ''))))) { + Dialogs.showCancelConfirmationDialog( + context: context, + isNew: _isNew, + onSave: handleSaveAction, + ); + } else { + Navigator.pop(context); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_isNew ? 'New Recipe' : _recipe!.name), + ), + drawer: const Navigation(currentLocation: RecipeDetailScreen.routeName), + body: Scrollbar( + controller: _scrollController, + child: SingleChildScrollView( + controller: _scrollController, + child: Column( + children: [ + FormWrapper( + formState: _recipeForm, + fields: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Name', + ), + validator: (value) { + if (value!.trim().isEmpty) { + return 'Empty title'; + } + return null; + }, + ), + NumberFormField( + controller: _servingsController, + label: 'Servings', + suffix: ' portions', + min: 0, + onChanged: (value) { + if ((value ?? 0) >= 0) { + setState(() { + _servingsController.text = (value ?? 0).toString(); + }); + } + }, + ), + TextFormField( + keyboardType: TextInputType.multiline, + controller: _notesController, + decoration: const InputDecoration( + labelText: 'Notes', + ), + minLines: 2, + maxLines: 5, + ), + const Divider(), + GestureDetector( + onTap: onAddIngredient, + child: Row( + children: [ + Text( + 'INGREDIENTS', + style: Theme.of(context).textTheme.subtitle2, + ), + const Spacer(), + IconButton( + onPressed: onAddIngredient, + icon: const Icon(Icons.add), + ), + ], + ), + ), + ], + ), + !_isNew && _ingredients.isNotEmpty + ? ListBody( + children: _ingredients.map((item) { + final ingredient = item.ingredient.target; + final index = _ingredients.indexOf(item); + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10.0, vertical: 5.0), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: AutoCompleteDropdownButton( + controller: _ingredientControllers[index], + selectedItem: ingredient, + label: 'Meal Category', + items: _meals, + onChanged: (value) { + setState(() { + _ingredients[index] + .ingredient + .target = value; + _ingredientControllers[index].text = + value?.value ?? ''; + }); + }, + ), + ), + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + ingredient == null + ? const MealDetailScreen() + : MealDetailScreen( + id: ingredient.id), + ), + ).then((result) { + _ingredients[index].ingredient.target = + result?[1]; + _ingredientControllers[index].text = + result?[1].value ?? ''; + reload(message: result?[0]); + }); + }, + icon: Icon(ingredient == null + ? Icons.add + : Icons.edit), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: NumberFormField( + controller: + _ingredientAmountControllers[index], + label: 'Amount', + suffix: Settings.nutritionMeasurementSuffix, + min: 0, + onChanged: (value) { + if ((value ?? 0) >= 0) { + setState(() { + _ingredients[index].amount = value ?? 0; + _ingredientAmountControllers[index] + .text = (value ?? 0).toString(); + }); + } + }, + ), + ), + ], + ), + ); + }).toList(), + ) + : Center( + child: Text(_isNew + ? 'Save the Recipe in order to add ingredients!' + : 'You have not added any Ingredients yet!'), + ) + ], + ), + ), + ), + bottomNavigationBar: DetailBottomRow( + onCancel: handleCancelAction, + onAction: _isSaving ? null : handleSaveAction, + onMiddleAction: _isSaving ? null : () => handleSaveAction(close: true), + ), + ); + } +} diff --git a/lib/screens/recipe/recipe_list.dart b/lib/screens/recipe/recipe_list.dart new file mode 100644 index 0000000..dc47f23 --- /dev/null +++ b/lib/screens/recipe/recipe_list.dart @@ -0,0 +1,200 @@ +import 'package:diameter/components/dialogs.dart'; +import 'package:diameter/models/ingredient.dart'; +import 'package:diameter/models/recipe.dart'; +import 'package:diameter/models/settings.dart'; +import 'package:diameter/navigation.dart'; +import 'package:diameter/screens/recipe/recipe_detail.dart'; +import 'package:flutter/material.dart'; + +class RecipeListScreen extends StatefulWidget { + static const String routeName = '/recipes'; + + const RecipeListScreen({Key? key}) : super(key: key); + + @override + _RecipeListScreenState createState() => _RecipeListScreenState(); +} + +class _RecipeListScreenState extends State { + List _recipes = []; + + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + reload(); + } + + void reload({String? message}) { + setState(() { + _recipes = Recipe.getAll(); + }); + setState(() { + if (message != null) { + var snackBar = SnackBar( + content: Text(message), + duration: const Duration(seconds: 2), + ); + ScaffoldMessenger.of(context) + ..removeCurrentSnackBar() + ..showSnackBar(snackBar); + } + }); + } + + void onDelete(Recipe recipe) { + Recipe.remove(recipe.id); + reload(message: 'Recipe deleted'); + } + + void handleDeleteAction(Recipe recipe) async { + if (Settings.get().showConfirmationDialogOnDelete) { + Dialogs.showConfirmationDialog( + context: context, + onConfirm: () => onDelete(recipe), + message: 'Are you sure you want to delete this Recipe?', + ); + } else { + onDelete(recipe); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Recipes'), actions: [ + IconButton(onPressed: reload, icon: const Icon(Icons.refresh)) + ]), + drawer: const Navigation(currentLocation: RecipeListScreen.routeName), + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: _recipes.isNotEmpty + ? Scrollbar( + controller: _scrollController, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(10.0), + itemCount: _recipes.length, + itemBuilder: (context, index) { + final recipe = _recipes[index]; + final carbsRatio = + Ingredient.getCarbsRatioForRecipe(recipe.id); + final carbsPerPortion = Recipe.getCarbsPerPortion(recipe.id); + return Card( + child: ListTile( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + RecipeDetailScreen(id: recipe.id), + ), + ).then((result) => reload(message: result?[0])); + }, + title: Text( + recipe.name.toUpperCase(), + style: Theme.of(context).textTheme.subtitle2, + ), + subtitle: Padding( + padding: + const EdgeInsets.symmetric(vertical: 10.0), + child: Row( + children: [ + Column( + children: [ + Text(recipe.notes ?? ''), + ], + ), + Expanded( + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: + ((carbsRatio ?? 0) > 0) + ? [ + Text(carbsRatio!.toString()), + const Text('% carbs', + textScaleFactor: 0.75), + ] + : [], + ), + ), + Expanded( + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: + (recipe.servings != null) + ? [ + Text(recipe.servings! + .toStringAsPrecision(3)), + const Text('servings', + textScaleFactor: 0.75), + ] + : [], + ), + ), + Expanded( + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: + ((carbsPerPortion ?? 0) > 0) + ? [ + Text(carbsPerPortion! + .toStringAsPrecision(3)), + Text( + '${Settings.nutritionMeasurementSuffix} carbs per serving', + textScaleFactor: 0.75), + ] + : [], + ), + ), + ], + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + onPressed: () => handleDeleteAction(recipe), + icon: const Icon(Icons.delete, + color: Colors.blue), + ) + ], + ), + ), + ); + }, + ), + ) + : const Center( + child: Text('You have not created any Recipes yet!'), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const RecipeDetailScreen(), + ), + ).then((result) => reload(message: result?[0])); + }, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index e49a6da..1b8e3eb 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -21,11 +21,11 @@ class Utils { static double calculateCarbsRatio( double carbsPerPortion, double portionSize) { - return Utils.roundToDecimalPlaces(carbsPerPortion * 100 / portionSize, 2); + return portionSize > 0 ? Utils.roundToDecimalPlaces(carbsPerPortion * 100 / portionSize, 2) : 0; } static double calculatePortionSize( double carbsRatio, double carbsPerPortion) { - return Utils.roundToDecimalPlaces(carbsPerPortion * 100 / carbsRatio, 2); + return carbsRatio > 0 ? Utils.roundToDecimalPlaces(carbsPerPortion * 100 / carbsRatio, 2) : 0; } }