Compare commits

..

23 Commits

Author SHA1 Message Date
40429832ab Functioning Ingredient BottomSheet 2025-02-11 21:52:22 +01:00
2b8e1dc2cb Fixed issue with a specific string as initial duration 2025-02-11 16:59:42 +01:00
b711276674 fixed hero tag issue 2025-02-11 16:57:52 +01:00
2f6440f78d Implemented Cooking Duration Button 2025-02-11 16:56:22 +01:00
28c1b8d56b made updateDifficulty accept nullable difficulty 2025-02-11 16:28:06 +01:00
094054ec00 removed notSelected option of Difficulty enum 2025-02-11 16:27:47 +01:00
681532f557 made difficulty nullable 2025-02-11 16:26:50 +01:00
509f1fd695 changed recipe class 2025-02-11 16:23:15 +01:00
3186ec8780 created clearing dropdown menus 2025-02-11 16:23:02 +01:00
6afab33d49 Removed total time 2025-02-11 15:41:26 +01:00
7eea286c7f Added fields to metadata page 2025-02-11 15:37:46 +01:00
63a7445515 added function to convert duration into a formatted string 2025-02-11 15:25:04 +01:00
7fd561180e changed totalTime to be a getter for preptime + cooktime 2025-02-11 15:24:49 +01:00
35df4c0cc1 added disposeRecipe function 2025-02-11 15:09:32 +01:00
0b58a7a794 added provider to createRecipePage 2025-02-11 15:09:14 +01:00
e8de4879eb cleaned up widget and added initialDuration field 2025-02-11 15:08:40 +01:00
3859700212 added provider in main.dart 2025-02-11 14:42:31 +01:00
8e352e729a made changes to the recipe class 2025-02-11 14:42:20 +01:00
48f2664d2f Removed "notSelected" value of multiple enums 2025-02-11 14:41:55 +01:00
f98fabf579 added function to format enum value names 2025-02-11 14:41:34 +01:00
17c92496a9 fixed bug 2025-02-11 13:13:27 +01:00
8872ec0889 refactored duration picker 2025-02-11 12:06:02 +01:00
c8cc5861ad added duration picker 2025-02-10 23:46:29 +01:00
14 changed files with 955 additions and 41 deletions

View File

@@ -155,4 +155,37 @@ abstract class CookingUnits {
};
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();
}
}

View File

@@ -1,10 +1,21 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'pages/create_recipe_page.dart';
import 'pages/overview_page.dart';
import 'services/providers/recipe_provider.dart';
void main() {
runApp(const RecipeJournal());
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(
create: (context) => RecipeProvider(),
),
],
child: const RecipeJournal(),
),
);
}
class RecipeJournal extends StatelessWidget {

View File

@@ -7,7 +7,12 @@ class IngredientListEntry {
final Unit unit;
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
bool operator ==(Object other) =>

View File

@@ -2,36 +2,38 @@ import '../src/enums.dart' show Difficulty, Cuisine, MealCategory;
import 'ingredient_list_entry.dart';
class Recipe {
final int id;
final String title;
final String description;
final Difficulty difficulty;
final List<IngredientListEntry> ingredients;
final List<String> steps;
final DateTime datePublished;
final Duration prepTime;
final Duration cookTime;
final Duration totalTime;
final List<String> keywords;
final MealCategory mealCategory;
final Cuisine cuisine;
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(),
this.totalTime = const Duration(),
this.mealCategory = MealCategory.notSelected,
this.cuisine = Cuisine.notSelected,
this.mealCategory,
this.cuisine,
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 int id;
final List<IngredientListEntry> ingredients;
final List<String> keywords;
final MealCategory? mealCategory;
final Duration prepTime;
final List<String> steps;
final String title;
final int servingSize;
Duration get totalTime => cookTime + prepTime;
Recipe copyWith({
int? id,
String? title,
@@ -46,6 +48,7 @@ class Recipe {
List<String>? keywords,
MealCategory? mealCategory,
Cuisine? cuisine,
int? servingSize,
}) {
return Recipe(
id: id ?? this.id,
@@ -61,12 +64,12 @@ class Recipe {
datePublished: datePublished ?? this.datePublished,
prepTime: prepTime ?? this.prepTime,
cookTime: cookTime ?? this.cookTime,
totalTime: totalTime ?? this.totalTime,
keywords: keywords != null
? List<String>.from(keywords)
: List<String>.from(this.keywords),
mealCategory: mealCategory ?? this.mealCategory,
cuisine: cuisine ?? this.cuisine,
servingSize: servingSize ?? this.servingSize,
);
}
}

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/providers/recipe_provider.dart';
import 'create_recipe_pages/ingredients_page.dart';
import 'create_recipe_pages/metadata_page.dart';
import 'create_recipe_pages/steps_page.dart';
@@ -21,6 +23,8 @@ class _CreateRecipePageState extends State<CreateRecipePage> {
const StepsPage(),
];
late RecipeProvider _recipeProvider;
final List<NavigationDestination> _destinations = [
NavigationDestination(
icon: Icon(Icons.list),
@@ -38,6 +42,7 @@ class _CreateRecipePageState extends State<CreateRecipePage> {
@override
Widget build(BuildContext context) {
_recipeProvider = context.watch<RecipeProvider>();
return Scaffold(
appBar: AppBar(),
bottomNavigationBar: NavigationBar(
@@ -47,7 +52,16 @@ class _CreateRecipePageState extends State<CreateRecipePage> {
_currentPageIndex = value;
}),
),
body: _pages.elementAt(_currentPageIndex),
body: IndexedStack(
index: _currentPageIndex,
children: _pages,
),
);
}
@override
void dispose() {
super.dispose();
_recipeProvider.disposeRecipe();
}
}

View File

@@ -1,10 +1,91 @@
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});
@override
State<IngredientsPage> createState() => _IngredientsPageState();
}
class _IngredientsPageState extends State<IngredientsPage> {
late RecipeProvider _recipeProvider;
@override
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),
),
),
],
),
);
}
}

