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: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:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
@@ -37,9 +39,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",
|
||||||
|
@@ -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,
|
||||||
|
@@ -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";
|
||||||
|
@@ -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,24 @@ 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 +53,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 +121,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 +140,28 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
@@ -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,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(
|
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()
|
||||||
|
: IconButton(
|
||||||
|
onPressed: handleRescan,
|
||||||
icon: Icon(Icons.restart_alt),
|
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
|
||||||
? CircularProgressIndicator()
|
? 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