Marco
7728ec3b66
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.
356 lines
10 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|