// ignore_for_file: inference_failure_on_function_invocation import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.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:image_picker/image_picker.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/platformdialog.dart'; import 'package:prasule/pw/platformroute.dart'; import 'package:prasule/util/drawer.dart'; import 'package:prasule/util/text_color.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; List wallets = []; DateTime? prevDate; late String locale; @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 Scaffold( drawer: makeDrawer(context, 1), floatingActionButton: SpeedDial( 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!), ), ); if (sw != null) { selectedWallet = sw; } setState(() {}); }, ), SpeedDialChild( child: const Icon(Icons.camera_alt), label: AppLocalizations.of(context).addCamera, onTap: () async { final picker = ImagePicker(); final media = await picker.pickImage(source: ImageSource.camera); logger.i(media?.name); }, ), SpeedDialChild( child: const Icon(Icons.image), label: AppLocalizations.of(context).addGallery, onTap: () { startOcr(ImageSource.gallery); }, ), ], ), appBar: AppBar( title: DropdownButton( value: (selectedWallet == null) ? -1 : wallets.indexOf(selectedWallet!), 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: [ PopupMenuButton( itemBuilder: (context) => [ AppLocalizations.of(context).settings, 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 { selectedWallet = await WalletManager.loadWallet(selectedWallet!.name); }); } else if (value == AppLocalizations.of(context).about) { showAboutDialog( context: context, applicationLegalese: AppLocalizations.of(context).license, applicationName: "PraĊĦule", ); } }, ), ], ), 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, ), ], ) : GroupedListView( groupHeaderBuilder: (element) => Text( DateFormat.yMMMM(locale).format(element.date), style: TextStyle( color: Theme.of(context).colorScheme.primary, ), ), elements: selectedWallet!.entries, itemComparator: (a, b) => b.date.compareTo(a.date), groupBy: (e) => DateFormat.yMMMM(locale).format(e.date), groupComparator: (a, b) { // TODO: better sorting algorithm lol final yearA = RegExp(r'\d+').firstMatch(a); if (yearA == null) return 0; final yearB = RegExp(r'\d+').firstMatch(b); if (yearB == null) return 0; final compareYears = int.parse(yearB.group(0)!) .compareTo(int.parse(yearA.group(0)!)); if (compareYears != 0) return compareYears; final months = List.generate( 12, (index) => DateFormat.MMMM(locale).format( DateTime(2023, index + 1), ), ); final monthA = RegExp('[^0-9 ]+').firstMatch(a); if (monthA == null) return 0; final monthB = RegExp('[^0-9 ]+').firstMatch(b); if (monthB == null) return 0; return months.indexOf(monthB.group(0)!).compareTo( months.indexOf(monthA.group(0)!), ); }, itemBuilder: (context, element) => Slidable( endActionPane: ActionPane( motion: const ScrollMotion(), children: [ SlidableAction( onPressed: (c) { Navigator.of(context) .push( MaterialPageRoute( builder: (c) => CreateSingleEntryView( w: selectedWallet!, editEntry: element, ), ), ) .then( (editedEntry) { if (editedEntry == null) return; selectedWallet!.entries.remove(element); selectedWallet!.entries.add(editedEntry); WalletManager.saveWallet(selectedWallet!); setState(() {}); }, ); }, backgroundColor: Theme.of(context).colorScheme.secondary, foregroundColor: Theme.of(context).colorScheme.onSecondary, icon: Icons.edit, ), SlidableAction( backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, icon: Icons.delete, onPressed: (c) { showDialog( context: context, builder: (cx) => PlatformDialog( title: 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( 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 .background .calculateTextColor(), ), ), ], ), ), ), ), ), ), ), ); } Future startOcr(ImageSource imgSrc) async { final availableLanguages = await TessdataApi.getDownloadedData(); if (availableLanguages.isEmpty) { if (!mounted) return; await showDialog( context: context, builder: (c) => PlatformDialog( title: AppLocalizations.of(context).missingOcr, actions: [ PlatformButton( text: AppLocalizations.of(context).download, onPressed: () { Navigator.of(context).push( platformRoute( (c) => const TessdataListView(), ), ); 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 showDialog( context: context, builder: (c) => StatefulBuilder( builder: (ctx, setState) => PlatformDialog( actions: [ TextButton( onPressed: () async { final picker = ImagePicker(); final media = await picker.pickImage(source: imgSrc); if (media == 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( showDialog( context: context, builder: (c) => PlatformDialog( title: AppLocalizations.of(context).ocrLoading, ), barrierDismissible: false, ), ); final string = await FlutterTesseractOcr.extractText( media.path, 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( 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: 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; setState(() {}); }, ), const SizedBox( width: 10, ), Text(availableLanguages[index].split(".").first), ], ), ), ], ), ), ), ); } Future getLostData() async { final picker = ImagePicker(); final response = await picker.retrieveLostData(); if (response.isEmpty) { return; } final files = response.files; if (files != null) { logger.i("Found lost files"); _handleLostFiles(files); } else { logger.e(response.exception); } } void _handleLostFiles(List files) { // TODO: implement } }