Compare commits
10 Commits
7eea286c7f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 40429832ab | |||
| 2b8e1dc2cb | |||
| b711276674 | |||
| 2f6440f78d | |||
| 28c1b8d56b | |||
| 094054ec00 | |||
| 681532f557 | |||
| 509f1fd695 | |||
| 3186ec8780 | |||
| 6afab33d49 |
@@ -155,4 +155,37 @@ abstract class CookingUnits {
|
|||||||
};
|
};
|
||||||
return abbreviations[unit.name] ?? '';
|
return abbreviations[unit.name] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<Unit> get values => [
|
||||||
|
teaspoon,
|
||||||
|
tablespoon,
|
||||||
|
fluidOunce,
|
||||||
|
cup,
|
||||||
|
pint,
|
||||||
|
quart,
|
||||||
|
gallon,
|
||||||
|
milliliter,
|
||||||
|
liter,
|
||||||
|
ounce,
|
||||||
|
pound,
|
||||||
|
gram,
|
||||||
|
kilogram,
|
||||||
|
piece,
|
||||||
|
dozen,
|
||||||
|
pinch,
|
||||||
|
dash,
|
||||||
|
drop,
|
||||||
|
stick,
|
||||||
|
can,
|
||||||
|
batch,
|
||||||
|
handful,
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<Unit> getByType(UnitType type) {
|
||||||
|
return values.where((unit) => unit.type == type).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Unit> getBySystem(System system) {
|
||||||
|
return values.where((unit) => unit.system == system).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ class IngredientListEntry {
|
|||||||
final Unit unit;
|
final Unit unit;
|
||||||
final bool optional;
|
final bool optional;
|
||||||
|
|
||||||
IngredientListEntry(this.ingredient, this.amount, this.unit, this.optional);
|
IngredientListEntry({
|
||||||
|
required this.ingredient,
|
||||||
|
required this.amount,
|
||||||
|
required this.unit,
|
||||||
|
this.optional = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class Recipe {
|
|||||||
this.id = -1,
|
this.id = -1,
|
||||||
this.title = '',
|
this.title = '',
|
||||||
this.description = '',
|
this.description = '',
|
||||||
this.difficulty = Difficulty.notSelected,
|
this.difficulty,
|
||||||
required this.datePublished,
|
required this.datePublished,
|
||||||
this.prepTime = const Duration(),
|
this.prepTime = const Duration(),
|
||||||
this.cookTime = const Duration(),
|
this.cookTime = const Duration(),
|
||||||
@@ -15,13 +15,14 @@ class Recipe {
|
|||||||
this.ingredients = const [],
|
this.ingredients = const [],
|
||||||
this.keywords = const [],
|
this.keywords = const [],
|
||||||
this.steps = const [],
|
this.steps = const [],
|
||||||
|
this.servingSize = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Duration cookTime;
|
final Duration cookTime;
|
||||||
final Cuisine? cuisine;
|
final Cuisine? cuisine;
|
||||||
final DateTime datePublished;
|
final DateTime datePublished;
|
||||||
final String description;
|
final String description;
|
||||||
final Difficulty difficulty;
|
final Difficulty? difficulty;
|
||||||
final int id;
|
final int id;
|
||||||
final List<IngredientListEntry> ingredients;
|
final List<IngredientListEntry> ingredients;
|
||||||
final List<String> keywords;
|
final List<String> keywords;
|
||||||
@@ -29,6 +30,7 @@ class Recipe {
|
|||||||
final Duration prepTime;
|
final Duration prepTime;
|
||||||
final List<String> steps;
|
final List<String> steps;
|
||||||
final String title;
|
final String title;
|
||||||
|
final int servingSize;
|
||||||
|
|
||||||
Duration get totalTime => cookTime + prepTime;
|
Duration get totalTime => cookTime + prepTime;
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ class Recipe {
|
|||||||
List<String>? keywords,
|
List<String>? keywords,
|
||||||
MealCategory? mealCategory,
|
MealCategory? mealCategory,
|
||||||
Cuisine? cuisine,
|
Cuisine? cuisine,
|
||||||
|
int? servingSize,
|
||||||
}) {
|
}) {
|
||||||
return Recipe(
|
return Recipe(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -66,6 +69,7 @@ class Recipe {
|
|||||||
: List<String>.from(this.keywords),
|
: List<String>.from(this.keywords),
|
||||||
mealCategory: mealCategory ?? this.mealCategory,
|
mealCategory: mealCategory ?? this.mealCategory,
|
||||||
cuisine: cuisine ?? this.cuisine,
|
cuisine: cuisine ?? this.cuisine,
|
||||||
|
servingSize: servingSize ?? this.servingSize,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,91 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class IngredientsPage extends StatelessWidget {
|
import '../../services/providers/recipe_provider.dart';
|
||||||
|
import '../../widgets/ingredient_bottomsheet.dart';
|
||||||
|
|
||||||
|
class IngredientsPage extends StatefulWidget {
|
||||||
const IngredientsPage({super.key});
|
const IngredientsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<IngredientsPage> createState() => _IngredientsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IngredientsPageState extends State<IngredientsPage> {
|
||||||
|
late RecipeProvider _recipeProvider;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Center(child: const Text('Ingredients'));
|
_recipeProvider = context.watch<RecipeProvider>();
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
ListView.separated(
|
||||||
|
padding: const EdgeInsets.only(bottom: 88),
|
||||||
|
itemCount: _recipeProvider.recipe.ingredients.length,
|
||||||
|
itemBuilder: _ingredientListBuilder,
|
||||||
|
separatorBuilder: (context, index) => const Divider(),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16, right: 16),
|
||||||
|
child: FloatingActionButton(
|
||||||
|
onPressed: _openIngredientBottomSheet,
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openIngredientBottomSheet() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => IngredientBottomsheet(
|
||||||
|
onSubmitted: _recipeProvider.addIngredient,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? _ingredientListBuilder(BuildContext context, int index) {
|
||||||
|
final ingredient = _recipeProvider.recipe.ingredients.elementAt(index);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(ingredient.ingredient.title),
|
||||||
|
subtitle: ingredient.optional ? const Text('optional') : null,
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
ingredient.amount.toString(),
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyLarge!
|
||||||
|
.copyWith(color: Theme.of(context).colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
|
||||||
|
Text(
|
||||||
|
ingredient.unit.name,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyLarge!
|
||||||
|
.copyWith(color: Theme.of(context).colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
const Padding(padding: EdgeInsets.symmetric(horizontal: 5)),
|
||||||
|
SizedBox(
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: () => _recipeProvider.removeIngredient(ingredient),
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../services/providers/recipe_provider.dart';
|
import '../../services/providers/recipe_provider.dart';
|
||||||
import '../../src/enums.dart';
|
import '../../src/enums.dart';
|
||||||
import '../../services/tools.dart'
|
import '../../services/tools.dart' show getEnumValueName;
|
||||||
show getEnumValueName, durationToFormattedString;
|
import '../../widgets/clearing_dropdown_menu.dart';
|
||||||
import '../../widgets/duration_picker.dart';
|
import '../../widgets/cooking_duration_button.dart';
|
||||||
|
|
||||||
class MetadataPage extends StatefulWidget {
|
class MetadataPage extends StatefulWidget {
|
||||||
const MetadataPage({super.key});
|
const MetadataPage({super.key});
|
||||||
@@ -20,119 +21,115 @@ class _MetadataPageState extends State<MetadataPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_recipeProvider = context.watch<RecipeProvider>();
|
_recipeProvider = context.watch<RecipeProvider>();
|
||||||
return Center(
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
|
onChanged: (value) => _recipeProvider.updateTitle(value),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
label: Text('Title'),
|
label: Text('Title'),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
enabledBorder: _recipeProvider.recipe.title.isEmpty
|
||||||
|
? OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.tertiary,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
|
onSubmitted: (value) => TextInputAction.next,
|
||||||
|
),
|
||||||
|
Padding(padding: EdgeInsets.only(top: 10)),
|
||||||
TextField(
|
TextField(
|
||||||
|
onChanged: (value) => _recipeProvider.updateDescription(value),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
label: Text('Description'),
|
label: Text('Description'),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
|
onSubmitted: (value) => TextInputAction.next,
|
||||||
),
|
),
|
||||||
DropdownButton(
|
Padding(padding: EdgeInsets.only(top: 10)),
|
||||||
value: _recipeProvider.recipe.difficulty,
|
TextField(
|
||||||
items: Difficulty.values
|
onChanged: (value) =>
|
||||||
.map(
|
_recipeProvider.updateServingSize(int.parse(value)),
|
||||||
(e) => DropdownMenuItem<Difficulty>(
|
keyboardType: TextInputType.number,
|
||||||
value: e,
|
inputFormatters: [
|
||||||
child: Text(getEnumValueName(e)),
|
FilteringTextInputFormatter.allow(RegExp(r'\d')),
|
||||||
|
],
|
||||||
|
maxLength: 2,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
label: Text('Serving Size'),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
enabledBorder: _recipeProvider.recipe.servingSize == 0
|
||||||
|
? OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.tertiary,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
: null,
|
||||||
onChanged: (value) => setState(() {
|
|
||||||
_recipeProvider.updateDifficulty(value ?? Difficulty.notSelected);
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
|
onSubmitted: (value) => TextInputAction.next,
|
||||||
|
),
|
||||||
|
Padding(padding: EdgeInsets.only(top: 10)),
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
|
enableFilter: false,
|
||||||
|
enableSearch: false,
|
||||||
|
requestFocusOnTap: false,
|
||||||
|
label: Text('Difficulty'),
|
||||||
dropdownMenuEntries:
|
dropdownMenuEntries:
|
||||||
getDropdownMenuEntriesFromEnum(MealCategory.values),
|
getDropdownMenuEntriesFromEnum(Difficulty.values),
|
||||||
enableFilter: true,
|
onSelected: _recipeProvider.updateDifficulty,
|
||||||
label: Text('Category'),
|
|
||||||
),
|
),
|
||||||
DropdownMenu(
|
Padding(padding: EdgeInsets.only(top: 10)),
|
||||||
dropdownMenuEntries: getDropdownMenuEntriesFromEnum(Cuisine.values),
|
ClearingDropdownMenu(
|
||||||
enableFilter: true,
|
entries: getDropdownMenuEntriesFromEnum(MealCategory.values),
|
||||||
label: Text('Cuisine'),
|
label: 'Category',
|
||||||
|
onSelected: _recipeProvider.updateMealCategory,
|
||||||
),
|
),
|
||||||
|
Padding(padding: EdgeInsets.only(top: 10)),
|
||||||
|
ClearingDropdownMenu(
|
||||||
|
entries: getDropdownMenuEntriesFromEnum(Cuisine.values),
|
||||||
|
label: 'Cuisine',
|
||||||
|
onSelected: _recipeProvider.updateCuisine,
|
||||||
|
),
|
||||||
|
Padding(padding: EdgeInsets.only(top: 10)),
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Text('Preparation time'),
|
Text(
|
||||||
_recipeProvider.recipe.prepTime.inMinutes == 0
|
'Prep Time',
|
||||||
? IconButton(
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
onPressed: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => _getDurationDialog(
|
|
||||||
context,
|
|
||||||
_recipeProvider.updatePrepTime,
|
|
||||||
_recipeProvider.recipe.prepTime),
|
|
||||||
),
|
),
|
||||||
icon: Icon(Icons.add),
|
|
||||||
)
|
|
||||||
: TextButton(
|
|
||||||
onPressed: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => _getDurationDialog(
|
|
||||||
context,
|
|
||||||
_recipeProvider.updatePrepTime,
|
|
||||||
_recipeProvider.recipe.prepTime),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
durationToFormattedString(
|
|
||||||
_recipeProvider.recipe.prepTime),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('Cooking time'),
|
|
||||||
_recipeProvider.recipe.prepTime.inMinutes == 0
|
|
||||||
? IconButton(
|
|
||||||
onPressed: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => _getDurationDialog(
|
|
||||||
context,
|
|
||||||
_recipeProvider.updateCookTime,
|
|
||||||
_recipeProvider.recipe.cookTime),
|
|
||||||
),
|
|
||||||
icon: Icon(Icons.add),
|
|
||||||
)
|
|
||||||
: TextButton(
|
|
||||||
onPressed: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => _getDurationDialog(
|
|
||||||
context,
|
|
||||||
_recipeProvider.updateCookTime,
|
|
||||||
_recipeProvider.recipe.cookTime),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
durationToFormattedString(
|
|
||||||
_recipeProvider.recipe.cookTime),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('Total Time'),
|
|
||||||
Padding(padding: EdgeInsets.only(left: 10)),
|
Padding(padding: EdgeInsets.only(left: 10)),
|
||||||
Text(durationToFormattedString(_recipeProvider.recipe.totalTime)),
|
CookingDurationButton(
|
||||||
|
heroTag: 1,
|
||||||
|
selectedDuration: _recipeProvider.recipe.prepTime,
|
||||||
|
onDurationChanged: _recipeProvider.updatePrepTime,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Padding(padding: EdgeInsets.only(top: 10)),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Cooking Time',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
Padding(padding: EdgeInsets.only(left: 10)),
|
||||||
|
CookingDurationButton(
|
||||||
|
heroTag: 2,
|
||||||
|
selectedDuration: _recipeProvider.recipe.cookTime,
|
||||||
|
onDurationChanged: _recipeProvider.updateCookTime,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
List<DropdownMenuEntry<T>> getDropdownMenuEntriesFromEnum<T extends Enum>(
|
List<DropdownMenuEntry<T>> getDropdownMenuEntriesFromEnum<T extends Enum>(
|
||||||
List<T> values) {
|
List<T> values) {
|
||||||
return values
|
return values
|
||||||
.map(
|
.map(
|
||||||
@@ -142,41 +139,5 @@ List<DropdownMenuEntry<T>> getDropdownMenuEntriesFromEnum<T extends Enum>(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Dialog _getDurationDialog(
|
|
||||||
BuildContext context,
|
|
||||||
void Function(Duration duration) update,
|
|
||||||
Duration initialDuration,
|
|
||||||
) {
|
|
||||||
final width = MediaQuery.of(context).size.width * 0.8;
|
|
||||||
final height = MediaQuery.of(context).size.height * 0.6;
|
|
||||||
Duration selectedTime = initialDuration;
|
|
||||||
return Dialog(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
DurationPicker(
|
|
||||||
initialDuration: initialDuration,
|
|
||||||
onChangedCallback: (duration) => selectedTime = duration,
|
|
||||||
height: width * 1.25 > height ? height : width * 1.25,
|
|
||||||
width: width,
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.bottomRight,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(0, 20, 20, 20),
|
|
||||||
child: FloatingActionButton.extended(
|
|
||||||
label: Text('Save'),
|
|
||||||
icon: Icon(Icons.save),
|
|
||||||
onPressed: () {
|
|
||||||
update(selectedTime);
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ class RecipeProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateDifficulty(Difficulty difficulty, {bool silent = false}) {
|
void updateServingSize(int servingSize, {bool silent = false}) {
|
||||||
|
_recipe = _recipe.copyWith(servingSize: servingSize);
|
||||||
|
if (!silent) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateDifficulty(Difficulty? difficulty, {bool silent = false}) {
|
||||||
_recipe = _recipe.copyWith(difficulty: difficulty);
|
_recipe = _recipe.copyWith(difficulty: difficulty);
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -73,14 +80,14 @@ class RecipeProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateMealCategory(MealCategory mealCategory, {bool silent = false}) {
|
void updateMealCategory(MealCategory? mealCategory, {bool silent = false}) {
|
||||||
_recipe = _recipe.copyWith(mealCategory: mealCategory);
|
_recipe = _recipe.copyWith(mealCategory: mealCategory);
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateCuisine(Cuisine cuisine, {bool silent = false}) {
|
void updateCuisine(Cuisine? cuisine, {bool silent = false}) {
|
||||||
_recipe = _recipe.copyWith(cuisine: cuisine);
|
_recipe = _recipe.copyWith(cuisine: cuisine);
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ enum UnitType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum Difficulty {
|
enum Difficulty {
|
||||||
notSelected,
|
|
||||||
veryEasy,
|
veryEasy,
|
||||||
easy,
|
easy,
|
||||||
intermediate,
|
intermediate,
|
||||||
|
|||||||
81
lib/widgets/clearing_dropdown_menu.dart
Normal file
81
lib/widgets/clearing_dropdown_menu.dart
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ClearingDropdownMenu<T> extends StatefulWidget {
|
||||||
|
final List<DropdownMenuEntry<T>> entries;
|
||||||
|
final T? initialValue;
|
||||||
|
final void Function(T? item)? onSelected;
|
||||||
|
final String? label;
|
||||||
|
|
||||||
|
const ClearingDropdownMenu({
|
||||||
|
super.key,
|
||||||
|
required this.entries,
|
||||||
|
this.initialValue,
|
||||||
|
this.onSelected,
|
||||||
|
this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ClearingDropdownMenu<T>> createState() =>
|
||||||
|
_ClearingDropdownMenuState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClearingDropdownMenuState<T> extends State<ClearingDropdownMenu<T>> {
|
||||||
|
late final TextEditingController _controller;
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
T? _selectedValue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedValue = widget.initialValue;
|
||||||
|
_controller = TextEditingController(
|
||||||
|
text: _selectedValue != null
|
||||||
|
? widget.entries
|
||||||
|
.firstWhere((entry) => entry.value == _selectedValue)
|
||||||
|
.label
|
||||||
|
: '');
|
||||||
|
|
||||||
|
_focusNode.addListener(_onFocusChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFocusChange() {
|
||||||
|
if (!_focusNode.hasFocus) {
|
||||||
|
// Check if the current text matches any entry
|
||||||
|
final validEntry = widget.entries.any((entry) =>
|
||||||
|
entry.label.toLowerCase() == _controller.text.toLowerCase());
|
||||||
|
|
||||||
|
if (!validEntry) {
|
||||||
|
// Clear the text and selection
|
||||||
|
_controller.clear();
|
||||||
|
setState(() {
|
||||||
|
_selectedValue = null;
|
||||||
|
});
|
||||||
|
widget.onSelected?.call(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DropdownMenu<T>(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
initialSelection: widget.initialValue,
|
||||||
|
onSelected: (T? value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedValue = value;
|
||||||
|
});
|
||||||
|
widget.onSelected?.call(value);
|
||||||
|
},
|
||||||
|
dropdownMenuEntries: widget.entries,
|
||||||
|
label: widget.label != null ? Text(widget.label!) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
83
lib/widgets/cooking_duration_button.dart
Normal file
83
lib/widgets/cooking_duration_button.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'duration_picker.dart';
|
||||||
|
import '../services/tools.dart' show durationToFormattedString;
|
||||||
|
|
||||||
|
class CookingDurationButton extends StatelessWidget {
|
||||||
|
const CookingDurationButton({
|
||||||
|
super.key,
|
||||||
|
required this.selectedDuration,
|
||||||
|
required this.onDurationChanged,
|
||||||
|
this.label = '',
|
||||||
|
required this.heroTag,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final void Function(Duration duration) onDurationChanged;
|
||||||
|
final Duration selectedDuration;
|
||||||
|
|
||||||
|
final Object heroTag;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
String text = label;
|
||||||
|
if (label.isEmpty || selectedDuration.inMinutes != 0) {
|
||||||
|
text = durationToFormattedString(selectedDuration);
|
||||||
|
}
|
||||||
|
return FloatingActionButton.extended(
|
||||||
|
heroTag: heroTag,
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _getDurationDialog(
|
||||||
|
context,
|
||||||
|
onDurationChanged,
|
||||||
|
selectedDuration,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
isExtended: selectedDuration.inMinutes != 0,
|
||||||
|
label: Text(
|
||||||
|
text,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
icon:
|
||||||
|
selectedDuration.inMinutes != 0 ? Icon(Icons.edit) : Icon(Icons.add),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog _getDurationDialog(
|
||||||
|
BuildContext context,
|
||||||
|
void Function(Duration duration) update,
|
||||||
|
Duration initialDuration,
|
||||||
|
) {
|
||||||
|
final width = MediaQuery.of(context).size.width * 0.8;
|
||||||
|
final height = MediaQuery.of(context).size.height * 0.6;
|
||||||
|
Duration selectedTime = initialDuration;
|
||||||
|
return Dialog(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
DurationPicker(
|
||||||
|
initialDuration: initialDuration,
|
||||||
|
onChangedCallback: (duration) => selectedTime = duration,
|
||||||
|
height: width * 1.25 > height ? height : width * 1.25,
|
||||||
|
width: width,
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 20, 20, 20),
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
|
label: Text('Save'),
|
||||||
|
icon: Icon(Icons.save),
|
||||||
|
onPressed: () {
|
||||||
|
update(selectedTime);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ class _DurationPickerState extends State<DurationPicker> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
if (widget.initialDuration.inMinutes != 0) {
|
if (widget.initialDuration.inMinutes != 0) {
|
||||||
_timeString =
|
_timeString =
|
||||||
'${widget.initialDuration.inHours}${widget.initialDuration.inMinutes % 60}';
|
'${widget.initialDuration.inHours}${(widget.initialDuration.inMinutes % 60).toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
193
lib/widgets/ingredient_bottomsheet.dart
Normal file
193
lib/widgets/ingredient_bottomsheet.dart
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../constants/cooking_units.dart';
|
||||||
|
import '../models/ingredient.dart';
|
||||||
|
import '../models/ingredient_list_entry.dart';
|
||||||
|
import '../models/unit.dart';
|
||||||
|
import '../src/enums.dart';
|
||||||
|
|
||||||
|
class IngredientBottomsheet extends StatefulWidget {
|
||||||
|
const IngredientBottomsheet({super.key, required this.onSubmitted});
|
||||||
|
|
||||||
|
final void Function(IngredientListEntry ingredient) onSubmitted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<IngredientBottomsheet> createState() => _IngredientBottomsheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IngredientBottomsheetState extends State<IngredientBottomsheet> {
|
||||||
|
final TextEditingController _amountController = TextEditingController();
|
||||||
|
final TextEditingController _ingredientController = TextEditingController();
|
||||||
|
final TextEditingController _unitController = TextEditingController();
|
||||||
|
bool _isOptional = false;
|
||||||
|
Unit? _selectedUnit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _bottomSheetContent(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _bottomSheetContent(BuildContext context) {
|
||||||
|
return Wrap(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: 20,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.max,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: DropdownMenu<Ingredient?>(
|
||||||
|
enableSearch: true,
|
||||||
|
enableFilter: true,
|
||||||
|
width: double.infinity,
|
||||||
|
requestFocusOnTap: true,
|
||||||
|
controller: _ingredientController,
|
||||||
|
label: const Text('Ingredient'),
|
||||||
|
textStyle:
|
||||||
|
TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
|
dropdownMenuEntries: [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(padding: EdgeInsets.only(top: 15)),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: TextField(
|
||||||
|
maxLines: 1,
|
||||||
|
maxLength: 4,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'\d')),
|
||||||
|
],
|
||||||
|
controller: _amountController,
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
label: Text('Amount'),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(padding: EdgeInsets.only(left: 10)),
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: DropdownMenu<Unit?>(
|
||||||
|
label: const Text('Unit'),
|
||||||
|
requestFocusOnTap: false,
|
||||||
|
width: double.infinity,
|
||||||
|
controller: _unitController,
|
||||||
|
onSelected: (value) =>
|
||||||
|
setState(() => _selectedUnit = value),
|
||||||
|
dropdownMenuEntries: CookingUnits.values
|
||||||
|
.map(
|
||||||
|
(e) => DropdownMenuEntry(value: e, label: e.name))
|
||||||
|
.toList(),
|
||||||
|
enableSearch: false,
|
||||||
|
enableFilter: false,
|
||||||
|
textStyle: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'optional',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
Checkbox(
|
||||||
|
value: _isOptional,
|
||||||
|
onChanged: (_) => setState(
|
||||||
|
() => _isOptional = !_isOptional,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 200,
|
||||||
|
width: 400,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _cancelTapped,
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _finishTapped,
|
||||||
|
child: const Text('Finish'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _nextTapped,
|
||||||
|
child: const Text('Next'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _nextTapped() {
|
||||||
|
final success = _submit();
|
||||||
|
if (success) {
|
||||||
|
setState(() {
|
||||||
|
_ingredientController.value = TextEditingValue.empty;
|
||||||
|
_amountController.value = TextEditingValue.empty;
|
||||||
|
_unitController.value = TextEditingValue.empty;
|
||||||
|
_isOptional = false;
|
||||||
|
_selectedUnit = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _finishTapped() {
|
||||||
|
final success = _submit();
|
||||||
|
if (success) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelTapped() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _submit() {
|
||||||
|
final ingredient = Ingredient(
|
||||||
|
title: _ingredientController.text, type: IngredientType.other);
|
||||||
|
final amount = int.tryParse(_amountController.text);
|
||||||
|
if (ingredient.title.isEmpty || _selectedUnit == null || amount == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newEntry = IngredientListEntry(
|
||||||
|
ingredient: ingredient,
|
||||||
|
amount: amount,
|
||||||
|
unit: _selectedUnit!,
|
||||||
|
optional: _isOptional);
|
||||||
|
widget.onSubmitted(newEntry);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user