diff --git a/moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart b/moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart index 078d9ab..9185ca3 100644 --- a/moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart +++ b/moneymgr_mobile/lib/routes/scans_list/scans_list_screen.dart @@ -4,6 +4,9 @@ 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/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'; part 'scans_list_screen.g.dart'; @@ -20,14 +23,21 @@ class ScansListScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final expensesList = ref.watch(_expensesListProvider); - return Scaffold( - appBar: AppBar(title: Text("Expenses")), - body: switch (expensesList) { - AsyncData(:final value) => _ExpensesList(list: value), - AsyncError(:final error) => Center(child: Text('Load error: $error')), - _ => const Center(child: CircularProgressIndicator()), - }, - ); + + return switch (expensesList) { + AsyncData(:final value) => Scaffold( + appBar: AppBar( + title: Text("Expenses"), + 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"), + }; } } diff --git a/moneymgr_mobile/lib/services/api/api_client.dart b/moneymgr_mobile/lib/services/api/api_client.dart index 32cc3bd..c0053c6 100644 --- a/moneymgr_mobile/lib/services/api/api_client.dart +++ b/moneymgr_mobile/lib/services/api/api_client.dart @@ -24,7 +24,11 @@ class ApiClient { : client = Dio(BaseOptions(baseUrl: token.apiUrl)); /// Get Dio instance - Future> execute(String uri, {String method = "GET"}) async { + Future> execute( + String uri, { + String method = "GET", + Object? data, + }) async { Logger.root.fine("Request on ${token.apiUrl} - URI $uri"); return client.request( uri, @@ -32,6 +36,7 @@ class ApiClient { method: method, headers: {apiTokenHeader: _genJWT(method, uri)}, ), + data: data, ); } diff --git a/moneymgr_mobile/lib/services/api/files_api.dart b/moneymgr_mobile/lib/services/api/files_api.dart new file mode 100644 index 0000000..08d553e --- /dev/null +++ b/moneymgr_mobile/lib/services/api/files_api.dart @@ -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 json) => + _$UploadResultFromJson(json); +} + +extension FilesApi on ApiClient { + /// Upload a file + Future 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); + } +} diff --git a/moneymgr_mobile/lib/widgets/synchronize_button.dart b/moneymgr_mobile/lib/widgets/synchronize_button.dart new file mode 100644 index 0000000..85ea93b --- /dev/null +++ b/moneymgr_mobile/lib/widgets/synchronize_button.dart @@ -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 _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), + ); + } +} diff --git a/moneymgr_mobile/pubspec.lock b/moneymgr_mobile/pubspec.lock index 3e030a4..e40a0f2 100644 --- a/moneymgr_mobile/pubspec.lock +++ b/moneymgr_mobile/pubspec.lock @@ -529,7 +529,7 @@ packages: source: hosted version: "3.2.2" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" diff --git a/moneymgr_mobile/pubspec.yaml b/moneymgr_mobile/pubspec.yaml index 6f61c51..c019987 100644 --- a/moneymgr_mobile/pubspec.yaml +++ b/moneymgr_mobile/pubspec.yaml @@ -72,6 +72,7 @@ dependencies: # API requests dio: ^5.8.0+1 + http_parser: ^4.1.2 # Qr Code library mobile_scanner: ^7.0.1