Can update scanned expenses entries
This commit is contained in:
		
							
								
								
									
										69
									
								
								moneymgr_mobile/lib/routes/scan_details/scan_details.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								moneymgr_mobile/lib/routes/scan_details/scan_details.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,69 @@
 | 
			
		||||
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");
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return switch (expense) {
 | 
			
		||||
      AsyncData(:final value) when value == null => FullScreenError(
 | 
			
		||||
        message: "Expense does not exists!",
 | 
			
		||||
        error: 'NONE',
 | 
			
		||||
      ),
 | 
			
		||||
      AsyncData(:final value) => ExpenseEditor(
 | 
			
		||||
        file: value!.$2,
 | 
			
		||||
        onFinished: handleUpdate,
 | 
			
		||||
        initialData: value.$1.baseExpense,
 | 
			
		||||
      ),
 | 
			
		||||
      AsyncError(:final error) => FullScreenError(
 | 
			
		||||
        message: "Failed to load expense information!",
 | 
			
		||||
        error: error.toString(),
 | 
			
		||||
      ),
 | 
			
		||||
      _ => LoadingScaffold(title: "Expense $id"),
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,5 +1,7 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:go_router/go_router.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/utils/time_utils.dart';
 | 
			
		||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
			
		||||
@@ -37,9 +39,10 @@ class _ExpensesList extends StatelessWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return ListView.builder(
 | 
			
		||||
      itemBuilder: (context, id) {
 | 
			
		||||
        final expense = list[id];
 | 
			
		||||
      itemBuilder: (context, entryNum) {
 | 
			
		||||
        final expense = list[entryNum];
 | 
			
		||||
        return ListTile(
 | 
			
		||||
          onTap: () => context.push("$scansPage/${expense.id}"),
 | 
			
		||||
          leading: Icon(Icons.receipt_long),
 | 
			
		||||
          title: Text(
 | 
			
		||||
            expense.label ?? "No label",
 | 
			
		||||
 
 | 
			
		||||
@@ -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/profile/profile_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/settings/settings_screen.dart';
 | 
			
		||||
import 'package:moneymgr_mobile/services/router/routes_list.dart';
 | 
			
		||||
@@ -36,7 +37,7 @@ GoRouter router(Ref ref) {
 | 
			
		||||
  // see [AuthState] enum.
 | 
			
		||||
  final navigationItems = [
 | 
			
		||||
    NavigationItem(
 | 
			
		||||
      path: scanPage,
 | 
			
		||||
      path: capturePage,
 | 
			
		||||
      body: (_) => ScanScreen(),
 | 
			
		||||
      icon: Icons.camera_alt_outlined,
 | 
			
		||||
      selectedIcon: Icons.camera_alt,
 | 
			
		||||
@@ -48,6 +49,15 @@ GoRouter router(Ref ref) {
 | 
			
		||||
      icon: Icons.list,
 | 
			
		||||
      selectedIcon: Icons.list_alt,
 | 
			
		||||
      label: "List",
 | 
			
		||||
      routes: [
 | 
			
		||||
        GoRoute(
 | 
			
		||||
          path: ":id",
 | 
			
		||||
          builder: (_, state) {
 | 
			
		||||
            final id = int.parse(state.pathParameters["id"]!);
 | 
			
		||||
            return ScanDetailScreen(id: id);
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    ),
 | 
			
		||||
    NavigationItem(
 | 
			
		||||
      path: profilePage,
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@ const manualAuthPage = "/login/manual";
 | 
			
		||||
const settingsPage = "/settings";
 | 
			
		||||
 | 
			
		||||
/// Scan path
 | 
			
		||||
const scanPage = "/scan";
 | 
			
		||||
const capturePage = "/scan";
 | 
			
		||||
 | 
			
		||||
/// Scans page
 | 
			
		||||
const scansPage = "/scans";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
import 'dart:math';
 | 
			
		||||
import 'dart:typed_data';
 | 
			
		||||
 | 
			
		||||
import 'package:freezed_annotation/freezed_annotation.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
@@ -8,17 +9,24 @@ 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';
 | 
			
		||||
part 'expenses.freezed.dart';
 | 
			
		||||
 | 
			
		||||
part 'expenses.g.dart';
 | 
			
		||||
 | 
			
		||||
typedef ExpensesList = List<Expense>;
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
abstract class BaseExpenseInfo with _$BaseExpenseInfo {
 | 
			
		||||
  const BaseExpenseInfo._();
 | 
			
		||||
 | 
			
		||||
  const factory BaseExpenseInfo({
 | 
			
		||||
    required String? label,
 | 
			
		||||
    required double cost,
 | 
			
		||||
    required DateTime time,
 | 
			
		||||
  }) = _BaseExpenseInfo;
 | 
			
		||||
 | 
			
		||||
  /// Get expense time as second
 | 
			
		||||
  int get timeAsSeconds => (time.millisecondsSinceEpoch / 1000).floor();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
@@ -45,6 +53,10 @@ abstract class Expense with _$Expense {
 | 
			
		||||
  factory Expense.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$ExpenseFromJson(json);
 | 
			
		||||
 | 
			
		||||
  /// Get base expense information
 | 
			
		||||
  BaseExpenseInfo get baseExpense =>
 | 
			
		||||
      BaseExpenseInfo(label: label, cost: cost, time: dateTime);
 | 
			
		||||
 | 
			
		||||
  /// Get associated expense file name
 | 
			
		||||
  String get localFileName {
 | 
			
		||||
    if (mimeType == "application/pdf") return "$id.pdf";
 | 
			
		||||
@@ -109,7 +121,7 @@ class ExpensesManager {
 | 
			
		||||
      id: (list.lastOrNull?.id ?? 0) + Random().nextInt(1000),
 | 
			
		||||
      label: info.label,
 | 
			
		||||
      cost: info.cost,
 | 
			
		||||
      time: (info.time.millisecondsSinceEpoch / 1000).floor(),
 | 
			
		||||
      time: info.timeAsSeconds,
 | 
			
		||||
      mimeType: fileMimeType,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@@ -128,4 +140,28 @@ class ExpensesManager {
 | 
			
		||||
    list.add(exp);
 | 
			
		||||
    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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -16,21 +16,27 @@ class ExpenseEditor extends HookConsumerWidget {
 | 
			
		||||
  final Uint8List file;
 | 
			
		||||
  final Future<void> Function(BaseExpenseInfo) onFinished;
 | 
			
		||||
  final Function()? onRescan;
 | 
			
		||||
  final Function()? onDelete;
 | 
			
		||||
  final BaseExpenseInfo? initialData;
 | 
			
		||||
 | 
			
		||||
  const ExpenseEditor({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.file,
 | 
			
		||||
    required this.onFinished,
 | 
			
		||||
    required this.onRescan,
 | 
			
		||||
    this.onRescan,
 | 
			
		||||
    this.onDelete,
 | 
			
		||||
    this.initialData,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @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());
 | 
			
		||||
    final labelController = useTextEditingController(text: initialData?.label);
 | 
			
		||||
    final costController = useTextEditingController(
 | 
			
		||||
      text: initialData?.cost.toString(),
 | 
			
		||||
    );
 | 
			
		||||
    final timeController = useState(initialData?.time ?? DateTime.now());
 | 
			
		||||
 | 
			
		||||
    final (:pending, :snapshot, :hasError) = useAsyncTask();
 | 
			
		||||
 | 
			
		||||
@@ -72,16 +78,34 @@ 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(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: Text("Expense info"),
 | 
			
		||||
        actions: [
 | 
			
		||||
          // Rescan expense
 | 
			
		||||
          IconButton(
 | 
			
		||||
            onPressed: onRescan == null ? null : handleRescan,
 | 
			
		||||
          onRescan == null
 | 
			
		||||
              ? Container()
 | 
			
		||||
              : IconButton(
 | 
			
		||||
                  onPressed: handleRescan,
 | 
			
		||||
                  icon: Icon(Icons.restart_alt),
 | 
			
		||||
                ),
 | 
			
		||||
 | 
			
		||||
          // Delete expense
 | 
			
		||||
          onDelete == null
 | 
			
		||||
              ? Container()
 | 
			
		||||
              : IconButton(onPressed: handleDelete, icon: Icon(Icons.delete)),
 | 
			
		||||
 | 
			
		||||
          // Submit
 | 
			
		||||
          snapshot.connectionState == ConnectionState.waiting
 | 
			
		||||
              ? CircularProgressIndicator()
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								moneymgr_mobile/lib/widgets/loading_scaffold.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								moneymgr_mobile/lib/widgets/loading_scaffold.dart
									
									
									
									
									
										Normal 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()),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user