3 Commits

Author SHA1 Message Date
768706e2d4 Add expense editor
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-07-14 10:06:07 +02:00
70023242e9 Start to build expense editor screen 2025-07-14 09:42:42 +02:00
951338b6e4 Display generated PDF on expense screen 2025-07-14 09:15:18 +02:00
6 changed files with 215 additions and 42 deletions

View File

@@ -1,16 +1,18 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:moneymgr_mobile/widgets/expense_editor.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:scanbot_sdk/scanbot_sdk_ui_v2.dart';
import '../../services/storage/expenses.dart';
import 'package:scanbot_sdk/scanbot_sdk.dart';
import 'package:scanbot_sdk/scanbot_sdk_ui_v2.dart' hide IconButton, EdgeInsets;
part 'scan_screen.g.dart';
@riverpod
Future<String?> _scanDocument(Ref ref) async {
Future<Uint8List?> _scanDocument(Ref ref) async {
var configuration = DocumentScanningFlow(
appearance: DocumentFlowAppearanceConfiguration(
statusBarMode: StatusBarMode.DARK,
@@ -23,10 +25,24 @@ Future<String?> _scanDocument(Ref ref) async {
);
var documentResult = await ScanbotSdkUiV2.startDocumentScanner(configuration);
return documentResult.data?.pdfURI;
if (documentResult.status != OperationStatus.OK) {
throw Exception("Scanner failed with status ${documentResult.status}");
}
// Convert result to PDF
var result = await ScanbotSdk.document.createPDFForDocument(
PDFFromDocumentParams(
documentID: documentResult.data!.uuid,
pdfConfiguration: PdfConfiguration(),
),
);
final pdfPath = result.pdfFileUri.replaceFirst("file://", "");
return File(pdfPath).readAsBytes();
}
class ScanScreen extends HookConsumerWidget {
const ScanScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final boredSuggestion = ref.watch(_scanDocumentProvider);
@@ -41,25 +57,29 @@ class ScanScreen extends HookConsumerWidget {
}
// Perform a switch-case on the result to handle loading/error states
return switch (boredSuggestion) {
AsyncData(:final value) when value != null => ExpenseEditor(
filePath: value,
onFinished: (e) {},
),
return Padding(
padding: const EdgeInsets.all(8.0),
child: switch (boredSuggestion) {
AsyncData(:final value) when value != null => ExpenseEditor(
file: value,
onFinished: (e) {},
onRescan: restartScan,
),
// No data
AsyncData(:final value) when value == null => ScanErrorScreen(
message: "No document scanned!",
onTryAgain: restartScan,
),
// No data
AsyncData(:final value) when value == null => ScanErrorScreen(
message: "No document scanned!",
onTryAgain: restartScan,
),
// Error
AsyncError(:final error) => ScanErrorScreen(
message: error.toString(),
onTryAgain: restartScan,
),
_ => const Center(child: CircularProgressIndicator()),
};
// Error
AsyncError(:final error) => ScanErrorScreen(
message: error.toString(),
onTryAgain: restartScan,
),
_ => const Center(child: CircularProgressIndicator()),
},
);
}
}
@@ -95,20 +115,3 @@ class ScanErrorScreen extends StatelessWidget {
);
}
}
class ExpenseEditor extends HookWidget {
final String filePath;
final Function(Expense) onFinished;
const ExpenseEditor({
super.key,
required this.filePath,
required this.onFinished,
});
@override
Widget build(BuildContext context) {
// TODO: implement build
throw UnimplementedError();
}
}

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

@@ -1,3 +1,8 @@
int secondsSinceEpoch(DateTime time) {
return time.millisecondsSinceEpoch ~/ 1000;
}
}
extension SimpleDateFormatting on DateTime {
String get simpleDate =>
"${day.toString().padLeft(2, "0")}/${month.toString().padLeft(2, '0')}/$year";
}

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

View File

@@ -382,6 +382,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.6"
flutter_pdfview:
dependency: "direct main"
description:
name: flutter_pdfview
sha256: c402ad1f51ba8ea73b9fb04c003ca0a9286118ba5ac9787ee2aa58956b3fcf8a
url: "https://pub.dev"
source: hosted
version: "1.4.1+1"
flutter_riverpod:
dependency: transitive
description:
@@ -665,7 +673,7 @@ packages:
source: hosted
version: "2.2.0"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"

View File

@@ -87,6 +87,10 @@ dependencies:
# Get documents path
path_provider: ^2.1.5
path: ^1.9.1
# PDF viewer
flutter_pdfview: ^1.4.1+1
dev_dependencies:
flutter_test: