diff --git a/moneymgr_mobile/lib/routes/scan_details/scan_details.dart b/moneymgr_mobile/lib/routes/scan_details/scan_details.dart new file mode 100644 index 0000000..5e6b53f --- /dev/null +++ b/moneymgr_mobile/lib/routes/scan_details/scan_details.dart @@ -0,0 +1,69 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:moneymgr_mobile/services/storage/expenses.dart'; +import 'package:moneymgr_mobile/utils/extensions.dart'; +import 'package:moneymgr_mobile/widgets/expense_editor.dart'; +import 'package:moneymgr_mobile/widgets/full_screen_error.dart'; +import 'package:moneymgr_mobile/widgets/loading_scaffold.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'scan_details.g.dart'; + +@riverpod +Future<(Expense, Uint8List)?> _getExpense(Ref ref, {required int id}) async { + final expProvider = ref.watch(expensesProvider).requireValue; + final expense = await expProvider.getById(id); + if (expense == null) return null; + final file = await expProvider.loadFile(expense); + + return (expense, file); +} + +class ScanDetailScreen extends HookConsumerWidget { + final int id; + + const ScanDetailScreen({super.key, required this.id}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final expenses = ref.watch(expensesProvider).requireValue; + final expense = ref.watch(_getExpenseProvider(id: id)); + + handleUpdate(BaseExpenseInfo newInfo) async { + try { + await expenses.updateExpense(expense.requireValue!.$1, newInfo); + if (context.mounted) { + context.pop(); + } + + ref.invalidate(expensesProvider); + } catch (e, s) { + Logger.root.warning("Failed to update expense! $e$s"); + if (context.mounted) { + context.showTextSnackBar("Failed to update expense! $e"); + } + } + } + + return switch (expense) { + AsyncData(:final value) when value == null => FullScreenError( + message: "Expense does not exists!", + error: 'NONE', + ), + AsyncData(:final value) => ExpenseEditor( + file: value!.$2, + onFinished: handleUpdate, + initialData: value.$1.baseExpense, + ), + AsyncError(:final error) => FullScreenError( + message: "Failed to load expense information!", + error: error.toString(), + ), + _ => LoadingScaffold(title: "Expense $id"), + }; + } +} diff --git a/moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart b/moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart index 7fddb75..078d9ab 100644 --- a/moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart +++ b/moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:moneymgr_mobile/services/router/routes_list.dart'; import 'package:moneymgr_mobile/services/storage/expenses.dart'; import 'package:moneymgr_mobile/utils/time_utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -37,9 +39,10 @@ class _ExpensesList extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.builder( - itemBuilder: (context, id) { - final expense = list[id]; + itemBuilder: (context, entryNum) { + final expense = list[entryNum]; return ListTile( + onTap: () => context.push("$scansPage/${expense.id}"), leading: Icon(Icons.receipt_long), title: Text( expense.label ?? "No label", diff --git a/moneymgr_mobile/lib/services/router/router.dart b/moneymgr_mobile/lib/services/router/router.dart index 627ab3d..5c67a62 100644 --- a/moneymgr_mobile/lib/services/router/router.dart +++ b/moneymgr_mobile/lib/services/router/router.dart @@ -7,6 +7,7 @@ import 'package:moneymgr_mobile/routes/login/manual_auth_screen.dart'; import 'package:moneymgr_mobile/routes/login/qr_auth_screen.dart'; import 'package:moneymgr_mobile/routes/profile/profile_screen.dart'; import 'package:moneymgr_mobile/routes/scan/scan_screen.dart'; +import 'package:moneymgr_mobile/routes/scan_details/scan_details.dart'; import 'package:moneymgr_mobile/routes/scans_list/scans_list_screen.dart'; import 'package:moneymgr_mobile/routes/settings/settings_screen.dart'; import 'package:moneymgr_mobile/services/router/routes_list.dart'; @@ -36,7 +37,7 @@ GoRouter router(Ref ref) { // see [AuthState] enum. final navigationItems = [ NavigationItem( - path: scanPage, + path: capturePage, body: (_) => ScanScreen(), icon: Icons.camera_alt_outlined, selectedIcon: Icons.camera_alt, @@ -48,6 +49,15 @@ GoRouter router(Ref ref) { icon: Icons.list, selectedIcon: Icons.list_alt, label: "List", + routes: [ + GoRoute( + path: ":id", + builder: (_, state) { + final id = int.parse(state.pathParameters["id"]!); + return ScanDetailScreen(id: id); + }, + ), + ], ), NavigationItem( path: profilePage, diff --git a/moneymgr_mobile/lib/services/router/routes_list.dart b/moneymgr_mobile/lib/services/router/routes_list.dart index 7017044..7b2d0de 100644 --- a/moneymgr_mobile/lib/services/router/routes_list.dart +++ b/moneymgr_mobile/lib/services/router/routes_list.dart @@ -14,7 +14,7 @@ const manualAuthPage = "/login/manual"; const settingsPage = "/settings"; /// Scan path -const scanPage = "/scan"; +const capturePage = "/scan"; /// Scans page const scansPage = "/scans"; diff --git a/moneymgr_mobile/lib/services/storage/expenses.dart b/moneymgr_mobile/lib/services/storage/expenses.dart index 468082a..986d86a 100644 --- a/moneymgr_mobile/lib/services/storage/expenses.dart +++ b/moneymgr_mobile/lib/services/storage/expenses.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'dart:typed_data'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -8,17 +9,24 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -part 'expenses.freezed.dart';part 'expenses.g.dart'; +part 'expenses.freezed.dart'; + +part 'expenses.g.dart'; typedef ExpensesList = List; @freezed abstract class BaseExpenseInfo with _$BaseExpenseInfo { + const BaseExpenseInfo._(); + const factory BaseExpenseInfo({ required String? label, required double cost, required DateTime time, }) = _BaseExpenseInfo; + + /// Get expense time as second + int get timeAsSeconds => (time.millisecondsSinceEpoch / 1000).floor(); } @freezed @@ -45,6 +53,10 @@ abstract class Expense with _$Expense { factory Expense.fromJson(Map json) => _$ExpenseFromJson(json); + /// Get base expense information + BaseExpenseInfo get baseExpense => + BaseExpenseInfo(label: label, cost: cost, time: dateTime); + /// Get associated expense file name String get localFileName { if (mimeType == "application/pdf") return "$id.pdf"; @@ -109,7 +121,7 @@ class ExpensesManager { id: (list.lastOrNull?.id ?? 0) + Random().nextInt(1000), label: info.label, cost: info.cost, - time: (info.time.millisecondsSinceEpoch / 1000).floor(), + time: info.timeAsSeconds, mimeType: fileMimeType, ); @@ -128,4 +140,28 @@ class ExpensesManager { list.add(exp); await saveList(list); } -} + + /// Get a single expense information by its ID + Future getById(int id) async { + final list = await getList(); + return list.firstWhere((e) => e.id == id); + } + + /// Load the file associated with an expense + Future loadFile(Expense expense) async { + final path = p.join(filesStoragePath.absolute.path, expense.localFileName); + return File(path).readAsBytes(); + } + + /// Update expense information + Future updateExpense(Expense expense, BaseExpenseInfo newInfo) async { + final list = await getList(); + final entry = list.indexWhere((e) => e.id == expense.id); + list[entry] = Expense(id: expense.id, + label: newInfo.label, + cost: newInfo.cost, + time: newInfo.timeAsSeconds, + mimeType: expense.mimeType); + saveList(list); + } +} \ No newline at end of file diff --git a/moneymgr_mobile/lib/widgets/expense_editor.dart b/moneymgr_mobile/lib/widgets/expense_editor.dart index 82d5e4f..171babe 100644 --- a/moneymgr_mobile/lib/widgets/expense_editor.dart +++ b/moneymgr_mobile/lib/widgets/expense_editor.dart @@ -16,21 +16,27 @@ class ExpenseEditor extends HookConsumerWidget { final Uint8List file; final Future Function(BaseExpenseInfo) onFinished; final Function()? onRescan; + final Function()? onDelete; + final BaseExpenseInfo? initialData; const ExpenseEditor({ super.key, required this.file, required this.onFinished, - required this.onRescan, + this.onRescan, + this.onDelete, + this.initialData, }); @override Widget build(BuildContext context, WidgetRef ref) { final serverConfig = ref.watch(prefsProvider).requireValue.serverConfig()!; - final labelController = useTextEditingController(); - final costController = useTextEditingController(); - final timeController = useState(DateTime.now()); + final labelController = useTextEditingController(text: initialData?.label); + final costController = useTextEditingController( + text: initialData?.cost.toString(), + ); + final timeController = useState(initialData?.time ?? DateTime.now()); final (:pending, :snapshot, :hasError) = useAsyncTask(); @@ -72,15 +78,33 @@ class ExpenseEditor extends HookConsumerWidget { } } + // Delete expense + handleDelete() async { + if (await confirm( + context, + content: Text("Do you really want to delete this expense?"), + ) && + onDelete != null) { + onDelete!(); + } + } + return Scaffold( appBar: AppBar( title: Text("Expense info"), actions: [ // Rescan expense - IconButton( - onPressed: onRescan == null ? null : handleRescan, - icon: Icon(Icons.restart_alt), - ), + onRescan == null + ? Container() + : IconButton( + onPressed: handleRescan, + icon: Icon(Icons.restart_alt), + ), + + // Delete expense + onDelete == null + ? Container() + : IconButton(onPressed: handleDelete, icon: Icon(Icons.delete)), // Submit snapshot.connectionState == ConnectionState.waiting diff --git a/moneymgr_mobile/lib/widgets/loading_scaffold.dart b/moneymgr_mobile/lib/widgets/loading_scaffold.dart new file mode 100644 index 0000000..db8d26b --- /dev/null +++ b/moneymgr_mobile/lib/widgets/loading_scaffold.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class LoadingScaffold extends StatelessWidget { + final String title; + + const LoadingScaffold({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(title)), + body: Center(child: CircularProgressIndicator()), + ); + } +}