2 Commits

Author SHA1 Message Date
6531d73c93 Ready to implement expense editor screen
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-07-14 08:18:59 +02:00
51ba649b6e Can save the list of expenses 2025-07-14 08:00:35 +02:00
4 changed files with 203 additions and 8 deletions

View File

@@ -1,12 +1,16 @@
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:riverpod_annotation/riverpod_annotation.dart';
import 'package:scanbot_sdk/scanbot_sdk_ui_v2.dart';
import '../../services/storage/expenses.dart';
part 'scan_screen.g.dart';
@riverpod
Future<String> _scanDocument(Ref ref) async {
Future<String?> _scanDocument(Ref ref) async {
var configuration = DocumentScanningFlow(
appearance: DocumentFlowAppearanceConfiguration(
statusBarMode: StatusBarMode.DARK,
@@ -18,22 +22,93 @@ Future<String> _scanDocument(Ref ref) async {
),
);
var documentResult = await ScanbotSdkUiV2.startDocumentScanner(configuration);
print("@@@");
print(documentResult);
print("####");
return "changeme";
return documentResult.data?.pdfURI;
}
class ScanScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final boredSuggestion = ref.watch(_scanDocumentProvider);
restartScan() async {
try {
final val = ref.refresh(_scanDocumentProvider);
Logger.root.info("Load again startup result: $val");
} catch (e, s) {
Logger.root.shout("Failed to try again startup loading! $e $s");
}
}
// Perform a switch-case on the result to handle loading/error states
return switch (boredSuggestion) {
AsyncData(:final value) => Text('data: $value'),
AsyncError(:final error) => Text('error: $error'),
AsyncData(:final value) when value != null => ExpenseEditor(
filePath: value,
onFinished: (e) {},
),
// 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()),
};
}
}
class ScanErrorScreen extends StatelessWidget {
final String message;
final Function() onTryAgain;
const ScanErrorScreen({
super.key,
required this.message,
required this.onTryAgain,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Spacer(flex: 5),
Text("An error occurred while scanning"),
Spacer(flex: 1),
Text(message),
Spacer(flex: 1),
MaterialButton(
onPressed: onTryAgain,
child: Text("Try again".toUpperCase()),
),
Spacer(flex: 5),
],
),
);
}
}
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

@@ -0,0 +1,117 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'expenses.freezed.dart';
part 'expenses.g.dart';
typedef ExpensesList = List<Expense>;
@freezed
abstract class Expense with _$Expense {
const Expense._();
const factory Expense({
/// Internal id used to identify the expense
required int id,
/// Label of the expense
required String? label,
/// The cost shall always be a positive value
required double cost,
/// Time associated with the expense
required int time,
/// Associated file mime type
required String mimeType,
}) = _Expense;
factory Expense.fromJson(Map<String, dynamic> json) =>
_$ExpenseFromJson(json);
/// Get associated expense file name
String get localFileName {
if (mimeType == "application/pdf") return "$id.pdf";
if (mimeType == "image/jpeg") return "$id.jpeg";
if (mimeType == "image/png") return "$id.png";
return id.toString();
}
}
@riverpod
Future<ExpensesManager> expenses(Ref ref) async => ExpensesManager.instance();
class ExpensesManager {
final String storagePath;
ExpensesManager._({required this.storagePath});
/// Get an instance of this manager
static Future<ExpensesManager> instance() async {
final appDir = await getApplicationDocumentsDirectory();
final subDir = p.join(appDir.absolute.path, "expenses");
final result = await Directory(subDir).create(recursive: true);
return ExpensesManager._(storagePath: result.absolute.path);
}
/// Get expenses list file path
File get expenseFile => File(p.join(storagePath, "list.json"));
/// Get the files storage path
Directory get filesStoragePath => Directory(p.join(storagePath, "exp_files"));
/// Get the current list of expenses
Future<ExpensesList> getList() async {
final jsonDec = jsonDecode(await expenseFile.readAsString());
return List<Expense>.from(jsonDec.map((m) => Expense.fromJson(m)));
}
/// Save the list of expenses
Future<void> saveList(ExpensesList list) async {
final jsonDoc = jsonEncode(list.map((t) => t.toJson()));
await expenseFile.writeAsString(jsonDoc);
}
/// Add a new expense to the list
Future<void> add({
required String? label,
required double cost,
required int time,
required List<int> fileContent,
required String fileMimeType,
}) async {
final list = await getList();
final exp = Expense(
id: (list.lastOrNull?.id ?? 0) + Random().nextInt(1000),
label: label,
cost: cost,
time: time,
mimeType: fileMimeType,
);
// Create files storage directory if required
if (!await filesStoragePath.exists()) {
await filesStoragePath.create(recursive: true);
}
// Save associated file
final file = File(
p.join(filesStoragePath.absolute.path, exp.localFileName),
);
await file.writeAsBytes(fileContent);
// Save the list of expenses
list.add(exp);
await saveList(list);
}
}

View File

@@ -673,7 +673,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"

View File

@@ -85,6 +85,9 @@ dependencies:
# https://developers.google.com/ml-kit/tips/installation-paths
scanbot_sdk: ^7.0.0
# Get documents path
path_provider: ^2.1.5
dev_dependencies:
flutter_test:
sdk: flutter