recipe list and detail screens

This commit is contained in:
spinel 2021-12-11 22:02:35 +01:00
parent 5e60ea09ce
commit 6b4f588d5d
13 changed files with 668 additions and 124 deletions

19
TODO
View File

@ -1,5 +1,6 @@
MAIN TASKS: MAIN TASKS:
General/Framework: General/Framework:
☐ create app icon
☐ show indicator and make all fields readonly if user somehow gets to a deleted record detail view ☐ 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) ☐ clean up controllers (dispose method of each stateful widget)
☐ account for deleted/disabled elements in dropdowns ☐ account for deleted/disabled elements in dropdowns
@ -8,14 +9,9 @@ MAIN TASKS:
☐ implement component for durations ☐ implement component for durations
☐ change placement of delete and floating button because its very easy to accidentally hit delete ☐ change placement of delete and floating button because its very easy to accidentally hit delete
Recipe: Recipe:
✔ add model for recipe @done(21-12-11 02:23) ✔ recipe list screen @done(21-12-11 22:01)
✔ add model for ingredient (relation betweeen recipe and meal) @done(21-12-11 02:23) ✔ recipe detail screen @done(21-12-11 22:01)
☐ recipe list screen
☐ recipe detail screen
☐ add functionality to create a meal from a recipe ☐ 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: Event Types:
☐ add colors as indicators for log entries (and later graphs in reports) ☐ add colors as indicators for log entries (and later graphs in reports)
Settings: Settings:
@ -31,12 +27,14 @@ FUTURE TASKS:
General/Framework: General/Framework:
☐ setup objectbox sync server ☐ setup objectbox sync server
☐ add explanations to each section ☐ 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 ☐ evaluate if some fields should be readonly instead of completely hidden
☐ alternate languages ☐ alternate languages
☐ log hba1c ☐ log hba1c
Reports: Reports:
☐ evaluate what type of reports there should be ☐ evaluate what type of reports there should be
☐ meal tweaking
☐ bolus tweaking
☐ daily graph (showing glucose curve, events, boli and meals)
Log Overview: Log Overview:
☐ add pagination ☐ add pagination
☐ add filters ☐ add filters
@ -50,6 +48,11 @@ FUTURE TASKS:
☐ option to switch theme ☐ option to switch theme
Archive: 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 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) ✔ 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) ✔ hide details like accuracies etc when picking meals @done(21-12-10 06:12) @project(MAIN TASKS.General/Framework)

View File

@ -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_portion_type_list.dart';
import 'package:diameter/screens/meal/meal_source_detail.dart'; import 'package:diameter/screens/meal/meal_source_detail.dart';
import 'package:diameter/screens/meal/meal_source_list.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:diameter/settings.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:diameter/screens/accuracy_list.dart'; import 'package:diameter/screens/accuracy_list.dart';
@ -61,6 +63,8 @@ Future<void> main() async {
Routes.accuracy: (context) => const AccuracyDetailScreen(), Routes.accuracy: (context) => const AccuracyDetailScreen(),
Routes.meals: (context) => const MealListScreen(), Routes.meals: (context) => const MealListScreen(),
Routes.meal: (context) => const MealDetailScreen(), Routes.meal: (context) => const MealDetailScreen(),
Routes.recipes: (context) => const RecipeListScreen(),
Routes.recipe: (context) => const RecipeDetailScreen(),
Routes.mealCategories: (context) => const MealCategoryListScreen(), Routes.mealCategories: (context) => const MealCategoryListScreen(),
Routes.mealCategory: (context) => const MealCategoryDetailScreen(), Routes.mealCategory: (context) => const MealCategoryDetailScreen(),
Routes.mealPortionTypes: (context) => Routes.mealPortionTypes: (context) =>

View File

@ -1,6 +1,7 @@
import 'package:diameter/main.dart'; import 'package:diameter/main.dart';
import 'package:diameter/models/meal.dart'; import 'package:diameter/models/meal.dart';
import 'package:diameter/models/recipe.dart'; import 'package:diameter/models/recipe.dart';
import 'package:diameter/utils/utils.dart';
import 'package:objectbox/objectbox.dart'; import 'package:objectbox/objectbox.dart';
import 'package:diameter/objectbox.g.dart' show Ingredient_, Recipe_; import 'package:diameter/objectbox.g.dart' show Ingredient_, Recipe_;
@ -25,9 +26,54 @@ class Ingredient {
required this.amount, required this.amount,
}); });
// methods
static Ingredient? get(int id) => box.get(id);
static void put(Ingredient ingredient) => box.put(ingredient);
static void putMany(List<Ingredient> ingredients) => box.putMany(ingredients);
static List<Ingredient> getAllForRecipe(int id) { static List<Ingredient> getAllForRecipe(int id) {
QueryBuilder<Ingredient> builder = box.query(Ingredient_.deleted.equals(false)); QueryBuilder<Ingredient> builder =
box.query(Ingredient_.deleted.equals(false));
builder.link(Ingredient_.recipe, Recipe_.id.equals(id)); builder.link(Ingredient_.recipe, Recipe_.id.equals(id));
return builder.build().find(); return builder.build().find();
} }
}
static double? getCarbsRatioForRecipe(int id) {
double carbsSum = 0.0;
double totalWeight = 0.0;
List<Ingredient> 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<Ingredient> 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 ?? '';
}
}

