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:calodiary/food_entry_bloc.dart';
import 'package:calodiary/row_with_spacers_widget.dart';
@ -16,22 +19,53 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
TextEditingController nameController = TextEditingController();
TextEditingController massController = TextEditingController();
TextEditingController kcalPerMassController = TextEditingController();
Map<String, double> suggestions = {};
@override
void initState() {
suggestions = FoodStorage.getInstance().getFoodEntryLookupDatabase;
super.initState();
}
@override
Widget build(BuildContext context) {
var nameWidget = TextField(
decoration: const InputDecoration(hintText: "Name"),
controller: nameController,
);
var nameWidget = Autocomplete<String>(
optionsViewOpenDirection: OptionsViewOpenDirection.down,
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(
decoration: const InputDecoration(hintText: "Menge"),
decoration: const InputDecoration(label: Text("Menge")),
keyboardType: TextInputType.number,
controller: massController,
);
var kcalPerMassWidget = TextField(
decoration: const InputDecoration(hintText: "kcal pro 100g"),
decoration: const InputDecoration(label: Text("kcal pro")),
keyboardType: TextInputType.number,
controller: kcalPerMassController);

View File

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

View File

@ -4,27 +4,34 @@ import 'package:calodiary/row_with_spacers_widget.dart';
class FoodEntryWidget extends StatelessWidget {
final FoodEntry entry;
final Function(BuildContext context) onDelete;
final Function(BuildContext context, String id) onDelete;
const FoodEntryWidget(
{super.key, required this.entry, required this.onDelete});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: RowWidget(
Text(entry.name),
Text(entry.mass.ceil().toString()),
Text(entry.kcalPerMass.ceil().toString()),
Text((entry.mass * entry.kcalPerMass / 100).ceil().toString()),
IconButton(
style: IconButton.styleFrom(padding: EdgeInsets.zero),
onPressed: () {
onDelete(context);
},
icon: const Icon(Icons.delete_forever_rounded)),
return Dismissible(
key: ValueKey(entry.id),
onDismissed: (direction) {
onDelete(context, entry.id);
},
child: Card(
elevation: 5.0,
child: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: RowWidget(
Text(entry.name),
Text(entry.mass.ceil().toString()),
Text(entry.kcalPerMass.ceil().toString()),
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 {
WidgetsFlutterBinding.ensureInitialized();
var storage = await AppStorage.create();
var storage = await FoodStorage.create();
await storage.buildFoodLookupDatabase();
var kcalLimit = await storage.readLimit();
var brightness = await storage.readBrightness();
@ -24,7 +25,7 @@ void main() async {
}
class MainApp extends StatelessWidget {
final AppStorage storage;
final FoodStorage storage;
final double kcalLimit;
final String brightness;
@ -36,38 +37,41 @@ class MainApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SettingsDataBloc(
SettingsState(kcalLimit: kcalLimit),
storage: storage),
),
BlocProvider(
create: (context) => ThemeDataBloc(ThemeState(brightness: brightness),
storage: storage),
),
],
child: BlocBuilder<ThemeDataBloc, ThemeState>(
builder: (context, state) {
var switchToTheme = ThemeData.light();
if (state.brightness == 'dark') {
switchToTheme = ThemeData.dark();
}
return SafeArea(
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SettingsDataBloc(
SettingsState(kcalLimit: kcalLimit),
storage: storage),
),
BlocProvider(
create: (context) => ThemeDataBloc(
ThemeState(brightness: brightness),
storage: storage),
),
],
child: BlocBuilder<ThemeDataBloc, ThemeState>(
builder: (context, state) {
var switchToTheme = ThemeData.light();
if (state.brightness == 'dark') {
switchToTheme = ThemeData.dark();
}
return MaterialApp.router(
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('de'),
],
theme: switchToTheme,
routerConfig: router,
);
},
return MaterialApp.router(
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('de'),
],
theme: switchToTheme,
routerConfig: router,
);
},
),
),
);
}

View File

@ -19,7 +19,7 @@ class PerDateWidget extends StatefulWidget {
}
class _PerDateWidgetState extends State<PerDateWidget> {
late AppStorage storage;
late FoodStorage storage;
late Future<List<FoodEntry>> entriesFuture;
late List<FoodEntry> entries;
@ -27,7 +27,7 @@ class _PerDateWidgetState extends State<PerDateWidget> {
void initState() {
super.initState();
storage = AppStorage.getInstance();
storage = FoodStorage.getInstance();
entriesFuture = storage.getEntriesForDate(widget.date);
entriesFuture.then((val) {
entries = val;
@ -93,15 +93,20 @@ class _PerDateWidgetState extends State<PerDateWidget> {
);
}
return FoodEntryWidget(
entry: state.foodEntries[index],
onDelete: (callbackContext) {
callbackContext
.read<FoodEntryBloc>()
.add(FoodDeletionEvent(
entryID: state.foodEntries[index].id,
));
},
return Column(
children: [
FoodEntryWidget(
entry: state.foodEntries[index],
onDelete: (callbackContext, id) {
callbackContext
.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';
class SettingsDataBloc extends Bloc<SettingsEvent, SettingsState> {
final AppStorage storage;
final FoodStorage storage;
SettingsDataBloc(super.initialState, {required this.storage}) {
on<DailyKcalLimitUpdated>(persistDailyLimit);

View File

@ -1,16 +1,19 @@
import 'dart:developer';
import 'dart:io';
import 'package:calodiary/food_entry_bloc.dart';
import 'package:path_provider/path_provider.dart';
import 'package:universal_platform/universal_platform.dart';
class AppStorage {
static late AppStorage _instance;
class FoodStorage {
static late FoodStorage _instance;
late String path;
AppStorage._create();
late Map<String, double> _foodLookupDatabase = {};
static Future<AppStorage> create() async {
var storage = AppStorage._create();
FoodStorage._create();
static Future<FoodStorage> create() async {
var storage = FoodStorage._create();
Directory dir = Directory('');
@ -26,7 +29,7 @@ class AppStorage {
return _instance;
}
static AppStorage getInstance() => _instance;
static FoodStorage getInstance() => _instance;
Future<List<FoodEntry>> getEntriesForDate(DateTime date) async {
List<FoodEntry> entries = [];
@ -132,4 +135,32 @@ class AppStorage {
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';
class ThemeDataBloc extends Bloc<ThemeToggleEvent, ThemeState> {
final AppStorage storage;
final FoodStorage storage;
ThemeDataBloc(super.initialState, {required this.storage}) {
on<ThemeToggleEvent>(switchTheme);

View File

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