Compare commits

..

10 Commits

Author SHA1 Message Date
ce373404ad Bloc is now global and responsible for all dates 2024-10-08 00:58:56 +02:00
cb18e1d1f0 Introduce PageView with manual switching of dates
1. Show PerDate widgets inside of an PageView
2. Introduce GoRouter so we can intercept back button taps with
   BackButtonListener
3. Implement rudimentary navigation
4. Fix bug that still showed a spinner event when the barcode was not
   found.
2024-10-06 02:20:08 +02:00
970cea8ba7 Oops, forgot to store the entries that were scanned. They just disappeared after restarting the app 2024-10-02 16:22:33 +02:00
37cceb3a9b Fix bug that caused infinite loop for selecting FoodEntries
And also, the Cancel button is removed when the FoodEntry is selected.
2024-09-25 18:20:50 +02:00
a70a7fd1e3 Remove debug statement 2024-09-25 17:43:42 +02:00
f28626c49e Move select logic for FoodEntry into BLoC 2024-09-25 17:33:40 +02:00
ace03d98d2 Handle changing a food entry 2024-09-24 17:23:01 +02:00
a7a7f44050 Make scanned widgets appear in the list instead of putting the info into
the EnterFoodWidget.

1. Make scanned foods appear in the list of foods
2. Remove Controller for entering food

This commit removes the EnterFoodController that was used to put a
scanned food into the EnterFoodWidget.
This is now unnecessary because scanning a product will be distributed
via the FoodBLoC.
2024-09-24 14:41:42 +02:00
e75a0765b4 Reverse the days for the lookup database so the newer entries are being suggested 2024-09-13 22:50:08 +02:00
9179cff38e Merge pull request 'Fix comma bug' (#6) from fix-comma-bug into master
Reviewed-on: #6
2024-09-13 20:39:00 +00:00
14 changed files with 803 additions and 354 deletions

View File

@ -1,12 +1,10 @@
import 'package:calorimeter/utils/enter_food_controller.dart';
import 'package:calorimeter/storage/storage.dart';
import 'package:flutter/material.dart';
import 'package:calorimeter/food_entry/food_entry_bloc.dart';
import 'package:calorimeter/utils/row_with_spacers_widget.dart';
import 'package:provider/provider.dart';
class EnterFoodWidget extends StatefulWidget {
final Function(BuildContext context, FoodEntry entry) onAdd;
final Function(BuildContext context, FoodEntryState entry) onAdd;
const EnterFoodWidget({super.key, required this.onAdd});
@ -18,7 +16,7 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
late TextEditingController nameController;
late TextEditingController massController;
late TextEditingController kcalPerMassController;
late Map<String, double> suggestions;
late Map<String, int> suggestions;
@override
void initState() {
@ -32,18 +30,12 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
@override
Widget build(BuildContext context) {
return Consumer<EnterFoodController>(
builder: (context, food, child) {
nameController.text = food.name;
kcalPerMassController.text = food.kcalPer100g;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: RowWidget(
Autocomplete<String>(
optionsViewOpenDirection: OptionsViewOpenDirection.down,
fieldViewBuilder:
(context, controller, focusNode, onSubmitted) {
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
nameController = controller;
return TextFormField(
controller: controller,
@ -67,17 +59,18 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
);
},
onSelected: (selectedFood) {
double kcalPerMassForSelectedFood =
suggestions[selectedFood]!;
context
.read<EnterFoodController>()
.set(selectedFood, kcalPerMassForSelectedFood.toString());
int kcalPerMassForSelectedFood = suggestions[selectedFood]!;
setState(() {
nameController.text = selectedFood;
kcalPerMassController.text =
kcalPerMassForSelectedFood.toString();
});
}),
TextField(
textAlign: TextAlign.end,
decoration: const InputDecoration(
label: Align(
alignment: Alignment.centerRight, child: Text("Menge")),
label:
Align(alignment: Alignment.centerRight, child: Text("Menge")),
),
keyboardType: TextInputType.number,
controller: massController,
@ -87,8 +80,7 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
textAlign: TextAlign.end,
decoration: const InputDecoration(
label: Align(
alignment: Alignment.centerRight,
child: Text("kcal pro"))),
alignment: Alignment.centerRight, child: Text("kcal pro"))),
keyboardType: TextInputType.number,
controller: kcalPerMassController,
onSubmitted: (value) => onSubmitAction(),
@ -104,16 +96,14 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
),
),
);
},
);
}
void onSubmitAction() {
double massAsNumber = 0.0;
double kcalPerMassAsNumber = 0.0;
int massAsNumber = 0;
int kcalPerMassAsNumber = 0;
try {
massAsNumber = double.parse(massController.text.replaceAll(",", "."));
massAsNumber = int.parse(massController.text.replaceAll(",", "."));
} catch (e) {
var snackbar = const SnackBar(content: Text("Menge muss eine Zahl sein"));
ScaffoldMessenger.of(context).clearSnackBars();
@ -123,21 +113,28 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
try {
kcalPerMassAsNumber =
double.parse(kcalPerMassController.text.replaceAll(",", "."));
int.parse(kcalPerMassController.text.replaceAll(",", "."));
} catch (e) {
var snackbar =
const SnackBar(content: Text("'kcal pro 100g' muss eine Zahl sein"));
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).removeCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(snackbar);
return;
}
var entry = FoodEntry(
var entry = FoodEntryState(
name: nameController.text,
mass: massAsNumber,
kcalPerMass: kcalPerMassAsNumber);
kcalPer100: kcalPerMassAsNumber,
waitingForNetwork: false,
isSelected: false,
);
widget.onAdd(context, entry);
context.read<EnterFoodController>().set("", "");
setState(() {
nameController.text = "";
massController.text = "";
kcalPerMassController.text = "";
});
}
}

