import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.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/walletentry.dart'; import 'package:prasule/api/wallet.dart'; import 'package:prasule/api/walletmanager.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/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'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class HomeView extends StatefulWidget { 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(); } void loadWallet() async { wallets = await WalletManager.listWallets(); if (wallets.isEmpty && mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (c) => const SetupView())); return; } selectedWallet = wallets.first; 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!.entries.clear(); var random = Random(); for (var i = 0; i < 30; i++) { selectedWallet!.entries.add( WalletSingleEntry( data: EntryData( name: "Test Entry #${i + 1}", amount: random.nextInt(20000).toDouble(), ), type: (random.nextInt(3) > 0) ? EntryType.expense : EntryType.income, date: DateTime( 2023, random.nextInt(12) + 1, random.nextInt(28) + 1, ), category: selectedWallet!.categories[ random.nextInt(selectedWallet!.categories.length)], id: selectedWallet!.nextId, ), ); } logger.i(selectedWallet!.entries.length); // save and reload WalletManager.saveWallet(selectedWallet!).then((value) { Navigator.of(context).pushReplacement( platformRoute( (p0) => const HomeView(), ), ); }); }, ), SpeedDialChild( child: const Icon(Icons.edit), label: AppLocalizations.of(context).addNew, onTap: () async { var sw = await Navigator.of(context).push( MaterialPageRoute( builder: (c) => CreateEntryView(w: selectedWallet!), ), ); if (sw != null) { selectedWallet = sw; } setState(() {}); }), SpeedDialChild( child: const Icon(Icons.camera_alt), label: AppLocalizations.of(context).addCamera, onTap: () async { final ImagePicker picker = ImagePicker(); final XFile? 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(); logger.i(wallets.length); 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( MaterialPageRoute( builder: (context) => const SettingsView(), ), ); } 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 var yearA = RegExp(r'\d+').firstMatch(a); if (yearA == null) return 0; var yearB = RegExp(r'\d+').firstMatch(b); if (yearB == null) return 0; var compareYears = int.parse(yearA.group(0)!) .compareTo(int.parse(yearB.group(0)!)); if (compareYears != 0) return compareYears; var months = List.generate( 12, (index) => DateFormat.MMMM(locale).format( DateTime(2023, index + 1), ), ); var monthA = RegExp(r'[^0-9 ]+').firstMatch(a); if (monthA == null) return 0; var monthB = RegExp(r'[^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) => CreateEntryView( 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: Theme.of(context).colorScheme.secondary), child: Padding( padding: const EdgeInsets.all(8.0), child: Icon( element.category.icon, color: Theme.of(context).colorScheme.onSecondary, ), ), ), title: Text(element.data.name), subtitle: Text( "${element.data.amount} ${selectedWallet!.currency.symbol}"), ), ), ), ), ), ); } Future startOcr(ImageSource imgSrc) async { var availableLanguages = await TessdataApi.getDownloadedData(); if (availableLanguages.isEmpty) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).missingOcr), action: SnackBarAction( label: AppLocalizations.of(context).download, onPressed: () { Navigator.of(context).push( MaterialPageRoute( builder: (c) => const TessdataListView(), ), ); }, ), ), ); return; } if (!mounted) return; var selectedLanguages = List.filled(availableLanguages.length, false); selectedLanguages[0] = true; showDialog( context: context, builder: (c) => StatefulBuilder( builder: (ctx, setState) => PlatformDialog( actions: [ TextButton( onPressed: () async { final ImagePicker picker = ImagePicker(); final XFile? media = await picker.pickImage(source: imgSrc); if (media == null) { if (mounted) Navigator.of(context).pop(); return; } // get selected languages var selected = availableLanguages .where((element) => selectedLanguages[availableLanguages.indexOf(element)]) .join("+") .replaceAll(".traineddata", ""); logger.i(selected); if (!mounted) return; showDialog( context: context, builder: (c) => PlatformDialog( title: AppLocalizations.of(context).ocrLoading), barrierDismissible: false); var 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; var lines = string.split("\n") ..removeWhere((element) { element.trim(); return element.isEmpty; }); var price = 0.0; var description = ""; for (var line in lines) { // find numbered prices on each line var regex = RegExp(r'\d+(?:(?:\.|,) {0,}\d{0,})+'); for (var match in regex.allMatches(line)) { price += double.tryParse(match.group(0).toString()) ?? 0; } description += "${line.replaceAll(regex, "")}\n"; } Navigator.of(ctx).pop(); // show edit Navigator.of(context) .push( platformRoute( (c) => CreateEntryView( w: selectedWallet!, editEntry: WalletSingleEntry( data: EntryData( name: "", amount: price, description: description), type: EntryType.expense, date: DateTime.now(), category: selectedWallet!.categories.first, id: selectedWallet!.nextId, ), ), ), ) .then( (newEntry) { // save entry if we didn't return empty if (newEntry == null) return; selectedWallet!.entries.add(newEntry); 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 ImagePicker picker = ImagePicker(); final LostDataResponse response = await picker.retrieveLostData(); if (response.isEmpty) { return; } final List? files = response.files; if (files != null) { logger.i("Found lost files"); _handleLostFiles(files); } else { logger.e(response.exception); } } void _handleLostFiles(List files) { // TODO: implement } }