View File

@ -1,5 +1,7 @@
import 'package:diameter/main.dart'; import 'package:diameter/main.dart';
import 'package:diameter/models/ingredient.dart';
import 'package:diameter/models/meal.dart'; import 'package:diameter/models/meal.dart';
import 'package:diameter/utils/utils.dart';
import 'package:objectbox/objectbox.dart'; import 'package:objectbox/objectbox.dart';
import 'package:diameter/objectbox.g.dart' show Recipe_; import 'package:diameter/objectbox.g.dart' show Recipe_;
@ -12,38 +14,43 @@ class Recipe {
int id; int id;
bool deleted; bool deleted;
String name; String name;
double? carbsRatio; double? servings;
double? portionSize;
double? carbsPerPortion;
int? delayedBolusDuration;
double? delayedBolusPercentage;
String? notes; String? notes;
// relations // relations
final portion = ToOne<Meal>(); final portion = ToOne<Meal>();
// constructor // constructor
Recipe({ Recipe({
this.id = 0, this.id = 0,
this.deleted = false, this.deleted = false,
this.name = '', this.name = '',
this.carbsRatio, this.servings,
this.portionSize,
this.carbsPerPortion,
this.delayedBolusDuration,
this.delayedBolusPercentage,
this.notes, this.notes,
}); });
// methods // methods
static Recipe? get(int id) => box.get(id); static Recipe? get(int id) => box.get(id);
static void put(Recipe recipe) => box.put(recipe); static void put(Recipe recipe) => box.put(recipe);
static List<Recipe> getAll() { static List<Recipe> getAll() {
QueryBuilder<Recipe> builder = box.query(Recipe_.deleted.equals(false))..order(Recipe_.name); QueryBuilder<Recipe> builder = box.query(Recipe_.deleted.equals(false))
..order(Recipe_.name);
return builder.build().find(); 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) { static void remove(int id) {
final item = box.get(id); final item = box.get(id);
if (item != null) { if (item != null) {

View File

@ -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_portion_type_list.dart';
import 'package:diameter/screens/meal/meal_source_detail.dart'; import 'package:diameter/screens/meal/meal_source_detail.dart';
import 'package:diameter/screens/meal/meal_source_list.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:diameter/settings.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -45,6 +47,10 @@ class Routes {
static const List<String> logEventTypeRoutes = [logEventType, logEventTypes]; static const List<String> logEventTypeRoutes = [logEventType, logEventTypes];
static const String events = LogEventListScreen.routeName; static const String events = LogEventListScreen.routeName;
static const String recipe = RecipeDetailScreen.routeName;
static const String recipes = RecipeListScreen.routeName;
static const List<String> recipeRoutes = [recipe, recipes];
static const String meal = MealDetailScreen.routeName; static const String meal = MealDetailScreen.routeName;
static const String meals = MealListScreen.routeName; static const String meals = MealListScreen.routeName;
static const List<String> mealRoutes = [meal, meals]; static const List<String> mealRoutes = [meal, meals];
@ -107,9 +113,17 @@ class _NavigationState extends State<Navigation> {
}, },
selected: widget.currentLocation == Routes.events, 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( ListTile(
title: const Text('Meals'), title: const Text('Meals'),
leading: const Icon(Icons.restaurant), leading: const Icon(Icons.dinner_dining),
onTap: () { onTap: () {
selectDestination(Routes.meals); selectDestination(Routes.meals);
}, },

View File

@ -943,7 +943,7 @@
}, },
{ {
"id": "18:6497942314956341514", "id": "18:6497942314956341514",
"lastPropertyId": "10:4370359747396560337", "lastPropertyId": "11:8488657312300528492",
"name": "Recipe", "name": "Recipe",
"flags": 2, "flags": 2,
"properties": [ "properties": [
@ -963,31 +963,6 @@
"name": "name", "name": "name",
"type": 9 "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", "id": "9:8593446427752839266",
"name": "notes", "name": "notes",
@ -1000,6 +975,11 @@
"flags": 520, "flags": 520,
"indexId": "29:5110151182694376118", "indexId": "29:5110151182694376118",
"relationTarget": "Meal" "relationTarget": "Meal"
},
{
"id": "11:8488657312300528492",
"name": "servings",
"type": 8
} }
], ],
"relations": [] "relations": []
@ -1085,7 +1065,12 @@
3282706593658092097, 3282706593658092097,
596980591281311896, 596980591281311896,
3633551763915044903, 3633551763915044903,
2215708755581938580 2215708755581938580,
241621230513128588,
4678123663117222609,
780211923138281722,
763575433624979013,
1225271130099322691
], ],
"retiredRelationUids": [], "retiredRelationUids": [],
"version": 1 "version": 1

View File

@ -930,7 +930,7 @@ final _entities = <ModelEntity>[
ModelEntity( ModelEntity(
id: const IdUid(18, 6497942314956341514), id: const IdUid(18, 6497942314956341514),
name: 'Recipe', name: 'Recipe',
lastPropertyId: const IdUid(10, 4370359747396560337), lastPropertyId: const IdUid(11, 8488657312300528492),
flags: 2, flags: 2,
properties: <ModelProperty>[ properties: <ModelProperty>[
ModelProperty( ModelProperty(
@ -948,31 +948,6 @@ final _entities = <ModelEntity>[
name: 'name', name: 'name',
type: 9, type: 9,
flags: 0), 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( ModelProperty(
id: const IdUid(9, 8593446427752839266), id: const IdUid(9, 8593446427752839266),
name: 'notes', name: 'notes',
@ -984,7 +959,12 @@ final _entities = <ModelEntity>[
type: 11, type: 11,
flags: 520, flags: 520,
indexId: const IdUid(29, 5110151182694376118), indexId: const IdUid(29, 5110151182694376118),
relationTarget: 'Meal') relationTarget: 'Meal'),
ModelProperty(
id: const IdUid(11, 8488657312300528492),
name: 'servings',
type: 8,
flags: 0)
], ],
relations: <ModelRelation>[], relations: <ModelRelation>[],
backlinks: <ModelBacklink>[]), backlinks: <ModelBacklink>[]),
@ -1080,7 +1060,12 @@ ModelDefinition getObjectBoxModel() {
3282706593658092097, 3282706593658092097,
596980591281311896, 596980591281311896,
3633551763915044903, 3633551763915044903,
2215708755581938580 2215708755581938580,
241621230513128588,
4678123663117222609,
780211923138281722,
763575433624979013,
1225271130099322691
], ],
retiredRelationUids: const [], retiredRelationUids: const [],
modelVersion: 5, modelVersion: 5,
@ -1922,17 +1907,13 @@ ModelDefinition getObjectBoxModel() {
final nameOffset = fbb.writeString(object.name); final nameOffset = fbb.writeString(object.name);
final notesOffset = final notesOffset =
object.notes == null ? null : fbb.writeString(object.notes!); object.notes == null ? null : fbb.writeString(object.notes!);
fbb.startTable(11); fbb.startTable(12);
fbb.addInt64(0, object.id); fbb.addInt64(0, object.id);
fbb.addBool(1, object.deleted); fbb.addBool(1, object.deleted);
fbb.addOffset(2, nameOffset); 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.addOffset(8, notesOffset);
fbb.addInt64(9, object.portion.targetId); fbb.addInt64(9, object.portion.targetId);
fbb.addFloat64(10, object.servings);
fbb.finish(fbb.endTable()); fbb.finish(fbb.endTable());
return object.id; return object.id;
}, },
@ -1946,16 +1927,8 @@ ModelDefinition getObjectBoxModel() {
const fb.BoolReader().vTableGet(buffer, rootOffset, 6, false), const fb.BoolReader().vTableGet(buffer, rootOffset, 6, false),
name: name:
const fb.StringReader().vTableGet(buffer, rootOffset, 8, ''), const fb.StringReader().vTableGet(buffer, rootOffset, 8, ''),
carbsRatio: const fb.Float64Reader() servings: const fb.Float64Reader()
.vTableGetNullable(buffer, rootOffset, 10), .vTableGetNullable(buffer, rootOffset, 24),
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),
notes: const fb.StringReader() notes: const fb.StringReader()
.vTableGetNullable(buffer, rootOffset, 20)); .vTableGetNullable(buffer, rootOffset, 20));
object.portion.targetId = object.portion.targetId =
@ -2609,32 +2582,16 @@ class Recipe_ {
/// see [Recipe.name] /// see [Recipe.name]
static final name = QueryStringProperty<Recipe>(_entities[16].properties[2]); static final name = QueryStringProperty<Recipe>(_entities[16].properties[2]);
/// see [Recipe.carbsRatio]
static final carbsRatio =
QueryDoubleProperty<Recipe>(_entities[16].properties[3]);
/// see [Recipe.portionSize]
static final portionSize =
QueryDoubleProperty<Recipe>(_entities[16].properties[4]);
/// see [Recipe.carbsPerPortion]
static final carbsPerPortion =
QueryDoubleProperty<Recipe>(_entities[16].properties[5]);
/// see [Recipe.delayedBolusDuration]
static final delayedBolusDuration =
QueryIntegerProperty<Recipe>(_entities[16].properties[6]);
/// see [Recipe.delayedBolusPercentage]
static final delayedBolusPercentage =
QueryDoubleProperty<Recipe>(_entities[16].properties[7]);
/// see [Recipe.notes] /// see [Recipe.notes]
static final notes = QueryStringProperty<Recipe>(_entities[16].properties[8]); static final notes = QueryStringProperty<Recipe>(_entities[16].properties[3]);
/// see [Recipe.portion] /// see [Recipe.portion]
static final portion = static final portion =
QueryRelationToOne<Recipe, Meal>(_entities[16].properties[9]); QueryRelationToOne<Recipe, Meal>(_entities[16].properties[4]);
/// see [Recipe.servings]
static final servings =
QueryDoubleProperty<Recipe>(_entities[16].properties[5]);
} }
/// [Ingredient] entity fields to define ObjectBox queries. /// [Ingredient] entity fields to define ObjectBox queries.

View File

@ -211,7 +211,7 @@ class _LogEntryScreenState extends State<LogEntryScreen> {
context: context, context: context,
isNew: _isNew, isNew: _isNew,
onSave: handleSaveAction, onSave: handleSaveAction,
onDiscard: (context) => Navigator.pushReplacementNamed(context, '/log'), onDiscard: (context) => Navigator.pop(context),
); );
} else { } else {
Navigator.pop(context); Navigator.pop(context);

View File

@ -392,7 +392,7 @@ class _LogMealDetailScreenState extends State<LogMealDetailScreen> {
controller: _amountController, controller: _amountController,
label: 'Amount', label: 'Amount',
suffix: _mealPortionType?.value, suffix: _mealPortionType?.value,
min: 1, min: 0,
onChanged: updateAmount, onChanged: updateAmount,
), ),
Row( Row(

View File

@ -158,8 +158,8 @@ class _LogEventListScreenState extends State<LogEventListScreen> {
const SizedBox(width: 24), const SizedBox(width: 24),
Expanded( Expanded(
child: Text( child: Text(
event.title ?? event.eventType.target?.value ?? '', (event.title ?? event.eventType.target?.value ?? '').toUpperCase(),
// style: Theme.of(context).textTheme.subtitle2, style: Theme.of(context).textTheme.subtitle2,
), ),
), ),
], ],

View File

@ -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<RecipeDetailScreen> {
Recipe? _recipe;
List<Ingredient> _ingredients = [];
bool _isNew = true;
bool _isSaving = false;
final GlobalKey<FormState> _recipeForm = GlobalKey<FormState>();
final ScrollController _scrollController = ScrollController();
final _nameController = TextEditingController(text: '');
final _servingsController = TextEditingController(text: '');
final _notesController = TextEditingController(text: '');
final List<TextEditingController> _ingredientControllers = [];
final List<TextEditingController> _ingredientAmountControllers = [];
List<Meal> _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<Ingredient> 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: <Widget>[
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: <Widget>[
Row(
children: [
Expanded(
child: AutoCompleteDropdownButton<Meal>(
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),
),
);
}
}

View File

@ -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<RecipeListScreen> {
List<Recipe> _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: <Widget>[
IconButton(onPressed: reload, icon: const Icon(Icons.refresh))
]),
drawer: const Navigation(currentLocation: RecipeListScreen.routeName),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
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),
),
);
}
}

View File

@ -21,11 +21,11 @@ class Utils {
static double calculateCarbsRatio( static double calculateCarbsRatio(
double carbsPerPortion, double portionSize) { double carbsPerPortion, double portionSize) {
return Utils.roundToDecimalPlaces(carbsPerPortion * 100 / portionSize, 2); return portionSize > 0 ? Utils.roundToDecimalPlaces(carbsPerPortion * 100 / portionSize, 2) : 0;
} }
static double calculatePortionSize( static double calculatePortionSize(
double carbsRatio, double carbsPerPortion) { double carbsRatio, double carbsPerPortion) {
return Utils.roundToDecimalPlaces(carbsPerPortion * 100 / carbsRatio, 2); return carbsRatio > 0 ? Utils.roundToDecimalPlaces(carbsPerPortion * 100 / carbsRatio, 2) : 0;
} }
} }