View File

@ -1,104 +1,305 @@
import 'package:barcode_scan2/barcode_scan2.dart';
import 'package:calorimeter/food_scan/food_fact_lookup.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:calorimeter/storage/storage.dart';
import 'package:uuid/uuid.dart';
class FoodEntryBloc extends Bloc<FoodEvent, FoodEntryState> {
final FoodEntryState initialState;
class FoodEntryBloc extends Bloc<FoodEvent, GlobalEntryState> {
final GlobalEntryState initialState;
final FoodStorage storage;
final DateTime forDate;
FoodEntryBloc(
{required this.initialState,
required this.forDate,
required this.storage})
FoodEntryBloc({required this.initialState, required this.storage})
: super(initialState) {
on<PageBeingInitialized>(handlePageBeingInitialized);
on<FoodEntryEvent>(handleFoodEntryEvent);
on<FoodDeletionEvent>(deleteFood);
on<PageChangedEvent>(updateEntries);
on<FoodChangedEvent>(handleFoodChangedEvent);
on<FoodDeletionEvent>(handleDeleteFoodEvent);
on<BarcodeScanned>(handleBarcodeScannedEvent);
on<FoodEntryTapped>(handleFoodEntryTapped);
}
void handlePageBeingInitialized(
PageBeingInitialized event, Emitter<GlobalEntryState> emit) async {
var newList = await storage.getEntriesForDate(event.forDate);
state.foodEntries.addAll({event.forDate: newList});
emit(GlobalEntryState(foodEntries: state.foodEntries));
}
void handleFoodEntryEvent(
FoodEntryEvent event, Emitter<FoodEntryState> emit) async {
FoodEntryState newState = FoodEntryState.from(state);
newState.addEntry(event.entry);
FoodEntryEvent event, Emitter<GlobalEntryState> emit) async {
var entriesForDate = state.foodEntries[event.forDate];
entriesForDate ??= [];
await storage.writeEntriesForDate(forDate, newState.foodEntries);
entriesForDate.add(event.entry);
await storage.writeEntriesForDate(event.forDate, entriesForDate);
storage.addFoodEntryToLookupDatabase(event.entry);
emit(newState);
// this is just checking if writing to the database worked
// can be optimized out by just emitting newState
var newList = await storage.getEntriesForDate(event.forDate);
var newFoodEntries = state.foodEntries;
newFoodEntries.addAll({event.forDate: newList});
emit(GlobalEntryState(foodEntries: newFoodEntries));
}
void deleteFood(FoodDeletionEvent event, Emitter<FoodEntryState> emit) async {
state.foodEntries.removeWhere((entry) => entry.id == event.entryID);
void handleFoodChangedEvent(
FoodChangedEvent event, Emitter<GlobalEntryState> emit) async {
var entriesForDate = state.foodEntries[event.forDate];
if (entriesForDate == null) return;
await storage.writeEntriesForDate(forDate, state.foodEntries);
var index = entriesForDate.indexWhere((entry) {
return entry.id == event.newEntry.id;
});
emit(FoodEntryState.from(state));
entriesForDate.removeAt(index);
entriesForDate.insert(index, event.newEntry);
await storage.writeEntriesForDate(event.forDate, entriesForDate);
storage.addFoodEntryToLookupDatabase(event.newEntry);
// this is just checking if writing to the database worked
// can be optimized out by just emitting newState
var newList = await storage.getEntriesForDate(event.forDate);
var newFoodEntries = state.foodEntries;
newFoodEntries.addAll({event.forDate: newList});
emit(GlobalEntryState(foodEntries: newFoodEntries));
}
void updateEntries(
PageChangedEvent event, Emitter<FoodEntryState> emit) async {
var entries = await storage.getEntriesForDate(event.changedToDate);
var newState = FoodEntryState(foodEntries: entries);
emit(newState);
void handleDeleteFoodEvent(
FoodDeletionEvent event, Emitter<GlobalEntryState> emit) async {
var entriesForDate = state.foodEntries[event.forDate];
if (entriesForDate == null) return;
entriesForDate.removeWhere((entry) => entry.id == event.entryID);
await storage.writeEntriesForDate(event.forDate, entriesForDate);
// this is just checking if writing to the database worked
// can be optimized out by just emitting newState
var newList = await storage.getEntriesForDate(event.forDate);
var newFoodEntries = state.foodEntries;
newFoodEntries.addAll({event.forDate: newList});
emit(GlobalEntryState(foodEntries: newFoodEntries));
}
void handleBarcodeScannedEvent(
BarcodeScanned event, Emitter<GlobalEntryState> emit) async {
var entriesForDate = state.foodEntries[event.forDate];
if (entriesForDate == null) return;
var client = FoodFactLookupClient();
var scanResult = await event.scanResultFuture;
if (scanResult.type == ResultType.Cancelled) {
return;
}
if (scanResult.type == ResultType.Error) {
emit(GlobalEntryState(
foodEntries: state.foodEntries,
errorString: "Fehler beim Scannen des Barcodes"));
return;
}
var responseFuture = client.retrieveFoodInfo(scanResult.rawContent);
var newEntryWaiting = FoodEntryState(
kcalPer100: 0,
name: "",
mass: 0,
waitingForNetwork: true,
isSelected: false,
);
entriesForDate.add(newEntryWaiting);
var newFoodEntries = state.foodEntries;
newFoodEntries.addAll({event.forDate: entriesForDate});
emit(GlobalEntryState(foodEntries: newFoodEntries));
await responseFuture.then((response) async {
var index = entriesForDate
.indexWhere((entryState) => entryState.id == newEntryWaiting.id);
// element not found (was deleted previously)
if (index == -1) {
return;
}
if (response.status == FoodFactResponseStatus.barcodeNotFound) {
entriesForDate.removeWhere((entry) => entry.id == newEntryWaiting.id);
var newFoodEntries = state.foodEntries;
newFoodEntries.addAll({event.forDate: entriesForDate});
emit(GlobalEntryState(
foodEntries: newFoodEntries,
errorString: "Barcode konnte nicht gefunden werden."));
return;
}
if (response.status ==
FoodFactResponseStatus.foodFactServerNotReachable) {
entriesForDate.removeWhere((entry) => entry.id == newEntryWaiting.id);
var newFoodEntries = state.foodEntries;
newFoodEntries.addAll({event.forDate: entriesForDate});
emit(GlobalEntryState(
foodEntries: newFoodEntries,
errorString: "OpenFoodFacts-Server konnte nicht erreicht werden."));
return;
}
var newEntryFinishedWaiting = FoodEntryState(
name: response.food?.name ?? "",
mass: response.food?.mass ?? 0,
kcalPer100: response.food?.kcalPer100g ?? 0,
waitingForNetwork: false,
isSelected: false,
);
entriesForDate.removeAt(index);
entriesForDate.insert(index, newEntryFinishedWaiting);
await storage.writeEntriesForDate(event.forDate, entriesForDate);
storage.addFoodEntryToLookupDatabase(newEntryFinishedWaiting);
var entriesFromStorage = await storage.getEntriesForDate(event.forDate);
var newFoodEntries = state.foodEntries;
newFoodEntries.addAll({event.forDate: entriesFromStorage});
emit(GlobalEntryState(foodEntries: newFoodEntries));
});
}
void handleFoodEntryTapped(
FoodEntryTapped event, Emitter<GlobalEntryState> emit) async {
var entriesForDate = state.foodEntries[event.forDate];
if (entriesForDate == null) return;
var oldStateOfTappedEntry = event.entry.isSelected;
for (var entry in entriesForDate) {
entry.isSelected = false;
}
var selectedEntry = entriesForDate.firstWhere((entry) {
return entry.id == event.entry.id;
});
selectedEntry.isSelected = !oldStateOfTappedEntry;
emit(GlobalEntryState(foodEntries: state.foodEntries));
}
}
class FoodEvent {}
class FoodEvent {
final DateTime forDate;
FoodEvent({required this.forDate});
}
class PageBeingInitialized extends FoodEvent {
PageBeingInitialized({required super.forDate});
}
class FoodEntryEvent extends FoodEvent {
final FoodEntry entry;
final FoodEntryState entry;
FoodEntryEvent({required this.entry});
FoodEntryEvent({required this.entry, required super.forDate});
}
class FoodChangedEvent extends FoodEvent {
final FoodEntryState newEntry;
FoodChangedEvent({required this.newEntry, required super.forDate});
}
class FoodDeletionEvent extends FoodEvent {
final String entryID;
FoodDeletionEvent({required this.entryID});
FoodDeletionEvent({required this.entryID, required super.forDate});
}
class PageChangedEvent extends FoodEvent {
final DateTime changedToDate;
class BarcodeScanned extends FoodEvent {
final Future<ScanResult> scanResultFuture;
PageChangedEvent({required this.changedToDate});
BarcodeScanned({required this.scanResultFuture, required super.forDate});
}
class FoodEntryTapped extends FoodEvent {
final FoodEntryState entry;
FoodEntryTapped({required this.entry, required super.forDate});
}
/// This is the state for one date/page
class GlobalEntryState {
final Map<DateTime, List<FoodEntryState>> foodEntries;
final String? errorString;
GlobalEntryState({required this.foodEntries, this.errorString});
factory GlobalEntryState.init() {
return GlobalEntryState(foodEntries: {});
}
static from(GlobalEntryState state) {
return GlobalEntryState(foodEntries: state.foodEntries);
}
bool addEntry(FoodEntryState entry, DateTime date) {
var list = foodEntries[date];
if (list == null) {
return false;
}
list.add(entry);
return true;
}
}
class FoodEntryState {
final List<FoodEntry> foodEntries;
FoodEntryState({required this.foodEntries});
factory FoodEntryState.init() {
return FoodEntryState(foodEntries: []);
}
static from(FoodEntryState state) {
List<FoodEntry> newList = List.from(state.foodEntries);
return FoodEntryState(foodEntries: newList);
}
void addEntry(FoodEntry entry) {
foodEntries.add(entry);
}
}
class FoodEntry {
final String name;
final double mass;
final double kcalPerMass;
final String id;
final String name;
final int mass;
final int kcalPer100;
final bool waitingForNetwork;
bool isSelected;
FoodEntry({
factory FoodEntryState({
required name,
required mass,
required kcalPer100,
required waitingForNetwork,
required isSelected,
}) {
return FoodEntryState.withID(
id: const Uuid().v1(),
name: name,
mass: mass,
kcalPer100: kcalPer100,
waitingForNetwork: waitingForNetwork,
isSelected: isSelected,
);
}
FoodEntryState.withID({
required this.id,
required this.name,
required this.mass,
required this.kcalPerMass,
}) : id = const Uuid().v1();
required this.kcalPer100,
required this.waitingForNetwork,
required this.isSelected,
});
@override
String toString() {
//we use quotation marks around the name because the name might contain
//commas and we want to store it in a csv file
return '$id,"$name",$mass,$kcalPerMass';
return '$id,"$name",$mass,$kcalPer100';
}
}

View File

@ -3,33 +3,33 @@ import 'package:calorimeter/food_entry/food_entry_bloc.dart';
import 'package:calorimeter/utils/row_with_spacers_widget.dart';
class FoodEntryWidget extends StatefulWidget {
final FoodEntry entry;
final FoodEntryState entry;
final Function(BuildContext context, String id) onDelete;
final Function(BuildContext context, FoodEntryState entry) onChange;
final Function(BuildContext context, FoodEntryState entry) onTap;
const FoodEntryWidget(
{super.key, required this.entry, required this.onDelete});
const FoodEntryWidget({
super.key,
required this.entry,
required this.onDelete,
required this.onChange,
required this.onTap,
});
@override
State<FoodEntryWidget> createState() => _FoodEntryWidgetState();
}
class _FoodEntryWidgetState extends State<FoodEntryWidget> {
late bool showCancelAndDelete;
@override
void initState() {
showCancelAndDelete = false;
super.initState();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
showCancelAndDelete = !showCancelAndDelete;
});
},
onTap: () => widget.onTap(context, widget.entry),
child: Stack(
children: [
Positioned.fill(
@ -38,15 +38,28 @@ class _FoodEntryWidgetState extends State<FoodEntryWidget> {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: RowWidget(
Text(widget.entry.name),
Text(widget.entry.mass.ceil().toString(),
textAlign: TextAlign.end),
Text(widget.entry.kcalPerMass.ceil().toString(),
widget.entry.waitingForNetwork
? const Center(child: CircularProgressIndicator())
: Text(widget.entry.name),
widget.entry.waitingForNetwork
? Container()
: Text(widget.entry.mass.ceil().toString(),
textAlign: TextAlign.end),
Opacity(
opacity: showCancelAndDelete ? 0.0 : 1.0,
child: Text(
(widget.entry.mass * widget.entry.kcalPerMass / 100)
opacity: widget.entry.isSelected ? 0.0 : 1.0,
child: widget.entry.waitingForNetwork
? Container()
: Text(widget.entry.kcalPer100.ceil().toString(),
textAlign: TextAlign.end),
),
Opacity(
opacity: widget.entry.isSelected ? 0.0 : 1.0,
child: widget.entry.waitingForNetwork
? Container()
: Text(
(widget.entry.mass *
widget.entry.kcalPer100 /
100)
.ceil()
.toString(),
textAlign: TextAlign.end),
@ -55,26 +68,35 @@ class _FoodEntryWidgetState extends State<FoodEntryWidget> {
),
),
Opacity(
opacity: showCancelAndDelete ? 0.66 : 0.0,
opacity: widget.entry.isSelected ? 0.66 : 0.0,
child: Container(
color: Theme.of(context).colorScheme.secondary)),
]),
),
Opacity(
opacity: showCancelAndDelete ? 1.0 : 0.0,
opacity: widget.entry.isSelected ? 1.0 : 0.0,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
SizedBox(
child: IconButton(
padding: const EdgeInsets.all(0.0),
icon: const Icon(Icons.cancel),
onPressed: showCancelAndDelete
? () => setState(() {
showCancelAndDelete = false;
})
: null,
),
icon: const Icon(Icons.edit),
onPressed: widget.entry.isSelected
? () async {
widget.onTap(context, widget.entry);
await showDialog(
context: context,
builder: (dialogContext) {
return FoodEntryChangeDialog(
entry: widget.entry,
onChange: (context, entry) {
widget.onChange(context, entry);
});
},
);
}
: null),
),
SizedBox(
child: IconButton(
@ -82,7 +104,7 @@ class _FoodEntryWidgetState extends State<FoodEntryWidget> {
iconSize: 24,
icon: const Icon(Icons.delete),
color: Colors.redAccent,
onPressed: showCancelAndDelete
onPressed: widget.entry.isSelected
? () => widget.onDelete(context, widget.entry.id)
: null),
),
@ -94,3 +116,125 @@ class _FoodEntryWidgetState extends State<FoodEntryWidget> {
);
}
}
class FoodEntryChangeDialog extends StatefulWidget {
final FoodEntryState entry;
final Function(BuildContext context, FoodEntryState entry) onChange;
const FoodEntryChangeDialog(
{required this.entry, super.key, required this.onChange});
@override
State<FoodEntryChangeDialog> createState() => _FoodEntryChangeDialogState();
}
class _FoodEntryChangeDialogState extends State<FoodEntryChangeDialog> {
late TextEditingController nameController;
late TextEditingController massController;
late TextEditingController kcalPer100Controller;
static const textFieldVerticalPadding = 16.0;
static const textFieldHorizontalPadding = 16.0;
@override
void initState() {
nameController = TextEditingController();
nameController.text = widget.entry.name;
massController = TextEditingController();
massController.text = widget.entry.mass.toString();
kcalPer100Controller = TextEditingController();
kcalPer100Controller.text = widget.entry.kcalPer100.toString();
super.initState();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: textFieldVerticalPadding,
horizontal: textFieldHorizontalPadding),
child: TextField(
onSubmitted: (val) => _onSubmitAction(),
controller: nameController,
decoration: const InputDecoration(
label: Text("Name"),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: textFieldVerticalPadding,
horizontal: textFieldHorizontalPadding),
child: TextField(
onSubmitted: (val) => _onSubmitAction(),
controller: massController,
decoration: const InputDecoration(
label: Text("Menge"),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: textFieldVerticalPadding,
horizontal: textFieldHorizontalPadding),
child: TextField(
onSubmitted: (val) => _onSubmitAction(),
controller: kcalPer100Controller,
decoration: const InputDecoration(
label: Text("kcal pro Menge"),
),
),
)
],
),
actions: [
IconButton(
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.cancel)),
IconButton(
onPressed: () => _onSubmitAction(),
icon: const Icon(Icons.check),
)
],
);
}
void _onSubmitAction() {
int mass;
int kcalPer100;
try {
mass = int.parse(massController.text);
} catch (e) {
mass = 0;
}
try {
kcalPer100 = int.parse(kcalPer100Controller.text);
} catch (e) {
kcalPer100 = 0;
}
var newEntry = FoodEntryState.withID(
id: widget.entry.id,
name: nameController.text,
mass: mass,
kcalPer100: kcalPer100,
waitingForNetwork: widget.entry.waitingForNetwork,
isSelected: false,
);
widget.onChange(context, newEntry);
Navigator.of(context).pop();
}
}

View File

@ -45,13 +45,28 @@ class FoodFactLookupClient {
class FoodFactModel {
final String name;
final int kcalPer100g;
final int mass;
FoodFactModel({required this.name, required this.kcalPer100g});
FoodFactModel({
required this.name,
required this.mass,
required this.kcalPer100g,
});
factory FoodFactModel.fromJson(Map<String, dynamic> json) {
String quantityString = json['product']['product_quantity'] ?? "0";
int quantity;
try {
quantity = int.parse(quantityString);
} catch (e) {
quantity = 0;
}
return FoodFactModel(
name: json['product']['product_name'],
kcalPer100g: json['product']['nutriments']['energy-kcal_100g']);
kcalPer100g: json['product']['nutriments']['energy-kcal_100g'],
mass: quantity);
}
}

View File

@ -1,15 +1,33 @@
import 'package:calorimeter/perdate/perdate_widget.dart';
import 'package:calorimeter/food_entry/food_entry_bloc.dart';
import 'package:calorimeter/perdate/perdate_pageview.dart';
import 'package:calorimeter/storage/storage.dart';
import 'package:calorimeter/utils/settings_bloc.dart';
import 'package:calorimeter/utils/theme_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
List<FoodEntryState> entriesForToday = [];
DateTime timeNow = DateTime.now();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
WidgetsFlutterBinding.ensureInitialized();
var storage = await FoodStorage.create();
await storage.buildFoodLookupDatabase();
timeNow = DateTime.now().copyWith(
hour: 0,
isUtc: true,
minute: 0,
second: 0,
millisecond: 0,
microsecond: 0);
entriesForToday = await storage.getEntriesForDate(timeNow);
var kcalLimit = await storage.readLimit();
var brightness = await storage.readBrightness();
@ -38,6 +56,13 @@ class MainApp extends StatelessWidget {
return SafeArea(
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => FoodEntryBloc(
storage: storage,
initialState:
GlobalEntryState(foodEntries: {timeNow: entriesForToday}),
),
),
BlocProvider(
create: (context) => SettingsDataBloc(
SettingsState(kcalLimit: kcalLimit),
@ -56,8 +81,25 @@ class MainApp extends StatelessWidget {
newBrightness = Brightness.dark;
}
return MaterialApp(
home: PerDateWidget(date: DateTime.now()),
return MaterialApp.router(
routerConfig: GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) {
return PerDatePageview(
initalDate: DateTime.now().copyWith(
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
microsecond: 0,
),
);
},
),
],
),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,

View File

@ -5,10 +5,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class FoodEntryList extends StatelessWidget {
final List<FoodEntry> entries;
final List<FoodEntryState> entries;
final DateTime date;
const FoodEntryList({
required this.entries,
required this.date,
super.key,
});
@ -17,6 +19,7 @@ class FoodEntryList extends StatelessWidget {
return ListView.builder(
itemCount: entries.length + 1,
itemBuilder: (BuildContext itemBuilderContext, int listIndex) {
//last item in list is the widget to enter food
if (listIndex == entries.length) {
return Column(
children: [
@ -24,7 +27,7 @@ class FoodEntryList extends StatelessWidget {
onAdd: (context, entry) {
context
.read<FoodEntryBloc>()
.add(FoodEntryEvent(entry: entry));
.add(FoodEntryEvent(entry: entry, forDate: date));
},
),
const SizedBox(height: 75),
@ -38,10 +41,20 @@ class FoodEntryList extends StatelessWidget {
FoodEntryWidget(
key: ValueKey(entries[entryIndex].id),
entry: entries[entryIndex],
onDelete: (callbackContext, id) {
callbackContext.read<FoodEntryBloc>().add(FoodDeletionEvent(
entryID: id,
));
onDelete: (_, id) {
context
.read<FoodEntryBloc>()
.add(FoodDeletionEvent(entryID: id, forDate: date));
},
onChange: (_, changedEntry) {
context.read<FoodEntryBloc>().add(
FoodChangedEvent(newEntry: changedEntry, forDate: date),
);
},
onTap: (_, tappedEntry) {
context.read<FoodEntryBloc>().add(
FoodEntryTapped(entry: tappedEntry, forDate: date),
);
},
),
const Divider(),

View File

@ -0,0 +1,76 @@
import 'package:calorimeter/perdate/perdate_widget.dart';
import 'package:flutter/material.dart';
class PerDatePageview extends StatefulWidget {
// this is the date for which the PerDate widget will be shown on screen
// left of it will be yesterday's PerDate widget
// right of it will be tomorrow's PerDate widget
final DateTime initalDate;
const PerDatePageview({required this.initalDate, super.key});
@override
State<PerDatePageview> createState() => _PerDatePageviewState();
}
class _PerDatePageviewState extends State<PerDatePageview> {
late PageController pageController;
late DateTime displayedDate;
late List<int> visitedIndexes = [];
final int initialOffset = 36500000;
//TODO: that is just ugly
bool backButtonWasPressed = false;
@override
void initState() {
super.initState();
pageController = PageController(initialPage: initialOffset);
displayedDate = widget.initalDate;
visitedIndexes.add(initialOffset);
}
@override
Widget build(BuildContext context) {
return BackButtonListener(
onBackButtonPressed: () async {
if (visitedIndexes.length == 1) {
return false;
}
visitedIndexes.removeLast();
backButtonWasPressed = true;
pageController.jumpToPage(visitedIndexes.last);
return true;
},
child: PageView.builder(
reverse: true,
controller: pageController,
onPageChanged: (value) {
if (backButtonWasPressed) {
backButtonWasPressed = false;
return;
}
visitedIndexes.add(value);
},
itemBuilder: (context, index) {
var dateToBuildWidgetFor =
displayedDate.subtract(Duration(days: index - initialOffset));
return PerDateWidget(
key: ValueKey(dateToBuildWidgetFor.toString()),
date: dateToBuildWidgetFor.copyWith(isUtc: true),
onDateSelected: (dateSelected) {
if (dateSelected == null) return;
var diff = dateSelected.difference(dateToBuildWidgetFor);
var newIndex = index - diff.inDays;
pageController.jumpToPage(newIndex);
});
}),
);
}
}

View File

@ -1,6 +1,4 @@
import 'package:barcode_scan2/barcode_scan2.dart';
import 'package:calorimeter/food_scan/food_fact_lookup.dart';
import 'package:calorimeter/utils/enter_food_controller.dart';
import 'package:calorimeter/utils/scan_food_floating_button.dart';
import 'package:calorimeter/utils/app_drawer.dart';
import 'package:calorimeter/food_entry/food_entry_bloc.dart';
@ -13,84 +11,83 @@ import 'package:calorimeter/utils/theme_switcher_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class PerDateWidget extends StatefulWidget {
final DateTime date;
const PerDateWidget({super.key, required this.date});
final Function(DateTime?) onDateSelected;
const PerDateWidget(
{super.key, required this.date, required this.onDateSelected});
@override
State<PerDateWidget> createState() => _PerDateWidgetState();
}
class _PerDateWidgetState extends State<PerDateWidget> {
class _PerDateWidgetState extends State<PerDateWidget>
with AutomaticKeepAliveClientMixin<PerDateWidget> {
late FoodStorage storage;
late Future<List<FoodEntry>> entriesFuture;
List<FoodEntry> entries = [];
List<FoodEntryState> entries = [];
@override
void initState() {
storage = FoodStorage.getInstance();
entriesFuture = storage.getEntriesForDate(widget.date);
entriesFuture.then((val) {
entries = val;
});
context
.read<FoodEntryBloc>()
.add(PageBeingInitialized(forDate: widget.date));
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: entriesFuture,
builder: (context, snapshot) {
return snapshot.connectionState != ConnectionState.done
? const Center(child: CircularProgressIndicator())
: MultiProvider(
providers: [
ChangeNotifierProvider(
create: (context) => EnterFoodController()),
BlocProvider(
create: (context) => FoodEntryBloc(
initialState: FoodEntryState(foodEntries: entries),
storage: storage,
forDate: widget.date,
),
)
],
child: BlocBuilder<FoodEntryBloc, FoodEntryState>(
builder: (context, state) {
super.build(context);
return BlocConsumer<FoodEntryBloc, GlobalEntryState>(
listener: (context, pageState) {
if (pageState.errorString != null) {
showNewSnackbarWith(context, pageState.errorString!);
}
},
builder: (context, globalState) {
return Scaffold(
appBar: AppBar(
title:
Text(DateFormat.yMMMMd('de').format(widget.date)),
title: Text(DateFormat.yMMMMd('de').format(widget.date)),
actions: const [ThemeSwitcherButton()],
),
body: FoodEntryList(entries: state.foodEntries),
body: FoodEntryList(
entries: globalState.foodEntries[widget.date] ?? [],
date: widget.date),
bottomNavigationBar: BottomAppBar(
shape: const RectangularNotchShape(),
color: Theme.of(context).colorScheme.secondary,
child: SumWidget(foodEntries: state.foodEntries)),
child: SumWidget(
foodEntries: globalState.foodEntries[widget.date] ?? [])),
drawer: const AppDrawer(),
floatingActionButton: OverflowBar(
children: [
ScanFoodFloatingButton(onPressed: () {
_onScanButtonPressed(context);
}),
floatingActionButton: OverflowBar(children: [
ScanFoodFloatingButton(
onPressed: () {
var result = BarcodeScanner.scan();
context.read<FoodEntryBloc>().add(
BarcodeScanned(
scanResultFuture: result,
forDate: widget.date,
),
);
},
),
const SizedBox(width: 8),
CalendarFloatingButton(
startFromDate: widget.date,
onDateSelected: (dateSelected) {
_onDateSelected(dateSelected);
widget.onDateSelected(dateSelected);
},
),
],
),
]),
floatingActionButtonLocation:
FloatingActionButtonLocation.endDocked);
},
),
);
},
);
}
@ -103,61 +100,8 @@ class _PerDateWidgetState extends State<PerDateWidget> {
..showSnackBar(snackbar);
}
void _onDateSelected(DateTime date) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return PerDateWidget(date: date);
},
),
).then((val) {
setState(
() {
entriesFuture = storage.getEntriesForDate(widget.date);
entriesFuture.then(
(val) {
entries = val;
},
);
},
);
});
}
void _onScanButtonPressed(BuildContext context) async {
var client = FoodFactLookupClient();
var scanResult = await BarcodeScanner.scan();
if (scanResult.type == ResultType.Cancelled) {
return;
}
if (!context.mounted) return;
if (scanResult.type == ResultType.Error) {
showNewSnackbarWith(context, "Fehler beim Scannen des Barcodes.");
}
var response = await client.retrieveFoodInfo(scanResult.rawContent);
if (!context.mounted) return;
if (response.status == FoodFactResponseStatus.barcodeNotFound) {
showNewSnackbarWith(context, "Barcode konnte nicht gefunden werden.");
return;
}
if (response.status == FoodFactResponseStatus.foodFactServerNotReachable) {
showNewSnackbarWith(
context, "OpenFoodFacts-Server konnte nicht erreicht werden.");
return;
}
context.read<EnterFoodController>().set(
response.food!.name,
response.food!.kcalPer100g.toString(),
);
}
@override
bool get wantKeepAlive => true;
}
class ErrorSnackbar extends SnackBar {

View File

@ -1,4 +1,3 @@
import 'dart:developer';
import 'dart:io';
import 'package:calorimeter/food_entry/food_entry_bloc.dart';
@ -8,7 +7,7 @@ import 'package:universal_platform/universal_platform.dart';
class FoodStorage {
static late FoodStorage _instance;
late String path;
final Map<String, double> _foodLookupDatabase = {};
final Map<String, int> _foodLookupDatabase = {};
FoodStorage._create();
@ -31,8 +30,8 @@ class FoodStorage {
static FoodStorage getInstance() => _instance;
Future<List<FoodEntry>> getEntriesForDate(DateTime date) async {
List<FoodEntry> entries = [];
Future<List<FoodEntryState>> getEntriesForDate(DateTime date) async {
List<FoodEntryState> entries = [];
var filePath = '$path/${date.year}/${date.month}/${date.day}';
var file = File(filePath);
@ -44,10 +43,28 @@ class FoodStorage {
for (var line in lines) {
var fields = line.splitWithIgnore(',', ignoreIn: '"');
var entry = FoodEntry(
int mass = 0;
int kcalPerMass = 0;
try {
mass = int.parse(fields[2]);
} catch (e) {
mass = double.parse(fields[2]).toInt();
}
try {
kcalPerMass = int.parse(fields[3]);
} catch (e) {
kcalPerMass = double.parse(fields[3]).toInt();
}
var entry = FoodEntryState(
name: fields[1].replaceAll('"', ""),
mass: double.parse(fields[2]),
kcalPerMass: double.parse(fields[3]));
mass: mass,
kcalPer100: kcalPerMass,
waitingForNetwork: false,
isSelected: false,
);
entries.add(entry);
}
@ -55,7 +72,7 @@ class FoodStorage {
}
Future<void> writeEntriesForDate(
DateTime date, List<FoodEntry> foodEntries) async {
DateTime date, List<FoodEntryState> foodEntries) async {
var filePath = '$path/${date.year}/${date.month}/${date.day}';
var file = File(filePath);
@ -139,11 +156,11 @@ class FoodStorage {
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);
var durationToPast = Duration(days: idx);
return DateTime.now().subtract(durationToPast);
});
for (var date in dates) {
for (var date in dates.reversed) {
addFoodEntryToLookupDatabaseFor(date);
}
}
@ -152,17 +169,15 @@ class FoodStorage {
var entriesForDate = await getEntriesForDate(date);
for (var entry in entriesForDate) {
_foodLookupDatabase[entry.name] = entry.kcalPerMass;
log("Added entry: ${entry.name}/${entry.kcalPerMass}");
_foodLookupDatabase[entry.name] = entry.kcalPer100;
}
}
void addFoodEntryToLookupDatabase(FoodEntry entry) {
_foodLookupDatabase[entry.name] = entry.kcalPerMass;
log("Added entry: ${entry.name}/${entry.kcalPerMass}");
void addFoodEntryToLookupDatabase(FoodEntryState entry) {
_foodLookupDatabase[entry.name] = entry.kcalPer100;
}
Map<String, double> get getFoodEntryLookupDatabase => _foodLookupDatabase;
Map<String, int> get getFoodEntryLookupDatabase => _foodLookupDatabase;
}
extension SplitWithIgnore on String {

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
class CalendarFloatingButton extends StatelessWidget {
final DateTime startFromDate;
final Function(DateTime) onDateSelected;
final Function(DateTime?) onDateSelected;
const CalendarFloatingButton(
{super.key, required this.startFromDate, required this.onDateSelected});
@ -17,12 +17,12 @@ class CalendarFloatingButton extends StatelessWidget {
initialDate: startFromDate,
currentDate: DateTime.now(),
firstDate: DateTime.now().subtract(const Duration(days: 365 * 10)),
lastDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
);
if (!context.mounted) return;
onDateSelected(datePicked ?? DateTime.now());
onDateSelected(datePicked);
},
heroTag: "calendarFAB",
child: const Icon(Icons.today),

View File

@ -1,12 +0,0 @@
import 'package:flutter/material.dart';
class EnterFoodController extends ChangeNotifier {
String name = "";
String kcalPer100g = "";
void set(String newName, String newKcal) {
name = newName;
kcalPer100g = newKcal;
notifyListeners();
}
}

View File

@ -4,7 +4,7 @@ import 'package:calorimeter/food_entry/food_entry_bloc.dart';
import 'package:calorimeter/utils/settings_bloc.dart';
class SumWidget extends StatelessWidget {
final List<FoodEntry> foodEntries;
final List<FoodEntryState> foodEntries;
const SumWidget({required this.foodEntries, super.key});
@override
@ -13,7 +13,7 @@ class SumWidget extends StatelessWidget {
builder: (context, state) {
var sum = 0.0;
for (var entry in foodEntries) {
sum += entry.kcalPerMass / 100 * entry.mass;
sum += entry.kcalPer100 / 100 * entry.mass;
}
var diff = state.kcalLimit - sum;
var diffLimit = state.kcalLimit ~/ 4;

View File

@ -183,18 +183,18 @@ packages:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
sha256: "619817c4b65b322b5104b6bb6dfe6cda62d9729bd7ad4303ecc8b4e690a67a77"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
version: "0.14.1"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
version: "5.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
@ -205,6 +205,11 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
frontend_server_client:
dependency: transitive
description:
@ -221,6 +226,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: "6f1b756f6e863259a99135ff3c95026c3cdca17d10ebef2bba2261a25ddc8bbc"
url: "https://pub.dev"
source: hosted
version: "14.3.0"
http_multi_server:
dependency: transitive
description:
@ -305,10 +318,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
version: "5.0.0"
logging:
dependency: transitive
description:
@ -353,10 +366,10 @@ packages:
dependency: transitive
description:
name: mime
sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "1.0.6"
version: "2.0.0"
nested:
dependency: transitive
description:
@ -401,10 +414,10 @@ packages:
dependency: transitive
description:
name: path_provider_android
sha256: "6f01f8e37ec30b07bc424b4deabac37cacb1bc7e2e515ad74486039918a37eb7"
sha256: f7544c346a0742aee1450f9e5c0f5269d7c602b9c95fdbcd9fb8f5b1df13b1cc
url: "https://pub.dev"
source: hosted
version: "2.2.10"
version: "2.2.11"
path_provider_foundation:
dependency: transitive
description:
@ -646,10 +659,10 @@ packages:
dependency: "direct main"
description:
name: uuid
sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.0"
version: "4.5.1"
vector_math:
dependency: transitive
description:
@ -678,10 +691,10 @@ packages:
dependency: transitive
description:
name: web
sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062
sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.1.0"
web_socket:
dependency: transitive
description:
@ -710,10 +723,10 @@ packages:
dependency: transitive
description:
name: xdg_directories
sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
version: "1.1.0"
xml:
dependency: transitive
description:
@ -732,4 +745,4 @@ packages:
version: "3.1.2"
sdks:
dart: ">=3.5.3 <4.0.0"
flutter: ">=3.22.0"
flutter: ">=3.24.0"

View File

@ -20,12 +20,13 @@ dependencies:
barcode_scan2: ^4.3.3
provider: ^6.1.2
test: ^1.25.7
go_router: ^14.3.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter_launcher_icons: "^0.13.1"
flutter_lints: ^5.0.0
flutter_launcher_icons: ^0.14.1
flutter:
uses-material-design: true