Implement food entry lookup on entering a food name.

Now, an on-the-fly food lookup is created from existing entries on startup. Those entries are used to make suggestions when the user is typing to enter new food entries.
This commit is contained in:
Marco 2024-09-04 22:47:32 +02:00
parent fb0dbef158
commit b83f547f6b
9 changed files with 179 additions and 104 deletions

View File

@ -1,3 +1,6 @@
import 'dart:developer';
import 'package:calodiary/storage/storage.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:calodiary/food_entry_bloc.dart'; import 'package:calodiary/food_entry_bloc.dart';
import 'package:calodiary/row_with_spacers_widget.dart'; import 'package:calodiary/row_with_spacers_widget.dart';
@ -16,22 +19,53 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
TextEditingController nameController = TextEditingController(); TextEditingController nameController = TextEditingController();
TextEditingController massController = TextEditingController(); TextEditingController massController = TextEditingController();
TextEditingController kcalPerMassController = TextEditingController(); TextEditingController kcalPerMassController = TextEditingController();
Map<String, double> suggestions = {};
@override
void initState() {
suggestions = FoodStorage.getInstance().getFoodEntryLookupDatabase;
super.initState();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var nameWidget = TextField( var nameWidget = Autocomplete<String>(
decoration: const InputDecoration(hintText: "Name"), optionsViewOpenDirection: OptionsViewOpenDirection.down,
controller: nameController, fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
); nameController = controller;
return TextFormField(
controller: controller,
focusNode: focusNode,
decoration: const InputDecoration(label: Text("Name")));
},
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text == '') {
return const Iterable<String>.empty();
}
return suggestions.keys.where(
(name) {
return name
.toLowerCase()
.contains(textEditingValue.text.toLowerCase());
},
);
},
onSelected: (selectedFood) {
double kcalPerMassForSelectedFood = suggestions[selectedFood]!;
setState(() {
kcalPerMassController.text = kcalPerMassForSelectedFood.toString();
});
});
var massWidget = TextField( var massWidget = TextField(
decoration: const InputDecoration(hintText: "Menge"), decoration: const InputDecoration(label: Text("Menge")),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
controller: massController, controller: massController,
); );
var kcalPerMassWidget = TextField( var kcalPerMassWidget = TextField(
decoration: const InputDecoration(hintText: "kcal pro 100g"), decoration: const InputDecoration(label: Text("kcal pro")),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
controller: kcalPerMassController); controller: kcalPerMassController);

View File

