calorimeter/lib/food_entry/food_entry_bloc.dart
Marco 7728ec3b66 Change behavior of global state
Until now, FoodEntryBloc (which is holding the global state  for every
day) would cause a change in every widget in the tree. For example, when
an entry for one day gets added, all other entries in opened days would
also be rebuilt.

Now, the GlobalState will be emitted with an additional date, which
signals, which date caused the state change.
With this information, I selectively only build the EntryLists that
needs to be rebuilt.

Additionally, the calendar FAB will push a new route instead of
navigating to a new day by utilizing the pageController.
2025-01-12 17:23:59 +01:00

356 lines
10 KiB
Dart

/* SPDX-License-Identifier: GPL-3.0-or-later */
/* Copyright (C) 2024 Marco Groß <mgross@sw-gross.de> */
import 'package:barcode_scan2/barcode_scan2.dart';
import 'package:calorimeter/food_scan/food_fact_lookup.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:calorimeter/storage/storage.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:uuid/uuid.dart';
class FoodEntryBloc extends Bloc<FoodEvent, GlobalEntryState> {
final GlobalEntryState initialState;
final FoodStorage storage;
FoodEntryBloc({required this.initialState, required this.storage})
: super(initialState) {
on<PageBeingInitialized>(handlePageBeingInitialized);
on<FoodEntryEvent>(handleFoodEntryEvent);
on<FoodChangedEvent>(handleFoodChangedEvent);
on<FoodDeletionEvent>(handleDeleteFoodEvent);
on<BarcodeAboutToBeScanned>(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, stateChangedForDate: event.forDate));
}
void handleFoodEntryEvent(
FoodEntryEvent event, Emitter<GlobalEntryState> emit) async {
var entriesForDate = state.foodEntries[event.forDate];
entriesForDate ??= [];
entriesForDate.add(event.entry);
await storage.writeEntriesForDate(event.forDate, entriesForDate);
storage.addFoodEntryToLookupDatabase(event.entry);
var newFoodEntries = state.foodEntries;
newFoodEntries.addAll({event.forDate: entriesForDate});
emit(GlobalEntryState(
foodEntries: newFoodEntries, stateChangedForDate: event.forDate));
}
void handleFoodChangedEvent(
FoodChangedEvent event, Emitter<GlobalEntryState> emit) async {
var entriesForDate = state.foodEntries[event.forDate];
if (entriesForDate == null) return;
var index = entriesForDate.indexWhere((entry) {
return entry.id == event.newEntry.id;
});
entriesForDate.removeAt(index);
entriesForDate.insert(index, event.newEntry);
await storage.writeEntriesForDate(event.forDate, entriesForDate);
storage.addFoodEntryToLookupDatabase(event.newEntry);
var newFoodEntries = state.foodEntries;
newFoodEntries.addAll({event.forDate: entriesForDate});
emit(GlobalEntryState(
foodEntries: newFoodEntries, stateChangedForDate: event.forDate));
}
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);
var newFoodEntries = state.foodEntries;
newFoodEntries.addAll({event.forDate: entriesForDate});
emit(GlobalEntryState(
foodEntries: newFoodEntries, stateChangedForDate: event.forDate));
}
void handleBarcodeScannedEvent(
BarcodeAboutToBeScanned event, Emitter<GlobalEntryState> emit) async {
ScanResult scanResult = ScanResult();
try {
scanResult = await BarcodeScanner.scan();
} on PlatformException catch (e) {
if (e.code == BarcodeScanner.cameraAccessDenied) {
emit(GlobalEntryState(
foodEntries: state.foodEntries,
stateChangedForDate: event.forDate,
appError:
GlobalAppError(GlobalAppErrorType.errCameraPermissionDenied)));
}
return;
}
var client = FoodFactLookupClient();
var entriesForDate = state.foodEntries[event.forDate];
if (entriesForDate == null) return;
if (scanResult.type == ResultType.Cancelled) {
return;
}
if (scanResult.type == ResultType.Error) {
emit(GlobalEntryState(
foodEntries: state.foodEntries,
stateChangedForDate: event.forDate,
appError: GlobalAppError(GlobalAppErrorType.errGeneralError)));
return;
}
var responseFuture = client.retrieveFoodInfo(scanResult.rawContent);
var newEntryWaiting = FoodEntryState(
kcalPer100: 0,
name: "",
mass: 0,
waitingForNetwork: true,
isSelected: false,
);
entriesForDate.add(newEntryWaiting);
state.foodEntries.addAll({event.forDate: entriesForDate});
emit(GlobalEntryState(
foodEntries: state.foodEntries,
stateChangedForDate: event.forDate,
));
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,
stateChangedForDate: event.forDate,
appError: GlobalAppError(GlobalAppErrorType.errbarcodeNotFound)));
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,
stateChangedForDate: event.forDate,
appError:
GlobalAppError(GlobalAppErrorType.errServerNotReachable)));
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 newFoodEntries = state.foodEntries;
newFoodEntries.addAll({event.forDate: entriesForDate});
emit(GlobalEntryState(
foodEntries: newFoodEntries, stateChangedForDate: event.forDate));
});
}
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, stateChangedForDate: event.forDate));
}
}
class FoodEvent {
final DateTime forDate;
FoodEvent({required this.forDate});
}
class PageBeingInitialized extends FoodEvent {
PageBeingInitialized({required super.forDate});
}
class FoodEntryEvent extends FoodEvent {
final FoodEntryState 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, required super.forDate});
}
class BarcodeAboutToBeScanned extends FoodEvent {
BarcodeAboutToBeScanned({required super.forDate});
}
class FoodEntryTapped extends FoodEvent {
final FoodEntryState entry;
FoodEntryTapped({required this.entry, required super.forDate});
}
class PermissionException extends FoodEvent {
PermissionException({required super.forDate});
}
class PageEntryState {}
class GlobalEntryState {
final Map<DateTime, List<FoodEntryState>> foodEntries;
final GlobalAppError? appError;
//we use this to only redraw pages whose entries changed
final DateTime? stateChangedForDate;
GlobalEntryState(
{required this.foodEntries, this.stateChangedForDate, this.appError});
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 String id;
final String name;
final int mass;
final int kcalPer100;
final bool waitingForNetwork;
bool isSelected;
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.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,$kcalPer100';
}
}
enum GlobalAppErrorType {
errGeneralError,
errbarcodeNotFound,
errServerNotReachable,
errCameraPermissionDenied
}
class GlobalAppError {
final GlobalAppErrorType type;
GlobalAppError(this.type);
String toErrorString(BuildContext context) {
switch (type) {
case GlobalAppErrorType.errGeneralError:
return AppLocalizations.of(context)!.errGeneralBarcodeError;
case GlobalAppErrorType.errbarcodeNotFound:
return AppLocalizations.of(context)!.errBarcodeNotFound;
case GlobalAppErrorType.errServerNotReachable:
return AppLocalizations.of(context)!.errServerNotReachable;
case GlobalAppErrorType.errCameraPermissionDenied:
return AppLocalizations.of(context)!.errPermissionNotGranted;
}
}
}