From 91be906894b01d8e1956f69edd156c37fc676db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 22 Jan 2024 19:20:41 +0100 Subject: [PATCH] feat: allow searching through entries --- CHANGELOG.md | 4 + android/app/src/main/AndroidManifest.xml | 3 +- lib/l10n/app_cs.arb | 3 +- lib/l10n/app_en.arb | 3 +- lib/pw/platformfield.dart | 8 +- lib/views/home.dart | 876 +++++++++++++---------- pubspec.yaml | 2 +- 7 files changed, 507 insertions(+), 392 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0114d..3d1b1ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ + +# 1.0.0-alpha+5 +- Add tests +- Add searching through entries to homepage # 1.0.0-alpha+4 - Fix OCR downloads # 1.0.0-alpha+3 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 23e2c46..91d67ed 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,8 @@ + android:icon="@mipmap/ic_launcher" + android:enableOnBackInvokedCallback="true"> { this.textStyle, this.textAlign = TextAlign.start, this.maxLines = 1, + this.focusNode, + this.inputBorder = const OutlineInputBorder(), }); final TextEditingController? controller; final bool? enabled; @@ -34,6 +36,8 @@ class PlatformField extends PlatformWidget { final TextStyle? textStyle; final TextAlign textAlign; final int? maxLines; + final InputBorder inputBorder; + final FocusNode? focusNode; @override TextField createAndroidWidget(BuildContext context) => TextField( @@ -43,10 +47,11 @@ class PlatformField extends PlatformWidget { obscureText: obscureText, decoration: InputDecoration( labelText: labelText, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)), + border: inputBorder, ), autocorrect: autocorrect, keyboardType: keyboardType, + focusNode: focusNode, style: textStyle, inputFormatters: inputFormatters, onChanged: onChanged, @@ -66,6 +71,7 @@ class PlatformField extends PlatformWidget { keyboardType: keyboardType, inputFormatters: inputFormatters, onChanged: onChanged, + focusNode: focusNode, maxLines: maxLines, style: textStyle, ); diff --git a/lib/views/home.dart b/lib/views/home.dart index be5637a..7223ccc 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -22,6 +22,7 @@ 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/platformfield.dart'; import 'package:prasule/pw/platformroute.dart'; import 'package:prasule/util/drawer.dart'; import 'package:prasule/util/text_color.dart'; @@ -44,6 +45,9 @@ class _HomeViewState extends State { List wallets = []; DateTime? prevDate; late String locale; + var _searchActive = false; + var _filter = ""; + final searchFocus = FocusNode(); @override void didChangeDependencies() { super.didChangeDependencies(); @@ -73,417 +77,515 @@ class _HomeViewState extends State { @override Widget build(BuildContext context) { - return Scaffold( - drawer: makeDrawer(context, 1), - floatingActionButton: SpeedDial( - icon: Icons.add, - activeIcon: Icons.close, - children: [ - if (kDebugMode) + return PopScope( + canPop: !_searchActive, // don't pop when we just want + // to deactivate searchfield + onPopInvoked: (b) { + if (b) return; + _searchActive = false; + _filter = ""; + setState(() {}); + }, + child: 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.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(), - ), - ); - }); + 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.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), - ), + 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); + }, ), - DropdownMenuItem( - value: -1, - child: Text(AppLocalizations.of(context).newWallet), + SpeedDialChild( + child: const Icon(Icons.image), + label: AppLocalizations.of(context).addGallery, + onTap: () { + startOcr(ImageSource.gallery); + }, ), ], - onChanged: (v) async { - if (v == null || v == -1) { - await Navigator.of(context).push( - platformRoute( - (c) => const SetupView( - newWallet: true, + ), + 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(selectedWallet!), + items: [ + ...wallets.map( + (e) => DropdownMenuItem( + value: wallets.indexOf( + e, + ), + child: Text(e.name), ), ), - ); - 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(), + 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, + ), ), - ], - ) - : (selectedWallet!.entries.isEmpty) - ? Column( - children: [ - Text( - AppLocalizations.of(context).noEntries, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - Text( - AppLocalizations.of(context).noEntriesSub, - ), - ], + ); + wallets = await WalletManager.listWallets(); + selectedWallet = wallets.last; + setState(() {}); + return; + } + selectedWallet = wallets[v]; + setState(() {}); + }, + ), + ), + actions: [ + if (!_searchActive) + IconButton( + onPressed: () { + _searchActive = true; + setState(() {}); + }, + icon: const Icon(Icons.search), + ), + if (!_searchActive) + 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(), + ), ) - : Column( - children: [ - Text( - NumberFormat.compactCurrency( - locale: locale, - symbol: selectedWallet!.currency.symbol, - ).format(selectedWallet!.calculateCurrentBalance()), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22, + .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, + ), ), - ), - const SizedBox( - height: 5, - ), - if (selectedWallet!.calculateMonthStatus( - DateTime.now().month, - DateTime.now().year, - ) == - 0) - Text(AppLocalizations.of(context).evenMoney) - else - RichText( - text: TextSpan( + Text( + AppLocalizations.of(context).noEntriesSub, + ), + ], + ) + : Overlay( + initialEntries: [ + OverlayEntry( + builder: (context) => Column( children: [ - TextSpan( - text: AppLocalizations.of(context) - .balanceStatusA, - ), - 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( + Text( + NumberFormat.compactCurrency( locale: locale, symbol: selectedWallet!.currency.symbol, ).format( - selectedWallet!.calculateMonthStatus( - DateTime.now().month, - DateTime.now().year, - ), + selectedWallet!.calculateCurrentBalance(), + ), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 22, ), ), - TextSpan( - text: AppLocalizations.of(context) - .balanceStatusB, + 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, + ), + 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, + ), + ], + ), + ), + 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.contains(_filter)) + .toList(), + 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(), + ), + ), + ], + ), + ), + ), + ), + ), ), ], ), ), - 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, - 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(), - ), - ), - ], - ), - ), + OverlayEntry( + builder: (context) => SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: GestureDetector( + onTap: () { + if (!_searchActive) return; + if (!searchFocus.hasFocus) { + _searchActive = false; + _filter = ""; + setState(() {}); + return; + } + searchFocus.unfocus(); + }, ), ), ), - ), - ], - ), + ], + ), + ), ), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index cd1ca24..8f456f7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: prasule description: Open-source private expense tracker -version: 1.0.0-alpha+4 +version: 1.0.0-alpha+5 environment: sdk: '>=3.1.0-262.2.beta <4.0.0'