View File

@@ -1,12 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
class MetadataPage extends StatelessWidget {
import '../../services/providers/recipe_provider.dart';
import '../../src/enums.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});
@override
State<MetadataPage> createState() => _MetadataPageState();
}
class _MetadataPageState extends State<MetadataPage> {
late RecipeProvider _recipeProvider;
@override
Widget build(BuildContext context) {
return const Center(
child: Text('Metadata'),
_recipeProvider = context.watch<RecipeProvider>();
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,
),
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,
),
)
: null,
),
onSubmitted: (value) => TextInputAction.next,
),
Padding(padding: EdgeInsets.only(top: 10)),
DropdownMenu(
enableFilter: false,
enableSearch: false,
requestFocusOnTap: false,
label: Text('Difficulty'),
dropdownMenuEntries:
getDropdownMenuEntriesFromEnum(Difficulty.values),
onSelected: _recipeProvider.updateDifficulty,
),
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(
children: [
Text(
'Prep Time',
style: Theme.of(context).textTheme.titleMedium,
),
Padding(padding: EdgeInsets.only(left: 10)),
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<T> values) {
return values
.map(
(e) => DropdownMenuEntry(
value: e,
label: getEnumValueName(e),
),
)
.toList();
}
}

View File

@@ -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();
@@ -66,13 +73,6 @@ class RecipeProvider extends ChangeNotifier {
}
}
void updateTotalTime(Duration totalTime, {bool silent = false}) {
_recipe = _recipe.copyWith(totalTime: totalTime);
if (!silent) {
notifyListeners();
}
}
void updateKeywords(List<String> keywords, {bool silent = false}) {
_recipe = _recipe.copyWith(keywords: keywords);
if (!silent) {
@@ -80,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();
@@ -148,4 +148,8 @@ class RecipeProvider extends ChangeNotifier {
notifyListeners();
}
}
void disposeRecipe() {
_recipe = Recipe(datePublished: DateTime.now());
}
}

10
lib/services/tools.dart Normal file
View File

@@ -0,0 +1,10 @@
String getEnumValueName<T extends Enum>(T value) {
String name =
value.name.replaceAllMapped(RegExp(r'[A-Z]'), (match) => ' ${match[0]}');
name = name[0].toUpperCase() + name.substring(1);
return name;
}
String durationToFormattedString(Duration duration) {
return '${duration.inHours}h ${duration.inMinutes % 60}m';
}

View File

