Can save expenses to local list
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing

This commit is contained in:
2025-07-14 16:24:59 +02:00
parent dd035f8a15
commit 547e9b7aad
3 changed files with 44 additions and 19 deletions

View File

@ -4,6 +4,7 @@ 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:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moneymgr_mobile/services/storage/expenses.dart';
import 'package:moneymgr_mobile/widgets/expense_editor.dart'; import 'package:moneymgr_mobile/widgets/expense_editor.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:scanbot_sdk/scanbot_sdk.dart'; import 'package:scanbot_sdk/scanbot_sdk.dart';
@ -11,6 +12,7 @@ import 'package:scanbot_sdk/scanbot_sdk_ui_v2.dart' hide IconButton, EdgeInsets;
part 'scan_screen.g.dart'; part 'scan_screen.g.dart';
/// Scan a document & return generated PDF as byte file
@riverpod @riverpod
Future<Uint8List?> _scanDocument(Ref ref) async { Future<Uint8List?> _scanDocument(Ref ref) async {
var configuration = DocumentScanningFlow( var configuration = DocumentScanningFlow(
@ -45,7 +47,8 @@ class ScanScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final boredSuggestion = ref.watch(_scanDocumentProvider); final scanDocProvider = ref.watch(_scanDocumentProvider);
final expenses = ref.watch(expensesProvider).requireValue;
restartScan() async { restartScan() async {
try { try {
@ -56,13 +59,19 @@ class ScanScreen extends HookConsumerWidget {
} }
} }
// Perform a switch-case on the result to handle loading/error states
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: switch (boredSuggestion) { child: switch (scanDocProvider) {
AsyncData(:final value) when value != null => ExpenseEditor( AsyncData(:final value) when value != null => ExpenseEditor(
file: value, file: value,
onFinished: (e) {}, onFinished: (expense) async {
await expenses.add(
info: expense,
fileContent: value,
fileMimeType: "application/pdf",
);
restartScan();
},
onRescan: restartScan, onRescan: restartScan,
), ),
@ -103,7 +112,7 @@ class ScanErrorScreen extends StatelessWidget {
Spacer(flex: 5), Spacer(flex: 5),
Text("An error occurred while scanning"), Text("An error occurred while scanning"),
Spacer(flex: 1), Spacer(flex: 1),
Text(message), Text(message, textAlign: TextAlign.center),
Spacer(flex: 1), Spacer(flex: 1),
MaterialButton( MaterialButton(
onPressed: onTryAgain, onPressed: onTryAgain,

View File

@ -17,7 +17,7 @@ typedef ExpensesList = List<Expense>;
abstract class BaseExpenseInfo with _$BaseExpenseInfo { abstract class BaseExpenseInfo with _$BaseExpenseInfo {
const factory BaseExpenseInfo({ const factory BaseExpenseInfo({
required String label, required String label,
required int cost, required double cost,
required DateTime time, required DateTime time,
}) = _BaseExpenseInfo; }) = _BaseExpenseInfo;
} }
@ -36,7 +36,7 @@ abstract class Expense with _$Expense {
/// The cost shall always be a positive value /// The cost shall always be a positive value
required double cost, required double cost,
/// Time associated with the expense /// Time associated with the expense (seconds since epoch)
required int time, required int time,
/// Associated file mime type /// Associated file mime type
@ -80,21 +80,24 @@ class ExpensesManager {
/// Get the current list of expenses /// Get the current list of expenses
Future<ExpensesList> getList() async { Future<ExpensesList> getList() async {
// On first save the list does not exists.
if (!await expenseFile.exists()) {
return [];
}
final jsonDec = jsonDecode(await expenseFile.readAsString()); final jsonDec = jsonDecode(await expenseFile.readAsString());
return List<Expense>.from(jsonDec.map((m) => Expense.fromJson(m))); return List<Expense>.from(jsonDec.map((m) => Expense.fromJson(m)));
} }
/// Save the list of expenses /// Save the list of expenses
Future<void> saveList(ExpensesList list) async { Future<void> saveList(ExpensesList list) async {
final jsonDoc = jsonEncode(list.map((t) => t.toJson())); final jsonDoc = jsonEncode(list.map((t) => t.toJson()).toList());
await expenseFile.writeAsString(jsonDoc); await expenseFile.writeAsString(jsonDoc);
} }
/// Add a new expense to the list /// Add a new expense to the list
Future<void> add({ Future<void> add({
required String? label, required BaseExpenseInfo info,
required double cost,
required int time,
required List<int> fileContent, required List<int> fileContent,
required String fileMimeType, required String fileMimeType,
}) async { }) async {
@ -102,9 +105,9 @@ class ExpensesManager {
final exp = Expense( final exp = Expense(
id: (list.lastOrNull?.id ?? 0) + Random().nextInt(1000), id: (list.lastOrNull?.id ?? 0) + Random().nextInt(1000),
label: label, label: info.label,
cost: cost, cost: info.cost,
time: time, time: (info.time.millisecondsSinceEpoch / 1000).floor(),
mimeType: fileMimeType, mimeType: fileMimeType,
); );

View File

@ -10,9 +10,11 @@ import 'package:moneymgr_mobile/utils/extensions.dart';
import 'package:moneymgr_mobile/utils/time_utils.dart'; import 'package:moneymgr_mobile/utils/time_utils.dart';
import 'package:moneymgr_mobile/widgets/pdf_viewer.dart'; import 'package:moneymgr_mobile/widgets/pdf_viewer.dart';
import '../utils/hooks.dart';
class ExpenseEditor extends HookConsumerWidget { class ExpenseEditor extends HookConsumerWidget {
final Uint8List file; final Uint8List file;
final Function(BaseExpenseInfo) onFinished; final Future<void> Function(BaseExpenseInfo) onFinished;
final Function()? onRescan; final Function()? onRescan;
const ExpenseEditor({ const ExpenseEditor({
@ -30,6 +32,8 @@ class ExpenseEditor extends HookConsumerWidget {
final costController = useTextEditingController(); final costController = useTextEditingController();
final timeController = useState(DateTime.now()); final timeController = useState(DateTime.now());
final (:pending, :snapshot, :hasError) = useAsyncTask();
// Pick a new date // Pick a new date
handlePickDate() async { handlePickDate() async {
final date = await showDatePicker( final date = await showDatePicker(
@ -48,10 +52,10 @@ class ExpenseEditor extends HookConsumerWidget {
return; return;
} }
onFinished( pending.value = onFinished(
BaseExpenseInfo( BaseExpenseInfo(
label: labelController.text, label: labelController.text,
cost: int.tryParse(costController.text) ?? 0, cost: double.tryParse(costController.text) ?? 0,
time: timeController.value, time: timeController.value,
), ),
); );
@ -79,7 +83,13 @@ class ExpenseEditor extends HookConsumerWidget {
), ),
// Submit // Submit
IconButton(onPressed: handleSubmit, icon: Icon(Icons.save)), snapshot.connectionState == ConnectionState.waiting
? CircularProgressIndicator()
: IconButton(
onPressed: handleSubmit,
icon: Icon(Icons.save),
color: hasError ? Colors.red : null,
),
], ],
), ),
body: Column( body: Column(
@ -94,7 +104,10 @@ class ExpenseEditor extends HookConsumerWidget {
// Cost // Cost
TextField( TextField(
controller: costController, controller: costController,
keyboardType: TextInputType.number, keyboardType: TextInputType.numberWithOptions(
decimal: true,
signed: false,
),
decoration: const InputDecoration(labelText: 'Cost'), decoration: const InputDecoration(labelText: 'Cost'),
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
), ),