Compare commits
7 Commits
7eea286c7f
...
2f6440f78d
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f6440f78d | |||
| 28c1b8d56b | |||
| 094054ec00 | |||
| 681532f557 | |||
| 509f1fd695 | |||
| 3186ec8780 | |||
| 6afab33d49 |
@@ -6,7 +6,7 @@ class Recipe {
|
||||
this.id = -1,
|
||||
this.title = '',
|
||||
this.description = '',
|
||||
this.difficulty = Difficulty.notSelected,
|
||||
this.difficulty,
|
||||
required this.datePublished,
|
||||
this.prepTime = const Duration(),
|
||||
this.cookTime = const Duration(),
|
||||
@@ -15,13 +15,14 @@ class Recipe {
|
||||
this.ingredients = const [],
|
||||
this.keywords = const [],
|
||||
this.steps = const [],
|
||||
this.servingSize = 0,
|
||||
});
|
||||
|
||||
final Duration cookTime;
|
||||
final Cuisine? cuisine;
|
||||
final DateTime datePublished;
|
||||
final String description;
|
||||
final Difficulty difficulty;
|
||||
final Difficulty? difficulty;
|
||||
final int id;
|
||||
final List<IngredientListEntry> ingredients;
|
||||
final List<String> keywords;
|
||||
@@ -29,6 +30,7 @@ class Recipe {
|
||||
final Duration prepTime;
|
||||
final List<String> steps;
|
||||
final String title;
|
||||
final int servingSize;
|
||||
|
||||
Duration get totalTime => cookTime + prepTime;
|
||||
|
||||
@@ -46,6 +48,7 @@ class Recipe {
|
||||
List<String>? keywords,
|
||||
MealCategory? mealCategory,
|
||||
Cuisine? cuisine,
|
||||
int? servingSize,
|
||||
}) {
|
||||
return Recipe(
|
||||
id: id ?? this.id,
|
||||
@@ -66,6 +69,7 @@ class Recipe {
|
||||
: List<String>.from(this.keywords),
|
||||
mealCategory: mealCategory ?? this.mealCategory,
|
||||
cuisine: cuisine ?? this.cuisine,
|
||||
servingSize: servingSize ?? this.servingSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../services/providers/recipe_provider.dart';
|
||||
import '../../src/enums.dart';
|
||||
import '../../services/tools.dart'
|
||||
show getEnumValueName, durationToFormattedString;
|
||||
import '../../widgets/duration_picker.dart';
|
||||
import '../../services/tools.dart' show getEnumValueName;
|
||||
import '../../widgets/clearing_dropdown_menu.dart';
|
||||
import '../../widgets/cooking_duration_button.dart';
|
||||
|
||||
class MetadataPage extends StatefulWidget {
|
||||
const MetadataPage({super.key});
|
||||
@@ -20,117 +21,111 @@ class _MetadataPageState extends State<MetadataPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_recipeProvider = context.watch<RecipeProvider>();
|
||||
return Center(
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
onChanged: (value) => _recipeProvider.updateTitle(value),
|
||||
decoration: InputDecoration(
|
||||
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(
|
||||
onChanged: (value) => _recipeProvider.updateDescription(value),
|
||||
decoration: InputDecoration(
|
||||
label: Text('Description'),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (value) => TextInputAction.next,
|
||||
),
|
||||
DropdownButton(
|
||||
value: _recipeProvider.recipe.difficulty,
|
||||
items: Difficulty.values
|
||||
.map(
|
||||
(e) => DropdownMenuItem<Difficulty>(
|
||||
value: e,
|
||||
child: Text(getEnumValueName(e)),
|
||||
Padding(padding: EdgeInsets.only(top: 10)),
|
||||
TextField(
|
||||
onChanged: (value) =>
|
||||
_recipeProvider.updateServingSize(int.parse(value)),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
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(),
|
||||
onChanged: (value) => setState(() {
|
||||
_recipeProvider.updateDifficulty(value ?? Difficulty.notSelected);
|
||||
}),
|
||||
: null,
|
||||
),
|
||||
onSubmitted: (value) => TextInputAction.next,
|
||||
),
|
||||
Padding(padding: EdgeInsets.only(top: 10)),
|
||||
DropdownMenu(
|
||||
enableFilter: false,
|
||||
enableSearch: false,
|
||||
requestFocusOnTap: false,
|
||||
label: Text('Difficulty'),
|
||||
dropdownMenuEntries:
|
||||
getDropdownMenuEntriesFromEnum(MealCategory.values),
|
||||
enableFilter: true,
|
||||
label: Text('Category'),
|
||||
getDropdownMenuEntriesFromEnum(Difficulty.values),
|
||||
onSelected: _recipeProvider.updateDifficulty,
|
||||
),
|
||||
DropdownMenu(
|
||||
dropdownMenuEntries: getDropdownMenuEntriesFromEnum(Cuisine.values),
|
||||
enableFilter: true,
|
||||
label: Text('Cuisine'),
|
||||
Padding(padding: EdgeInsets.only(top: 10)),
|
||||
ClearingDropdownMenu(
|
||||
entries: getDropdownMenuEntriesFromEnum(MealCategory.values),
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Preparation time'),
|
||||
_recipeProvider.recipe.prepTime.inMinutes == 0
|
||||
? IconButton(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => _getDurationDialog(
|
||||
context,
|
||||
_recipeProvider.updatePrepTime,
|
||||
_recipeProvider.recipe.prepTime),
|
||||
Text(
|
||||
'Prep Time',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
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)),
|
||||
Text(durationToFormattedString(_recipeProvider.recipe.totalTime)),
|
||||
CookingDurationButton(
|
||||
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(
|
||||
selectedDuration: _recipeProvider.recipe.cookTime,
|
||||
onDurationChanged: _recipeProvider.updateCookTime,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<DropdownMenuEntry<T>> getDropdownMenuEntriesFromEnum<T extends Enum>(
|
||||
List<T> values) {
|
||||
@@ -143,40 +138,4 @@ List<DropdownMenuEntry<T>> getDropdownMenuEntriesFromEnum<T extends Enum>(
|
||||
)
|
||||
.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);
|
||||
if (!silent) {
|
||||
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);
|
||||
if (!silent) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void updateCuisine(Cuisine cuisine, {bool silent = false}) {
|
||||
void updateCuisine(Cuisine? cuisine, {bool silent = false}) {
|
||||
_recipe = _recipe.copyWith(cuisine: cuisine);
|
||||
if (!silent) {
|
||||
notifyListeners();
|
||||
|
||||
@@ -7,7 +7,6 @@ enum UnitType {
|
||||
}
|
||||
|
||||
enum Difficulty {
|
||||
notSelected,
|
||||
veryEasy,
|
||||
easy,
|
||||
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();
|
||||
}
|
||||
}
|
||||
79
lib/widgets/cooking_duration_button.dart
Normal file
79
lib/widgets/cooking_duration_button.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
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 = '',
|
||||
});
|
||||
|
||||
final String label;
|
||||
final void Function(Duration duration) onDurationChanged;
|
||||
final Duration selectedDuration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String text = label;
|
||||
if (label.isEmpty || selectedDuration.inMinutes != 0) {
|
||||
text = durationToFormattedString(selectedDuration);
|
||||
}
|
||||
return FloatingActionButton.extended(
|
||||
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();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user