/* SPDX-License-Identifier: GPL-3.0-or-later */ /* Copyright (C) 2024 Marco Groß */ 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 { final GlobalEntryState initialState; final FoodStorage storage; FoodEntryBloc({required this.initialState, required this.storage}) : super(initialState) { on(handlePageBeingInitialized); on(handleFoodEntryEvent); on(handleFoodChangedEvent); on(handleDeleteFoodEvent); on(handleBarcodeScannedEvent); on(handleFoodEntryTapped); } void handlePageBeingInitialized( PageBeingInitialized event, Emitter 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 emit) async { var entriesForDate = state.foodEntries[event.forDate]; entriesForDate ??= []; entriesForDate.add(event.entry); await storage.writeEntriesForDate(event.forDate, entriesForDate); storage.addFoodEntryToLookupDatabase(event.entry); // 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 handleFoodChangedEvent( FoodChangedEvent event, Emitter 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); // 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 handleDeleteFoodEvent( FoodDeletionEvent event, Emitter 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( BarcodeAboutToBeScanned event, Emitter emit) async { ScanResult scanResult = ScanResult(); try { scanResult = await BarcodeScanner.scan(); } on PlatformException catch (e) { if (e.code == BarcodeScanner.cameraAccessDenied) { emit(GlobalEntryState( foodEntries: state.foodEntries, appError: GlobalAppError(GlobalAppErrorType.errCameraPermissionDenied))); } return; } var entriesForDate = state.foodEntries[event.forDate]; if (entriesForDate == null) return; var client = FoodFactLookupClient(); if (scanResult.type == ResultType.Cancelled) { return; } if (scanResult.type == ResultType.Error) { emit(GlobalEntryState( foodEntries: state.foodEntries, 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); 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, 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, 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 entriesFromStorage = await storage.getEntriesForDate(event.forDate); var newFoodEntries = state.foodEntries; newFoodEntries.addAll({event.forDate: entriesFromStorage}); emit(GlobalEntryState(foodEntries: newFoodEntries)); }); } void handleFoodEntryTapped( FoodEntryTapped event, Emitter 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 { 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}); } /// This is the state for one date/page class GlobalEntryState { final Map> foodEntries; final GlobalAppError? appError; GlobalEntryState({required this.foodEntries, 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; } } }