@@ -7,7 +7,6 @@ enum UnitType {
}
enum Difficulty {
notSelected,
veryEasy,
easy,
intermediate,
@@ -16,7 +15,6 @@ enum Difficulty {
}
enum IngredientType {
notSelected,
vegetable,
meat,
fish,
@@ -29,7 +27,6 @@ enum IngredientType {
}
enum MealCategory {
notSelected,
// Main Meals
breakfast,
brunch,
@@ -46,7 +43,6 @@ enum MealCategory {
}
enum Cuisine {
notSelected,
// Asian Cuisines
chinese,
japanese,

View 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();
}
}

View 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();
},
),
),
),
],
),
);
}

View File

@@ -0,0 +1,269 @@
import 'package:flutter/material.dart';
class DurationPicker extends StatefulWidget {
const DurationPicker({
super.key,
required this.onChangedCallback,
required this.width,
required this.height,
this.initialDuration = const Duration(),
});
final void Function(Duration duration) onChangedCallback;
final double width;
final double height;
final Duration initialDuration;
@override
State<DurationPicker> createState() => _DurationPickerState();
}
class _DurationPickerState extends State<DurationPicker> {
String _timeString = '';
@override
void initState() {
super.initState();
if (widget.initialDuration.inMinutes != 0) {
_timeString =
'${widget.initialDuration.inHours}${(widget.initialDuration.inMinutes % 60).toString().padLeft(2, '0')}';
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: widget.width,
height: widget.height / 4,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_timeString.padLeft(4, '0').substring(0, 2),
style: Theme.of(context).textTheme.displayMedium,
),
Padding(padding: EdgeInsets.only(left: 5)),
Text(
'h',
style:
Theme.of(context).textTheme.bodyLarge!.copyWith(height: 2),
),
Padding(padding: EdgeInsets.only(left: 10)),
Text(
_timeString.padLeft(4, '0').substring(2, 4),
style: Theme.of(context).textTheme.displayMedium,
),
Padding(padding: EdgeInsets.only(left: 5)),
Text(
'm',
style:
Theme.of(context).textTheme.bodyLarge!.copyWith(height: 2),
),
],
),
),
Padding(padding: EdgeInsets.only(top: 10)),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ElevatedButton(
style: _buttonTheme,
onPressed: () => _addTime(7),
child: Text('7'),
),
),
),
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ElevatedButton(
style: _buttonTheme,
onPressed: () => _addTime(8),
child: Text('8'),
),
),
),
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ElevatedButton(
style: _buttonTheme,
onPressed: () => _addTime(9),
child: Text('9'),
),
),
),
],
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ElevatedButton(
style: _buttonTheme,
onPressed: () => _addTime(4),
child: Text('4'),
),
),
),
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ElevatedButton(
style: _buttonTheme,
onPressed: () => _addTime(5),
child: Text('5'),
),
),
),
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ElevatedButton(
style: _buttonTheme,
onPressed: () => _addTime(6),
child: Text('6'),
),
),
),
],
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ElevatedButton(
style: _buttonTheme,
onPressed: () => _addTime(1),
child: Text('1'),
),
),
),
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ElevatedButton(
style: _buttonTheme,
onPressed: () => _addTime(2),
child: Text('2'),
),
),
),
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ElevatedButton(
style: _buttonTheme,
onPressed: () => _addTime(3),
child: Text('3'),
),
),
),
],
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(padding: EdgeInsets.all(0)),
),
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ElevatedButton(
style: _buttonTheme,
onPressed: () => _addTime(0),
child: Text('0'),
),
),
),
SizedBox(
width: widget.width / 3,
height: widget.height / 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ElevatedButton(
style: _buttonTheme,
onPressed: _removeTime,
child: Icon(
Icons.backspace,
size: 24,
),
),
),
),
],
),
],
);
}
void _addTime(int time) {
if (_timeString.length >= 4) return;
if (_timeString.isEmpty && time == 0) return;
setState(() {
_timeString += time.toString();
});
widget.onChangedCallback(_duration);
}
void _removeTime() {
if (_timeString.isEmpty) return;
setState(() {
_timeString = _timeString.substring(0, _timeString.length - 1);
});
widget.onChangedCallback(_duration);
}
Duration get _duration {
return Duration(
hours: int.parse(_timeString.padLeft(4, '0').substring(0, 2)),
minutes: int.parse(_timeString.padLeft(4, '0').substring(2, 4)),
);
}
ButtonStyle get _buttonTheme => ElevatedButton.styleFrom(
textStyle: Theme.of(context).textTheme.displaySmall,
);
}

View 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;
}
}