From 50812af2fc00eb78f914cfdcfc0a5d206dd3959e Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Mon, 14 Jul 2025 16:49:32 +0200 Subject: [PATCH] Display the list of expenses --- .../routes/scans_list/scans_list_screen.dart | 57 ++++++++++++++++ .../lib/services/router/router.dart | 8 +++ .../lib/services/router/routes_list.dart | 5 +- .../lib/services/storage/expenses.dart | 8 ++- moneymgr_mobile/lib/utils/pdf_utils.dart | 65 +++++++++++++++++++ .../lib/widgets/expense_editor.dart | 2 +- moneymgr_mobile/lib/widgets/pdf_viewer.dart | 57 +--------------- 7 files changed, 145 insertions(+), 57 deletions(-) create mode 100644 moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart create mode 100644 moneymgr_mobile/lib/utils/pdf_utils.dart diff --git a/moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart b/moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart new file mode 100644 index 0000000..eaa4dd7 --- /dev/null +++ b/moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:moneymgr_mobile/services/storage/expenses.dart'; +import 'package:moneymgr_mobile/utils/time_utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'scans_list_screen.g.dart'; + +@riverpod +Future _expensesList(Ref ref) { + final expenses = ref.watch(expensesProvider).requireValue; + return expenses.getList(); +} + +class ScansListScreen extends HookConsumerWidget { + const ScansListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final expensesList = ref.watch(_expensesListProvider); + return Scaffold( + appBar: AppBar(title: Text("Expenses")), + body: switch (expensesList) { + AsyncData(:final value) => _ExpensesList(list: value), + AsyncError(:final error) => Center(child: Text('Load error: $error')), + _ => const Center(child: CircularProgressIndicator()), + }, + ); + } +} + +class _ExpensesList extends StatelessWidget { + final ExpensesList list; + + const _ExpensesList({required this.list}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemBuilder: (context, id) { + final expense = list[id]; + return ListTile( + leading: Icon(Icons.receipt_long), + title: Text( + expense.labelOrNull ?? "No label", + style: TextStyle( + fontStyle: expense.labelOrNull == null ? FontStyle.italic : null, + ), + ), + subtitle: Text(expense.dateTime.simpleDate), + trailing: Text("${expense.cost} €", style: TextStyle(fontSize: 20)), + ); + }, + itemCount: list.length, + ); + } +} diff --git a/moneymgr_mobile/lib/services/router/router.dart b/moneymgr_mobile/lib/services/router/router.dart index 3572fc0..627ab3d 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/scans_list/scans_list_screen.dart'; import 'package:moneymgr_mobile/routes/settings/settings_screen.dart'; import 'package:moneymgr_mobile/services/router/routes_list.dart'; import 'package:moneymgr_mobile/widgets/load_startup_data.dart'; @@ -41,6 +42,13 @@ GoRouter router(Ref ref) { selectedIcon: Icons.camera_alt, label: "Scan", ), + NavigationItem( + path: scansPage, + body: (_) => ScansListScreen(), + icon: Icons.list, + selectedIcon: Icons.list_alt, + label: "List", + ), NavigationItem( path: profilePage, body: (_) => ProfileScreen(), diff --git a/moneymgr_mobile/lib/services/router/routes_list.dart b/moneymgr_mobile/lib/services/router/routes_list.dart index 1feef5d..7017044 100644 --- a/moneymgr_mobile/lib/services/router/routes_list.dart +++ b/moneymgr_mobile/lib/services/router/routes_list.dart @@ -13,8 +13,11 @@ const manualAuthPage = "/login/manual"; /// Settings path const settingsPage = "/settings"; -/// Scan URL path +/// Scan path const scanPage = "/scan"; +/// Scans page +const scansPage = "/scans"; + /// Profile path const profilePage = "/profile"; \ No newline at end of file diff --git a/moneymgr_mobile/lib/services/storage/expenses.dart b/moneymgr_mobile/lib/services/storage/expenses.dart index 7b7a1a5..18dfe32 100644 --- a/moneymgr_mobile/lib/services/storage/expenses.dart +++ b/moneymgr_mobile/lib/services/storage/expenses.dart @@ -16,7 +16,7 @@ typedef ExpensesList = List; @freezed abstract class BaseExpenseInfo with _$BaseExpenseInfo { const factory BaseExpenseInfo({ - required String label, + required String? label, required double cost, required DateTime time, }) = _BaseExpenseInfo; @@ -53,6 +53,12 @@ abstract class Expense with _$Expense { if (mimeType == "image/png") return "$id.png"; return id.toString(); } + + /// Get expense label or null if empty + String? get labelOrNull => label == "" ? null : label; + + /// Get expense date + DateTime get dateTime => DateTime.fromMillisecondsSinceEpoch(time * 1000); } @riverpod diff --git a/moneymgr_mobile/lib/utils/pdf_utils.dart b/moneymgr_mobile/lib/utils/pdf_utils.dart new file mode 100644 index 0000000..55da0aa --- /dev/null +++ b/moneymgr_mobile/lib/utils/pdf_utils.dart @@ -0,0 +1,65 @@ +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:pdf_image_renderer/pdf_image_renderer.dart'; + + +/// Render PDF to image bits +Future renderPdf( + { + String? path, + Uint8List? pdfBytes, + }) async { + assert(path != null || pdfBytes != null); + + // Create temporary file if required + var isTemp = false; + if (path == null) { + path = p.join( + (await getTemporaryDirectory()).absolute.path, + "render-${Random().nextInt(10000).toString()}+.pdf", + ); + + await File(path).writeAsBytes(pdfBytes!); + isTemp = true; + } + + try { + final pdf = PdfImageRenderer(path: path); + await pdf.open(); + await pdf.openPage(pageIndex: 0); + + // get the render size after the page is loaded + final size = await pdf.getPageSize(pageIndex: 0); + + // get the actual image of the page + final img = await pdf.renderPage( + pageIndex: 0, + x: 0, + y: 0, + width: size.width, + // you can pass a custom size here to crop the image + height: size.height, + // you can pass a custom size here to crop the image + scale: 1, + // increase the scale for better quality (e.g. for zooming) + background: Colors.white, + ); + + // close the page again + await pdf.closePage(pageIndex: 0); + + // close the PDF after rendering the page + pdf.close(); + + return img!; + } finally { + if (isTemp) { + await File(path).delete(); + } + } +} \ 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 543d65e..82d5e4f 100644 --- a/moneymgr_mobile/lib/widgets/expense_editor.dart +++ b/moneymgr_mobile/lib/widgets/expense_editor.dart @@ -54,7 +54,7 @@ class ExpenseEditor extends HookConsumerWidget { pending.value = onFinished( BaseExpenseInfo( - label: labelController.text, + label: labelController.text.isEmpty ? null : labelController.text, cost: double.tryParse(costController.text) ?? 0, time: timeController.value, ), diff --git a/moneymgr_mobile/lib/widgets/pdf_viewer.dart b/moneymgr_mobile/lib/widgets/pdf_viewer.dart index bcc458c..910fd79 100644 --- a/moneymgr_mobile/lib/widgets/pdf_viewer.dart +++ b/moneymgr_mobile/lib/widgets/pdf_viewer.dart @@ -1,12 +1,8 @@ -import 'dart:io'; -import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:pdf_image_renderer/pdf_image_renderer.dart'; +import 'package:moneymgr_mobile/utils/pdf_utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'pdf_viewer.g.dart'; @@ -17,54 +13,7 @@ Future _renderPdf( String? path, Uint8List? pdfBytes, }) async { - assert(path != null || pdfBytes != null); - - // Create temporary file if required - var isTemp = false; - if (path == null) { - path = p.join( - (await getTemporaryDirectory()).absolute.path, - "render-${Random().nextInt(10000).toString()}+.pdf", - ); - - await File(path).writeAsBytes(pdfBytes!); - isTemp = true; - } - - try { - final pdf = PdfImageRenderer(path: path); - await pdf.open(); - await pdf.openPage(pageIndex: 0); - - // get the render size after the page is loaded - final size = await pdf.getPageSize(pageIndex: 0); - - // get the actual image of the page - final img = await pdf.renderPage( - pageIndex: 0, - x: 0, - y: 0, - width: size.width, - // you can pass a custom size here to crop the image - height: size.height, - // you can pass a custom size here to crop the image - scale: 1, - // increase the scale for better quality (e.g. for zooming) - background: Colors.white, - ); - - // close the page again - await pdf.closePage(pageIndex: 0); - - // close the PDF after rendering the page - pdf.close(); - - return img!; - } finally { - if (isTemp) { - await File(path).delete(); - } - } + return renderPdf(path: path, pdfBytes: pdfBytes); } class PDFViewer extends ConsumerWidget { @@ -98,7 +47,7 @@ class PDFViewer extends ConsumerWidget { fit: fit, ), AsyncError(:final error) => Text('PDF error: $error'), - _ => const CircularProgressIndicator(), + _ => Center(child: const CircularProgressIndicator()), }; } }