Add expense editor
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 10:06:07 +02:00
parent 70023242e9
commit 768706e2d4
3 changed files with 155 additions and 94 deletions

View File

@ -1,15 +1,10 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:alert_dialog/alert_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_pdfview/flutter_pdfview.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/services/storage/prefs.dart';
import 'package:moneymgr_mobile/utils/time_utils.dart';
import 'package:moneymgr_mobile/widgets/expense_editor.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:scanbot_sdk/scanbot_sdk.dart';
import 'package:scanbot_sdk/scanbot_sdk_ui_v2.dart' hide IconButton, EdgeInsets;
@ -68,6 +63,7 @@ class ScanScreen extends HookConsumerWidget {
AsyncData(:final value) when value != null => ExpenseEditor(
file: value,
onFinished: (e) {},
onRescan: restartScan,
),
// No data
@ -119,91 +115,3 @@ class ScanErrorScreen extends StatelessWidget {
);
}
}
class ExpenseEditor extends HookConsumerWidget {
final Uint8List file;
final Function(Expense) onFinished;
const ExpenseEditor({
super.key,
required this.file,
required this.onFinished,
});
@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());
// Pick a new date
handlePickDate() async {
final date = await showDatePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime(2099),
initialDate: timeController.value,
);
if (date != null) timeController.value = date;
}
return ListView(
children: [
// Expense preview
SizedBox(
height: 200,
child: PDFView(
pdfData: file,
onError: (e) {
Logger.root.warning("Failed to render PDF $e");
alert(context, content: Text("Failed to render PDF $e"));
},
fitPolicy: FitPolicy.BOTH,
),
),
SizedBox(height: 10),
// Cost
TextField(
controller: costController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Cost'),
textInputAction: TextInputAction.done,
),
SizedBox(height: 10),
// Label
TextField(
controller: labelController,
keyboardType: TextInputType.text,
decoration: const InputDecoration(labelText: 'Label'),
textInputAction: TextInputAction.done,
maxLength: serverConfig.constraints.inbox_entry_label.max,
),
SizedBox(height: 10),
// Date
TextField(
enabled: true,
readOnly: true,
controller: TextEditingController(
text: timeController.value.simpleDate,
),
keyboardType: TextInputType.datetime,
decoration: InputDecoration(
labelText: 'Date',
suffixIcon: IconButton(
onPressed: handlePickDate,
icon: const Icon(Icons.date_range),
),
),
),
],
);
}
}

View File

@ -13,6 +13,15 @@ part 'expenses.g.dart';
typedef ExpensesList = List<Expense>;
@freezed
abstract class BaseExpenseInfo with _$BaseExpenseInfo {
const factory BaseExpenseInfo({
required String label,
required int cost,
required DateTime time,
}) = _BaseExpenseInfo;
}
@freezed
abstract class Expense with _$Expense {
const Expense._();

View File

@ -0,0 +1,144 @@
import 'dart:typed_data';
import 'package:alert_dialog/alert_dialog.dart';
import 'package:confirm_dialog/confirm_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_pdfview/flutter_pdfview.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/services/storage/prefs.dart';
import 'package:moneymgr_mobile/utils/extensions.dart';
import 'package:moneymgr_mobile/utils/time_utils.dart';
class ExpenseEditor extends HookConsumerWidget {
final Uint8List file;
final Function(BaseExpenseInfo) onFinished;
final Function()? onRescan;
const ExpenseEditor({
super.key,
required this.file,
required this.onFinished,
required this.onRescan,
});
@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());
// Pick a new date
handlePickDate() async {
final date = await showDatePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime(2099),
initialDate: timeController.value,
);
if (date != null) timeController.value = date;
}
// Save expense
handleSubmit() async {
if (costController.text.isEmpty) {
context.showTextSnackBar("Please specify expense cost!");
return;
}
onFinished(
BaseExpenseInfo(
label: labelController.text,
cost: int.tryParse(costController.text) ?? 0,
time: timeController.value,
),
);
}
// Cancel operation
handleRescan() async {
if (await confirm(
context,
content: Text("Do you really want to discard this expense?"),
) &&
onRescan != null) {
onRescan!();
}
}
return Scaffold(
appBar: AppBar(
title: Text("Expense info"),
actions: [
// Rescan expense
IconButton(
onPressed: onRescan == null ? null : handleRescan,
icon: Icon(Icons.restart_alt),
),
// Submit
IconButton(onPressed: handleSubmit, icon: Icon(Icons.save)),
],
),
body: Column(
children: [
// Expense preview
Expanded(
child: PDFView(
pdfData: file,
onError: (e) {
Logger.root.warning("Failed to render PDF $e");
alert(context, content: Text("Failed to render PDF $e"));
},
fitPolicy: FitPolicy.BOTH,
),
),
SizedBox(height: 10),
// Cost
TextField(
controller: costController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Cost'),
textInputAction: TextInputAction.done,
),
SizedBox(height: 10),
// Date
TextField(
enabled: true,
readOnly: true,
controller: TextEditingController(
text: timeController.value.simpleDate,
),
keyboardType: TextInputType.datetime,
decoration: InputDecoration(
labelText: 'Date',
suffixIcon: IconButton(
onPressed: handlePickDate,
icon: const Icon(Icons.date_range),
),
),
),
SizedBox(height: 10),
// Label
TextField(
controller: labelController,
keyboardType: TextInputType.text,
decoration: const InputDecoration(labelText: 'Label'),
textInputAction: TextInputAction.done,
maxLength: serverConfig.constraints.inbox_entry_label.max,
),
],
),
);
}
}