import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_file_dialog/flutter_file_dialog.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_speed_dial/flutter_speed_dial.dart'; import 'package:flutter_tesseract_ocr/flutter_tesseract_ocr.dart'; import 'package:grouped_list/grouped_list.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/entry_data.dart'; import 'package:prasule/api/wallet.dart'; import 'package:prasule/api/wallet_entry.dart'; import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/main.dart'; import 'package:prasule/network/tessdata.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformfield.dart'; import 'package:prasule/pw/platformroute.dart'; import 'package:prasule/util/drawer.dart'; import 'package:prasule/util/sorting.dart'; import 'package:prasule/util/text_color.dart'; import 'package:prasule/util/utils.dart'; import 'package:prasule/views/create_entry.dart'; import 'package:prasule/views/settings/settings.dart'; import 'package:prasule/views/settings/tessdata_list.dart'; import 'package:prasule/views/setup.dart'; /// Main view, shows entries class HomeView extends StatefulWidget { /// Main view, shows entries const HomeView({super.key}); @override State createState() => _HomeViewState(); } class _HomeViewState extends State { Wallet? selectedWallet; // current wallet List wallets = []; // all available wallets DateTime? prevDate; late String locale; // user's locale var _searchActive = false; // whether search field is shown var _filter = ""; // search filter final searchFocus = FocusNode(); SortType sort = SortType.newest; OverlayEntry? overlayEntry; @override void didChangeDependencies() { super.didChangeDependencies(); locale = Localizations.localeOf(context).languageCode; initializeDateFormatting(Localizations.localeOf(context).languageCode); } @override void initState() { super.initState(); loadWallet(); } Future loadWallet() async { wallets = await WalletManager.listWallets(); if (wallets.isEmpty && mounted) { unawaited( Navigator.of(context) .pushReplacement(platformRoute((c) => const SetupView())), ); return; } selectedWallet = wallets.first; selectedWallet!.recurEntries(); setState(() {}); } @override Widget build(BuildContext context) { return PopScope( canPop: !_searchActive, // don't pop when we just want // to deactivate searchfield onPopInvoked: (b) { if (b) return; _searchActive = false; _filter = ""; overlayEntry?.remove(); setState(() {}); }, child: Scaffold( drawer: makeDrawer(context, 1), floatingActionButton: SpeedDial( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(16)), ), icon: Icons.add, activeIcon: Icons.close, children: [ if (kDebugMode) SpeedDialChild( child: const Icon(Icons.bug_report), label: AppLocalizations.of(context).createTestData, onTap: () { // debug option to quickly fill a wallet with data if (selectedWallet == null) return; selectedWallet!.createTestEntries().then((_) { Navigator.of(context).pushReplacement( platformRoute( (p0) => const HomeView(), ), ); }); }, ), SpeedDialChild( child: const Icon(Icons.edit), label: AppLocalizations.of(context).addNew, onTap: () async { final sw = await Navigator.of(context).push( MaterialPageRoute( builder: (c) => CreateSingleEntryView( w: selectedWallet!, locale: locale, ), ), ); if (sw != null) { selectedWallet = sw; } setState(() {}); }, ), SpeedDialChild( child: const Icon(Icons.camera_alt), label: AppLocalizations.of(context).addCamera, onTap: () { startOcr(SourceType.camera); }, ), SpeedDialChild( child: const Icon(Icons.image), label: AppLocalizations.of(context).addGallery, onTap: () { startOcr(SourceType.photoLibrary); }, ), ], ), appBar: AppBar( title: AnimatedCrossFade( duration: const Duration(milliseconds: 500), crossFadeState: _searchActive ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: PlatformField( inputBorder: InputBorder.none, focusNode: searchFocus, onChanged: (e) { _filter = e; setState(() {}); }, labelText: AppLocalizations.of(context).searchLabel, ), secondChild: DropdownButton( value: (selectedWallet == null) ? -1 : wallets.indexOf( wallets .where((w) => w.name == selectedWallet!.name) .first, ), items: [ ...wallets.map( (e) => DropdownMenuItem( value: wallets.indexOf( e, ), child: Text(e.name), ), ), DropdownMenuItem( value: -1, child: Text(AppLocalizations.of(context).newWallet), ), ], onChanged: (v) async { if (v == null || v == -1) { await Navigator.of(context).push( platformRoute( (c) => const SetupView( newWallet: true, ), ), ); wallets = await WalletManager.listWallets(); selectedWallet = wallets.last; setState(() {}); return; } selectedWallet = wallets[v]; setState(() {}); }, ), ), actions: _searchActive ? [] : [ PopupMenuButton( tooltip: AppLocalizations.of(context).sort, icon: const Icon(Icons.sort_rounded), itemBuilder: (context) => [ AppLocalizations.of(context).sortNewest, AppLocalizations.of(context).sortOldest, ] .map( (e) => PopupMenuItem( value: e, child: Text(e), ), ) .toList(), onSelected: (value) { if (value == AppLocalizations.of(context).sortNewest) { sort = SortType.newest; setState(() {}); } else if (value == AppLocalizations.of(context).sortOldest) { sort = SortType.oldest; setState(() {}); } }, ), PopupMenuButton( itemBuilder: (context) => [ AppLocalizations.of(context).settings, AppLocalizations.of(context).search, AppLocalizations.of(context).about, ] .map((e) => PopupMenuItem(value: e, child: Text(e))) .toList(), onSelected: (value) { if (value == AppLocalizations.of(context).settings) { Navigator.of(context) .push( platformRoute( (context) => const SettingsView(), ), ) .then((value) async { wallets = await WalletManager.listWallets(); selectedWallet = await WalletManager.loadWallet( selectedWallet!.name, ); setState(() {}); }); } else if (value == AppLocalizations.of(context).about) { showAbout(context); } else if (value == AppLocalizations.of(context).search) { _searchActive = !_searchActive; if (!_searchActive) { _filter = ""; } else { overlayEntry = OverlayEntry( builder: (context) => Align( alignment: Alignment.bottomCenter, child: SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height - 100, child: GestureDetector( onTap: () { if (!searchFocus.hasFocus) { _searchActive = false; _filter = ""; overlayEntry?.remove(); setState(() {}); return; } searchFocus.unfocus(); }, ), ), ), ); Overlay.of(context).insert( overlayEntry!, ); } setState(() {}); } }, ), ], ), body: Center( child: SizedBox( width: MediaQuery.of(context).size.width * 0.9, height: MediaQuery.of(context).size.height, child: (selectedWallet == null) ? const Column( children: [ SizedBox( width: 40, height: 40, child: CircularProgressIndicator(), ), ], ) : (selectedWallet!.entries.isEmpty) ? Column( children: [ Text( AppLocalizations.of(context).noEntries, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), ), Text( AppLocalizations.of(context).noEntriesSub, ), ], ) : Overlay( initialEntries: [ OverlayEntry( builder: (context) => Column( children: [ Text( NumberFormat.compactCurrency( locale: locale, symbol: selectedWallet!.currency.symbol, ).format( selectedWallet!.currentBalance, ), style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 22, ), ), const SizedBox( height: 5, ), if (selectedWallet!.calculateMonthStatus( DateTime.now().month, DateTime.now().year, ) == 0) Text(AppLocalizations.of(context).evenMoney) else RichText( text: TextSpan( children: [ TextSpan( text: AppLocalizations.of(context) .balanceStatusA, style: TextStyle( color: Theme.of(context) .colorScheme .onSurface, ), ), TextSpan( style: TextStyle( color: (selectedWallet! .calculateMonthStatus( DateTime.now().month, DateTime.now().year, ) > 0 ? ((MediaQuery.of(context) .platformBrightness == Brightness.dark) ? Colors.green.shade300 : Colors.green) .harmonizeWith( Theme.of(context) .colorScheme .primary, ) : ((MediaQuery.of(context) .platformBrightness == Brightness.dark) ? Colors.red.shade300 : Colors.red) .harmonizeWith( Theme.of(context) .colorScheme .primary, )), ), text: NumberFormat.compactCurrency( locale: locale, symbol: selectedWallet!.currency.symbol, ).format( selectedWallet! .calculateMonthStatus( DateTime.now().month, DateTime.now().year, ), ), ), TextSpan( text: AppLocalizations.of(context) .balanceStatusB, style: TextStyle( color: Theme.of(context) .colorScheme .onSurface, ), ), ], ), ), const SizedBox( height: 10, ), Expanded( child: GroupedListView( groupHeaderBuilder: (element) => Text( DateFormat.yMMMM(locale) .format(element.date), style: TextStyle( color: Theme.of(context) .colorScheme .primary, ), ), elements: selectedWallet!.entries .where( (element) => element.data.name .toLowerCase() .contains(_filter.toLowerCase()), ) .toList(), itemComparator: (a, b) => (sort == SortType.newest) ? b.date.compareTo(a.date) : a.date.compareTo(b.date), groupBy: (e) => DateFormat.yMMMM(locale).format(e.date), groupComparator: (a, b) => (sort == SortType.newest) ? groupSortNewest(a, b, locale) : groupSortOldest(a, b, locale), itemBuilder: (context, element) => Slidable( endActionPane: ActionPane( extentRatio: 0.3, motion: const ScrollMotion(), children: [ SlidableAction( backgroundColor: Theme.of(context) .colorScheme .error, foregroundColor: Theme.of(context) .colorScheme .onError, icon: Icons.delete, onPressed: (c) { showAdaptiveDialog( context: context, builder: (cx) => AlertDialog.adaptive( title: Text( AppLocalizations.of( context, ).sureDialog, ), content: Text( AppLocalizations.of(context) .deleteSure, ), actions: [ PlatformButton( text: AppLocalizations.of( context, ).yes, onPressed: () { selectedWallet?.entries .removeWhere( (e) => e.id == element.id, ); WalletManager .saveWallet( selectedWallet!, ); Navigator.of(cx).pop(); setState(() {}); }, ), PlatformButton( text: AppLocalizations.of( context, ).no, onPressed: () { Navigator.of(cx).pop(); }, ), ], ), ); }, ), ], ), child: ListTile( onTap: () { Navigator.of(context) .push( MaterialPageRoute( builder: (c) => CreateSingleEntryView( locale: locale, w: selectedWallet!, editEntry: element, ), ), ) .then( (editedEntry) { if (editedEntry == null) { return; } selectedWallet!.entries .remove(element); selectedWallet!.entries .add(editedEntry); WalletManager.saveWallet( selectedWallet!, ); setState(() {}); }, ); }, leading: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: element.category.color, ), child: Padding( padding: const EdgeInsets.all(8), child: Icon( element.category.icon, color: element.category.color .calculateTextColor(), ), ), ), title: Text(element.data.name), subtitle: RichText( text: TextSpan( children: [ TextSpan( text: NumberFormat.currency( symbol: selectedWallet! .currency.symbol, ).format(element.data.amount), style: TextStyle( color: (element.type == EntryType.income) ? (MediaQuery.of(context) .platformBrightness == Brightness.dark) ? Colors .green.shade300 : Colors.green .harmonizeWith( Theme.of(context) .colorScheme .primary, ) : (MediaQuery.of(context) .platformBrightness == Brightness.dark) ? Colors.red.shade300 : Colors.red .harmonizeWith( Theme.of(context) .colorScheme .primary, ), ), ), TextSpan( text: " | ${DateFormat.MMMd(locale).format(element.date)}", style: TextStyle( color: Theme.of(context) .colorScheme .surface .calculateTextColor(), ), ), ], ), ), ), ), ), ), ], ), ), ], ), ), ), ), ); } Future startOcr(SourceType sourceType) async { final availableLanguages = await TessdataApi.getDownloadedData(); if (availableLanguages.isEmpty) { if (!mounted) return; await showAdaptiveDialog( context: context, builder: (c) => AlertDialog.adaptive( title: Text(AppLocalizations.of(context).missingOcr), actions: [ PlatformButton( text: AppLocalizations.of(context).download, onPressed: () { Navigator.of(context) .push( platformRoute( (c) => const TessdataListView(), ), ) .then((value) => Navigator.of(c).pop()); }, ), PlatformButton( text: AppLocalizations.of(context).ok, onPressed: () { Navigator.of(c).pop(); }, ), ], ), ); return; } if (!mounted) return; final selectedLanguages = List.filled(availableLanguages.length, false); selectedLanguages[0] = true; await showAdaptiveDialog( context: context, builder: (c) => StatefulBuilder( builder: (ctx, setDialogState) => AlertDialog.adaptive( actions: [ TextButton( onPressed: () async { final filePath = await FlutterFileDialog.pickFile( params: OpenFileDialogParams( dialogType: OpenFileDialogType.image, sourceType: sourceType, ), ); if (filePath == null) { if (mounted) Navigator.of(context).pop(); return; } // get selected languages final selected = availableLanguages .where( (element) => selectedLanguages[ availableLanguages.indexOf(element)], ) .join("+") .replaceAll(".traineddata", ""); logger.i(selected); if (!mounted) return; unawaited( showAdaptiveDialog( context: context, builder: (c) => AlertDialog.adaptive( title: Text(AppLocalizations.of(context).ocrLoading), ), barrierDismissible: false, ), ); final string = await FlutterTesseractOcr.extractText( filePath, language: selected, args: { "psm": "4", "preserve_interword_spaces": "1", }, ); if (!mounted) return; Navigator.of(context).pop(); logger.i(string); if (!mounted) return; final lines = string.split("\n") ..removeWhere((element) { element.trim(); return element.isEmpty; }); var price = 0.0; final description = StringBuffer(); for (final line in lines) { // find numbered prices on each line final regex = RegExp(r'\d+(?:(?:\.|,) {0,}\d{0,})+'); for (final match in regex.allMatches(line)) { price += double.tryParse(match.group(0).toString()) ?? 0; } description.write("${line.replaceAll(regex, "")}\n"); } if (!ctx.mounted) return; Navigator.of(ctx).pop(); // show edit final newEntry = await Navigator.of(context).push( platformRoute( (c) => CreateSingleEntryView( locale: locale, w: selectedWallet!, editEntry: WalletSingleEntry( data: EntryData( name: "", amount: price, description: description.toString(), ), type: EntryType.expense, date: DateTime.now(), category: selectedWallet!.categories.first, id: selectedWallet!.nextId, ), ), ), ); if (newEntry == null) return; selectedWallet!.entries.add(newEntry); await WalletManager.saveWallet(selectedWallet!); setState(() {}); }, child: const Text("Ok"), ), TextButton( onPressed: () { Navigator.of(c).pop(); }, child: const Text("Cancel"), ), ], title: Text(AppLocalizations.of(context).ocrSelect), content: Column( children: [ ...List.generate( availableLanguages.length, (index) => Row( children: [ Checkbox( value: selectedLanguages[index], onChanged: (value) { if (value == null || (selectedLanguages .where((element) => element) .length <= 1 && !value)) return; selectedLanguages[index] = value; setDialogState(() {}); }, ), const SizedBox( width: 10, ), Text(availableLanguages[index].split(".").first), ], ), ), ], ), ), ), ); } } /// Represents entry sorting type enum SortType { /// Sort newest first newest, /// Sort oldest first oldest }