Compare commits
3 Commits
cecb7a0cd1
...
8ec6e48938
Author | SHA1 | Date | |
---|---|---|---|
8ec6e48938 | |||
235fda5c72 | |||
2568ea14b4 |
86
moneymgr_mobile/lib/routes/scan_details/scan_details.dart
Normal file
86
moneymgr_mobile/lib/routes/scan_details/scan_details.dart
Normal 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"),
|
||||
};
|
||||
}
|
||||
}
|
@@ -1,7 +1,12 @@
|
||||
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: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';
|
||||
@@ -18,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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +49,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",
|
||||
|
@@ -24,7 +24,11 @@ class ApiClient {
|
||||
: client = Dio(BaseOptions(baseUrl: token.apiUrl));
|
||||
|
||||
/// 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");
|
||||
return client.request(
|
||||
uri,
|
||||
@@ -32,6 +36,7 @@ class ApiClient {
|
||||
method: method,
|
||||
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);
|
||||
}
|
||||
}
|
@@ -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,23 @@ 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 +52,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 +120,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 +139,45 @@ 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);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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,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(
|
||||
appBar: AppBar(
|
||||
title: Text("Expense info"),
|
||||
actions: [
|
||||
// Rescan expense
|
||||
IconButton(
|
||||
onPressed: onRescan == null ? null : handleRescan,
|
||||
icon: Icon(Icons.restart_alt),
|
||||
),
|
||||
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
|
||||
|
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()),
|
||||
);
|
||||
}
|
||||
}
|
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
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user