3 Commits

Author SHA1 Message Date
8ec6e48938 Start to build synchronization logic
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-07-15 21:14:09 +02:00
235fda5c72 Can delete expense 2025-07-15 20:11:43 +02:00
2568ea14b4 Can update scanned expenses entries 2025-07-15 20:00:55 +02:00
12 changed files with 347 additions and 24 deletions

View File

@@ -0,0 +1,86 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/utils/extensions.dart';
import 'package:moneymgr_mobile/widgets/expense_editor.dart';
import 'package:moneymgr_mobile/widgets/full_screen_error.dart';
import 'package:moneymgr_mobile/widgets/loading_scaffold.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'scan_details.g.dart';
@riverpod
Future<(Expense, Uint8List)?> _getExpense(Ref ref, {required int id}) async {
final expProvider = ref.watch(expensesProvider).requireValue;
final expense = await expProvider.getById(id);
if (expense == null) return null;
final file = await expProvider.loadFile(expense);
return (expense, file);
}
class ScanDetailScreen extends HookConsumerWidget {
final int id;
const ScanDetailScreen({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final expenses = ref.watch(expensesProvider).requireValue;
final expense = ref.watch(_getExpenseProvider(id: id));
handleUpdate(BaseExpenseInfo newInfo) async {
try {
await expenses.updateExpense(expense.requireValue!.$1, newInfo);
if (context.mounted) {
context.pop();
}
ref.invalidate(expensesProvider);
} catch (e, s) {
Logger.root.warning("Failed to update expense! $e$s");
if (context.mounted) {
context.showTextSnackBar("Failed to update expense! $e");
}
}
}
handleDelete() async {
try {
await expenses.deleteExpense(expense.requireValue!.$1);
if (context.mounted) {
context.pop();
}
ref.invalidate(expensesProvider);
} catch (e, s) {
Logger.root.warning("Failed to delete expense! $e$s");
if (context.mounted) {
context.showTextSnackBar("Failed to delete expense! $e");
}
}
}
return switch (expense) {
AsyncData(:final value) when value == null => FullScreenError(
message: "Expense does not exists!",
error: 'NONE',
),
AsyncData(:final value) => ExpenseEditor(
file: value!.$2,
initialData: value.$1.baseExpense,
onFinished: handleUpdate,
onDelete: handleDelete,
),
AsyncError(:final error) => FullScreenError(
message: "Failed to load expense information!",
error: error.toString(),
),
_ => LoadingScaffold(title: "Expense $id"),
};
}
}

View File

@@ -1,7 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:moneymgr_mobile/services/router/routes_list.dart';
import 'package:moneymgr_mobile/services/storage/expenses.dart'; import 'package:moneymgr_mobile/services/storage/expenses.dart';
import 'package:moneymgr_mobile/utils/time_utils.dart'; import 'package:moneymgr_mobile/utils/time_utils.dart';
import 'package:moneymgr_mobile/widgets/full_screen_error.dart';
import 'package:moneymgr_mobile/widgets/loading_scaffold.dart';
import 'package:moneymgr_mobile/widgets/synchronize_button.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'scans_list_screen.g.dart'; part 'scans_list_screen.g.dart';
@@ -18,14 +23,21 @@ class ScansListScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final expensesList = ref.watch(_expensesListProvider); final expensesList = ref.watch(_expensesListProvider);
return Scaffold(
appBar: AppBar(title: Text("Expenses")), return switch (expensesList) {
body: switch (expensesList) { AsyncData(:final value) => Scaffold(
AsyncData(:final value) => _ExpensesList(list: value), appBar: AppBar(
AsyncError(:final error) => Center(child: Text('Load error: $error')), title: Text("Expenses"),
_ => const Center(child: CircularProgressIndicator()), actions: [value.isEmpty ? Container() : SynchronizeButton()],
}, ),
); body: _ExpensesList(list: value),
),
AsyncError(:final error) => FullScreenError(
message: "Failed to load the list of expenses",
error: error.toString(),
),
_ => const LoadingScaffold(title: "Expenses"),
};
} }
} }
@@ -37,9 +49,10 @@ class _ExpensesList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.builder( return ListView.builder(
itemBuilder: (context, id) { itemBuilder: (context, entryNum) {
final expense = list[id]; final expense = list[entryNum];
return ListTile( return ListTile(
onTap: () => context.push("$scansPage/${expense.id}"),
leading: Icon(Icons.receipt_long), leading: Icon(Icons.receipt_long),
title: Text( title: Text(
expense.label ?? "No label", expense.label ?? "No label",

View File

@@ -24,7 +24,11 @@ class ApiClient {
: client = Dio(BaseOptions(baseUrl: token.apiUrl)); : client = Dio(BaseOptions(baseUrl: token.apiUrl));
/// Get Dio instance /// Get Dio instance
Future<Response<T>> execute<T>(String uri, {String method = "GET"}) async { Future<Response<T>> execute<T>(
String uri, {
String method = "GET",
Object? data,
}) async {
Logger.root.fine("Request on ${token.apiUrl} - URI $uri"); Logger.root.fine("Request on ${token.apiUrl} - URI $uri");
return client.request( return client.request(
uri, uri,
@@ -32,6 +36,7 @@ class ApiClient {
method: method, method: method,
headers: {apiTokenHeader: _genJWT(method, uri)}, headers: {apiTokenHeader: _genJWT(method, uri)},
), ),
data: data,
); );
} }

View File

@@ -0,0 +1,43 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:http_parser/http_parser.dart';
import 'package:logging/logging.dart';
import 'package:moneymgr_mobile/services/api/api_client.dart';
part 'files_api.freezed.dart';
part 'files_api.g.dart';
@freezed
abstract class UploadResult with _$UploadResult {
const factory UploadResult({required int id}) = _UploadResult;
factory UploadResult.fromJson(Map<String, dynamic> json) =>
_$UploadResultFromJson(json);
}
extension FilesApi on ApiClient {
/// Upload a file
Future<UploadResult> uploadFile({
required String filename,
required String mimeType,
required Uint8List bytes,
}) async {
final res = await execute(
"/file",
method: "POST",
data: FormData.fromMap({
"file": MultipartFile.fromBytes(
bytes,
filename: filename,
contentType: MediaType.parse(mimeType),
),
}),
);
Logger.root.fine("Successfully uploaded file with response=${res.data}");
return UploadResult.fromJson(res.data);
}
}

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/scan_details/scan_details.dart';
import 'package:moneymgr_mobile/routes/scans_list/scans_list_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';
@@ -36,7 +37,7 @@ GoRouter router(Ref ref) {
// see [AuthState] enum. // see [AuthState] enum.
final navigationItems = [ final navigationItems = [
NavigationItem( NavigationItem(
path: scanPage, path: capturePage,
body: (_) => ScanScreen(), body: (_) => ScanScreen(),
icon: Icons.camera_alt_outlined, icon: Icons.camera_alt_outlined,
selectedIcon: Icons.camera_alt, selectedIcon: Icons.camera_alt,
@@ -48,6 +49,15 @@ GoRouter router(Ref ref) {
icon: Icons.list, icon: Icons.list,
selectedIcon: Icons.list_alt, selectedIcon: Icons.list_alt,
label: "List", label: "List",
routes: [
GoRoute(
path: ":id",
builder: (_, state) {
final id = int.parse(state.pathParameters["id"]!);
return ScanDetailScreen(id: id);
},
),
],
), ),
NavigationItem( NavigationItem(
path: profilePage, path: profilePage,

View File

@@ -14,7 +14,7 @@ const manualAuthPage = "/login/manual";
const settingsPage = "/settings"; const settingsPage = "/settings";
/// Scan path /// Scan path
const scanPage = "/scan"; const capturePage = "/scan";
/// Scans page /// Scans page
const scansPage = "/scans"; const scansPage = "/scans";

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -8,17 +9,23 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'expenses.freezed.dart';part 'expenses.g.dart'; part 'expenses.freezed.dart';
part 'expenses.g.dart';
typedef ExpensesList = List<Expense>; typedef ExpensesList = List<Expense>;
@freezed @freezed
abstract class BaseExpenseInfo with _$BaseExpenseInfo { abstract class BaseExpenseInfo with _$BaseExpenseInfo {
const 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;
/// Get expense time as second
int get timeAsSeconds => (time.millisecondsSinceEpoch / 1000).floor();
} }
@freezed @freezed
@@ -45,6 +52,10 @@ abstract class Expense with _$Expense {
factory Expense.fromJson(Map<String, dynamic> json) => factory Expense.fromJson(Map<String, dynamic> json) =>
_$ExpenseFromJson(json); _$ExpenseFromJson(json);
/// Get base expense information
BaseExpenseInfo get baseExpense =>
BaseExpenseInfo(label: label, cost: cost, time: dateTime);
/// Get associated expense file name /// Get associated expense file name
String get localFileName { String get localFileName {
if (mimeType == "application/pdf") return "$id.pdf"; if (mimeType == "application/pdf") return "$id.pdf";
@@ -109,7 +120,7 @@ class ExpensesManager {
id: (list.lastOrNull?.id ?? 0) + Random().nextInt(1000), id: (list.lastOrNull?.id ?? 0) + Random().nextInt(1000),
label: info.label, label: info.label,
cost: info.cost, cost: info.cost,
time: (info.time.millisecondsSinceEpoch / 1000).floor(), time: info.timeAsSeconds,
mimeType: fileMimeType, mimeType: fileMimeType,
); );
@@ -128,4 +139,45 @@ class ExpensesManager {
list.add(exp); list.add(exp);
await saveList(list); await saveList(list);
} }
/// Get a single expense information by its ID
Future<Expense?> getById(int id) async {
final list = await getList();
return list.firstWhere((e) => e.id == id);
}
/// Load the file associated with an expense
Future<Uint8List> loadFile(Expense expense) async {
final path = p.join(filesStoragePath.absolute.path, expense.localFileName);
return File(path).readAsBytes();
}
/// Update expense information
Future<void> updateExpense(Expense expense, BaseExpenseInfo newInfo) async {
final list = await getList();
final entry = list.indexWhere((e) => e.id == expense.id);
list[entry] = Expense(
id: expense.id,
label: newInfo.label,
cost: newInfo.cost,
time: newInfo.timeAsSeconds,
mimeType: expense.mimeType,
);
saveList(list);
}
/// Delete an expense
Future<void> deleteExpense(Expense expense) async {
// Remove expense from the list
final list = await getList();
await saveList(list.where((e) => e.id != expense.id).toList());
// Delete associated file, if any
final filePath = File(
p.join(filesStoragePath.absolute.path, expense.localFileName),
);
if (await filePath.exists()) {
await filePath.delete();
}
}
} }

View File

@@ -16,21 +16,27 @@ class ExpenseEditor extends HookConsumerWidget {
final Uint8List file; final Uint8List file;
final Future<void> Function(BaseExpenseInfo) onFinished; final Future<void> Function(BaseExpenseInfo) onFinished;
final Function()? onRescan; final Function()? onRescan;
final Function()? onDelete;
final BaseExpenseInfo? initialData;
const ExpenseEditor({ const ExpenseEditor({
super.key, super.key,
required this.file, required this.file,
required this.onFinished, required this.onFinished,
required this.onRescan, this.onRescan,
this.onDelete,
this.initialData,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final serverConfig = ref.watch(prefsProvider).requireValue.serverConfig()!; final serverConfig = ref.watch(prefsProvider).requireValue.serverConfig()!;
final labelController = useTextEditingController(); final labelController = useTextEditingController(text: initialData?.label);
final costController = useTextEditingController(); final costController = useTextEditingController(
final timeController = useState(DateTime.now()); text: initialData?.cost.toString(),
);
final timeController = useState(initialData?.time ?? DateTime.now());
final (:pending, :snapshot, :hasError) = useAsyncTask(); final (:pending, :snapshot, :hasError) = useAsyncTask();
@@ -72,15 +78,33 @@ class ExpenseEditor extends HookConsumerWidget {
} }
} }
// Delete expense
handleDelete() async {
if (await confirm(
context,
content: Text("Do you really want to delete this expense?"),
) &&
onDelete != null) {
onDelete!();
}
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("Expense info"), title: Text("Expense info"),
actions: [ actions: [
// Rescan expense // Rescan expense
IconButton( onRescan == null
onPressed: onRescan == null ? null : handleRescan, ? Container()
icon: Icon(Icons.restart_alt), : IconButton(
), onPressed: handleRescan,
icon: Icon(Icons.restart_alt),
),
// Delete expense
onDelete == null
? Container()
: IconButton(onPressed: handleDelete, icon: Icon(Icons.delete)),
// Submit // Submit
snapshot.connectionState == ConnectionState.waiting snapshot.connectionState == ConnectionState.waiting

View File

@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
class LoadingScaffold extends StatelessWidget {
final String title;
const LoadingScaffold({super.key, required this.title});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(child: CircularProgressIndicator()),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:moneymgr_mobile/services/api/api_client.dart';
import 'package:moneymgr_mobile/services/api/files_api.dart';
import 'package:moneymgr_mobile/services/storage/expenses.dart';
import 'package:moneymgr_mobile/utils/extensions.dart';
import 'package:moneymgr_mobile/utils/hooks.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'synchronize_button.g.dart';
/// Synchronize expenses list with backend
@riverpod
Future<void> _performSynchronization(Ref ref) async {
final expenses = ref.watch(expensesProvider).requireValue;
final apiService = ref.watch(apiServiceProvider)!;
final list = await expenses.getList();
for (final exp in list) {
// First, upload file
final bytes = await expenses.loadFile(exp);
final file = await apiService.uploadFile(
filename: exp.localFileName,
mimeType: exp.mimeType,
bytes: bytes,
);
// TODO continue
break;
}
}
class SynchronizeButton extends HookConsumerWidget {
const SynchronizeButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final (:pending, :snapshot, :hasError) = useAsyncTask();
handleSynchronize() async {
try {
await ref.watch(
_performSynchronizationProvider.selectAsync((it) => it),
);
} catch (e, s) {
Logger.root.warning("Failed to synchronize expenses! $e $s");
if (context.mounted) {
context.showTextSnackBar("Failed to synchronize expenses! $e");
}
}
}
return snapshot.connectionState == ConnectionState.waiting
? Padding(
padding: const EdgeInsets.all(12.0),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
onPressed: () => pending.value = handleSynchronize(),
style: ButtonStyle(
backgroundColor: hasError
? WidgetStatePropertyAll(Colors.red)
: null,
),
icon: Icon(Icons.sync_rounded),
);
}
}

View File

@@ -529,7 +529,7 @@ packages:
source: hosted source: hosted
version: "3.2.2" version: "3.2.2"
http_parser: http_parser:
dependency: transitive dependency: "direct main"
description: description:
name: http_parser name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"

View File

@@ -72,6 +72,7 @@ dependencies:
# API requests # API requests
dio: ^5.8.0+1 dio: ^5.8.0+1
http_parser: ^4.1.2
# Qr Code library # Qr Code library
mobile_scanner: ^7.0.1 mobile_scanner: ^7.0.1