Start to build synchronization logic
This commit is contained in:
		@@ -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/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';
 | 
				
			||||||
@@ -20,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"),
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										43
									
								
								moneymgr_mobile/lib/services/api/files_api.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								moneymgr_mobile/lib/services/api/files_api.dart
									
									
									
									
									
										Normal 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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										74
									
								
								moneymgr_mobile/lib/widgets/synchronize_button.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								moneymgr_mobile/lib/widgets/synchronize_button.dart
									
									
									
									
									
										Normal 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),
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user