Display the list of expenses

This commit is contained in:
2025-07-14 16:49:32 +02:00
parent 547e9b7aad
commit 50812af2fc
7 changed files with 145 additions and 57 deletions

View File

@ -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> _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,
);
}
}

View File

@ -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/login/qr_auth_screen.dart';
import 'package:moneymgr_mobile/routes/profile/profile_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/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/routes/settings/settings_screen.dart';
import 'package:moneymgr_mobile/services/router/routes_list.dart'; import 'package:moneymgr_mobile/services/router/routes_list.dart';
import 'package:moneymgr_mobile/widgets/load_startup_data.dart'; import 'package:moneymgr_mobile/widgets/load_startup_data.dart';
@ -41,6 +42,13 @@ GoRouter router(Ref ref) {
selectedIcon: Icons.camera_alt, selectedIcon: Icons.camera_alt,
label: "Scan", label: "Scan",
), ),
NavigationItem(
path: scansPage,
body: (_) => ScansListScreen(),
icon: Icons.list,
selectedIcon: Icons.list_alt,
label: "List",
),
NavigationItem( NavigationItem(
path: profilePage, path: profilePage,
body: (_) => ProfileScreen(), body: (_) => ProfileScreen(),

View File

@ -13,8 +13,11 @@ const manualAuthPage = "/login/manual";
/// Settings path /// Settings path
const settingsPage = "/settings"; const settingsPage = "/settings";
/// Scan URL path /// Scan path
const scanPage = "/scan"; const scanPage = "/scan";
/// Scans page
const scansPage = "/scans";
/// Profile path /// Profile path
const profilePage = "/profile"; const profilePage = "/profile";

View File

@ -16,7 +16,7 @@ typedef ExpensesList = List<Expense>;
@freezed @freezed
abstract class BaseExpenseInfo with _$BaseExpenseInfo { abstract class BaseExpenseInfo with _$BaseExpenseInfo {
const factory BaseExpenseInfo({ const factory BaseExpenseInfo({
required String label, required String? label,
required double cost, required double cost,
required DateTime time, required DateTime time,
}) = _BaseExpenseInfo; }) = _BaseExpenseInfo;
@ -53,6 +53,12 @@ abstract class Expense with _$Expense {
if (mimeType == "image/png") return "$id.png"; if (mimeType == "image/png") return "$id.png";
return id.toString(); 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 @riverpod

View File

@ -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<Uint8List> 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();
}
}
}

View File

@ -54,7 +54,7 @@ class ExpenseEditor extends HookConsumerWidget {
pending.value = onFinished( pending.value = onFinished(
BaseExpenseInfo( BaseExpenseInfo(
label: labelController.text, label: labelController.text.isEmpty ? null : labelController.text,
cost: double.tryParse(costController.text) ?? 0, cost: double.tryParse(costController.text) ?? 0,
time: timeController.value, time: timeController.value,
), ),

View File

@ -1,12 +1,8 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:moneymgr_mobile/utils/pdf_utils.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pdf_image_renderer/pdf_image_renderer.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'pdf_viewer.g.dart'; part 'pdf_viewer.g.dart';
@ -17,54 +13,7 @@ Future<Uint8List> _renderPdf(
String? path, String? path,
Uint8List? pdfBytes, Uint8List? pdfBytes,
}) async { }) async {
assert(path != null || pdfBytes != null); return renderPdf(path: path, pdfBytes: pdfBytes);
// 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();
}
}
} }
class PDFViewer extends ConsumerWidget { class PDFViewer extends ConsumerWidget {
@ -98,7 +47,7 @@ class PDFViewer extends ConsumerWidget {
fit: fit, fit: fit,
), ),
AsyncError(:final error) => Text('PDF error: $error'), AsyncError(:final error) => Text('PDF error: $error'),
_ => const CircularProgressIndicator(), _ => Center(child: const CircularProgressIndicator()),
}; };
} }
} }