@ -4,7 +4,7 @@ import 'package:uuid/uuid.dart';
class FoodEntryBloc extends Bloc<FoodEvent, FoodEntryState> { class FoodEntryBloc extends Bloc<FoodEvent, FoodEntryState> {
final FoodEntryState initialState; final FoodEntryState initialState;
final AppStorage storage; final FoodStorage storage;
final DateTime forDate; final DateTime forDate;
FoodEntryBloc( FoodEntryBloc(
@ -12,16 +12,18 @@ class FoodEntryBloc extends Bloc<FoodEvent, FoodEntryState> {
required this.forDate, required this.forDate,
required this.storage}) required this.storage})
: super(initialState) { : super(initialState) {
on<FoodEntryEvent>(addFoodEntry); on<FoodEntryEvent>(handleFoodEntryEvent);
on<FoodDeletionEvent>(deleteFood); on<FoodDeletionEvent>(deleteFood);
on<PageChangedEvent>(updateEntries); on<PageChangedEvent>(updateEntries);
} }
void addFoodEntry(FoodEntryEvent event, Emitter<FoodEntryState> emit) async { void handleFoodEntryEvent(
FoodEntryEvent event, Emitter<FoodEntryState> emit) async {
FoodEntryState newState = FoodEntryState.from(state); FoodEntryState newState = FoodEntryState.from(state);
newState.addEntry(event.entry); newState.addEntry(event.entry);
await storage.writeEntriesForDate(forDate, newState.foodEntries); await storage.writeEntriesForDate(forDate, newState.foodEntries);
storage.addFoodEntryToLookupDatabase(event.entry);
emit(newState); emit(newState);
} }

View File

@ -4,27 +4,34 @@ import 'package:calodiary/row_with_spacers_widget.dart';
class FoodEntryWidget extends StatelessWidget { class FoodEntryWidget extends StatelessWidget {
final FoodEntry entry; final FoodEntry entry;
final Function(BuildContext context) onDelete; final Function(BuildContext context, String id) onDelete;
const FoodEntryWidget( const FoodEntryWidget(
{super.key, required this.entry, required this.onDelete}); {super.key, required this.entry, required this.onDelete});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Dismissible(
child: Padding( key: ValueKey(entry.id),
padding: const EdgeInsets.only(left: 4.0), onDismissed: (direction) {
child: RowWidget( onDelete(context, entry.id);
Text(entry.name), },
Text(entry.mass.ceil().toString()), child: Card(
Text(entry.kcalPerMass.ceil().toString()), elevation: 5.0,
Text((entry.mass * entry.kcalPerMass / 100).ceil().toString()), child: Padding(
IconButton( padding: const EdgeInsets.only(left: 4.0),
style: IconButton.styleFrom(padding: EdgeInsets.zero), child: RowWidget(
onPressed: () { Text(entry.name),
onDelete(context); Text(entry.mass.ceil().toString()),
}, Text(entry.kcalPerMass.ceil().toString()),
icon: const Icon(Icons.delete_forever_rounded)), Text((entry.mass * entry.kcalPerMass / 100).ceil().toString()),
IconButton(
style: IconButton.styleFrom(padding: EdgeInsets.zero),
onPressed: () {
onDelete(context, entry.id);
},
icon: const Icon(Icons.delete_forever_rounded)),
),
), ),
), ),
); );

View File

@ -10,7 +10,8 @@ import 'package:go_router/go_router.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
var storage = await AppStorage.create(); var storage = await FoodStorage.create();
await storage.buildFoodLookupDatabase();
var kcalLimit = await storage.readLimit(); var kcalLimit = await storage.readLimit();
var brightness = await storage.readBrightness(); var brightness = await storage.readBrightness();
@ -24,7 +25,7 @@ void main() async {
} }
class MainApp extends StatelessWidget { class MainApp extends StatelessWidget {
final AppStorage storage; final FoodStorage storage;
final double kcalLimit; final double kcalLimit;
final String brightness; final String brightness;
@ -36,38 +37,41 @@ class MainApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( return SafeArea(
providers: [ child: MultiBlocProvider(
BlocProvider( providers: [
create: (context) => SettingsDataBloc( BlocProvider(
SettingsState(kcalLimit: kcalLimit), create: (context) => SettingsDataBloc(
storage: storage), SettingsState(kcalLimit: kcalLimit),
), storage: storage),
BlocProvider( ),
create: (context) => ThemeDataBloc(ThemeState(brightness: brightness), BlocProvider(
storage: storage), create: (context) => ThemeDataBloc(
), ThemeState(brightness: brightness),
], storage: storage),
child: BlocBuilder<ThemeDataBloc, ThemeState>( ),
builder: (context, state) { ],
var switchToTheme = ThemeData.light(); child: BlocBuilder<ThemeDataBloc, ThemeState>(
if (state.brightness == 'dark') { builder: (context, state) {
switchToTheme = ThemeData.dark(); var switchToTheme = ThemeData.light();
} if (state.brightness == 'dark') {
switchToTheme = ThemeData.dark();
}
return MaterialApp.router( return MaterialApp.router(
localizationsDelegates: const [ localizationsDelegates: const [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
supportedLocales: const [ supportedLocales: const [
Locale('de'), Locale('de'),
], ],
theme: switchToTheme, theme: switchToTheme,
routerConfig: router, routerConfig: router,
); );
}, },
),
), ),
); );
} }

View File

@ -19,7 +19,7 @@ class PerDateWidget extends StatefulWidget {
} }
class _PerDateWidgetState extends State<PerDateWidget> { class _PerDateWidgetState extends State<PerDateWidget> {
late AppStorage storage; late FoodStorage storage;
late Future<List<FoodEntry>> entriesFuture; late Future<List<FoodEntry>> entriesFuture;
late List<FoodEntry> entries; late List<FoodEntry> entries;
@ -27,7 +27,7 @@ class _PerDateWidgetState extends State<PerDateWidget> {
void initState() { void initState() {
super.initState(); super.initState();
storage = AppStorage.getInstance(); storage = FoodStorage.getInstance();
entriesFuture = storage.getEntriesForDate(widget.date); entriesFuture = storage.getEntriesForDate(widget.date);
entriesFuture.then((val) { entriesFuture.then((val) {
entries = val; entries = val;
@ -93,15 +93,20 @@ class _PerDateWidgetState extends State<PerDateWidget> {
); );
} }
return FoodEntryWidget( return Column(
entry: state.foodEntries[index], children: [
onDelete: (callbackContext) { FoodEntryWidget(
callbackContext entry: state.foodEntries[index],
.read<FoodEntryBloc>() onDelete: (callbackContext, id) {
.add(FoodDeletionEvent( callbackContext
entryID: state.foodEntries[index].id, .read<FoodEntryBloc>()
)); .add(FoodDeletionEvent(
}, entryID: id,
));
},
),
const Divider(),
],
); );
}, },
); );

View File

@ -2,7 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:calodiary/storage/storage.dart'; import 'package:calodiary/storage/storage.dart';
class SettingsDataBloc extends Bloc<SettingsEvent, SettingsState> { class SettingsDataBloc extends Bloc<SettingsEvent, SettingsState> {
final AppStorage storage; final FoodStorage storage;
SettingsDataBloc(super.initialState, {required this.storage}) { SettingsDataBloc(super.initialState, {required this.storage}) {
on<DailyKcalLimitUpdated>(persistDailyLimit); on<DailyKcalLimitUpdated>(persistDailyLimit);

View File

@ -1,16 +1,19 @@
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:calodiary/food_entry_bloc.dart'; import 'package:calodiary/food_entry_bloc.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:universal_platform/universal_platform.dart'; import 'package:universal_platform/universal_platform.dart';
class AppStorage { class FoodStorage {
static late AppStorage _instance; static late FoodStorage _instance;
late String path; late String path;
AppStorage._create(); late Map<String, double> _foodLookupDatabase = {};
static Future<AppStorage> create() async { FoodStorage._create();
var storage = AppStorage._create();
static Future<FoodStorage> create() async {
var storage = FoodStorage._create();
Directory dir = Directory(''); Directory dir = Directory('');
@ -26,7 +29,7 @@ class AppStorage {
return _instance; return _instance;
} }
static AppStorage getInstance() => _instance; static FoodStorage getInstance() => _instance;
Future<List<FoodEntry>> getEntriesForDate(DateTime date) async { Future<List<FoodEntry>> getEntriesForDate(DateTime date) async {
List<FoodEntry> entries = []; List<FoodEntry> entries = [];
@ -132,4 +135,32 @@ class AppStorage {
await file.writeAsString(brightness); await file.writeAsString(brightness);
} }
Future<void> buildFoodLookupDatabase() async {
// get a list of dates of the last 365 days
var dates = List<DateTime>.generate(365, (idx) {
var pastDay = Duration(days: idx);
return DateTime.now().subtract(pastDay);
});
for (var date in dates) {
addFoodEntryToLookupDatabaseFor(date);
}
}
Future<void> addFoodEntryToLookupDatabaseFor(DateTime date) async {
var entriesForDate = await getEntriesForDate(date);
for (var entry in entriesForDate) {
_foodLookupDatabase[entry.name] = entry.kcalPerMass;
log("Added entry: ${entry.name}/${entry.kcalPerMass}");
}
}
void addFoodEntryToLookupDatabase(FoodEntry entry) {
_foodLookupDatabase[entry.name] = entry.kcalPerMass;
log("Added entry: ${entry.name}/${entry.kcalPerMass}");
}
Map<String, double> get getFoodEntryLookupDatabase => _foodLookupDatabase;
} }

View File

@ -2,7 +2,7 @@ import 'package:calodiary/storage/storage.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class ThemeDataBloc extends Bloc<ThemeToggleEvent, ThemeState> { class ThemeDataBloc extends Bloc<ThemeToggleEvent, ThemeState> {
final AppStorage storage; final FoodStorage storage;
ThemeDataBloc(super.initialState, {required this.storage}) { ThemeDataBloc(super.initialState, {required this.storage}) {
on<ThemeToggleEvent>(switchTheme); on<ThemeToggleEvent>(switchTheme);

View File

@ -53,10 +53,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: crypto name: crypto
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.5"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -69,10 +69,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: ffi name: ffi
sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.3"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -121,10 +121,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: cdae1b9c8bd7efadcef6112e81c903662ef2ce105cbd220a04bbb7c3425b5554 sha256: "2ddb88e9ad56ae15ee144ed10e33886777eb5ca2509a914850a5faa7b52ff459"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.0" version: "14.2.7"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@ -193,10 +193,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "25dfcaf170a0190f47ca6355bdd4552cb8924b430512ff0cafb8db9bd41fe33b" sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.14.0" version: "1.15.0"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@ -217,18 +217,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.4"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: "9c96da072b421e98183f9ea7464898428e764bc0ce5567f27ec8693442e72514" sha256: "6f01f8e37ec30b07bc424b4deabac37cacb1bc7e2e515ad74486039918a37eb7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.5" version: "2.2.10"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
@ -257,10 +257,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_windows name: path_provider_windows
sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.1" version: "2.3.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -350,10 +350,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "2419f20b0c8677b2d67c8ac4d1ac7372d862dc6c460cdbb052b40155408cd794" sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.1" version: "0.7.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -374,10 +374,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: uuid name: uuid
sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.4.0" version: "4.5.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -390,18 +390,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "7475cb4dd713d57b6f7464c0e13f06da0d535d8b2067e188962a59bac2cf280b" sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.2" version: "14.2.5"
win32:
dependency: transitive
description:
name: win32
sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4
url: "https://pub.dev"
source: hosted
version: "5.5.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description: