Files
MoneyMgr/moneymgr_mobile/lib/services/storage/expenses.dart
2025-07-15 20:11:43 +02:00

184 lines
5.1 KiB
Dart

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';
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';
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
abstract class Expense with _$Expense {
const Expense._();
const factory Expense({
/// Internal id used to identify the expense
required int id,
/// Label of the expense
required String? label,
/// The cost shall always be a positive value
required double cost,
/// Time associated with the expense (seconds since epoch)
required int time,
/// Associated file mime type
required String mimeType,
}) = _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";
if (mimeType == "image/jpeg") return "$id.jpeg";
if (mimeType == "image/png") return "$id.png";
return id.toString();
}
/// Get expense date
DateTime get dateTime => DateTime.fromMillisecondsSinceEpoch(time * 1000);
}
@riverpod
Future<ExpensesManager> expenses(Ref ref) async => ExpensesManager.instance();
class ExpensesManager {
final String storagePath;
ExpensesManager._({required this.storagePath});
/// Get an instance of this manager
static Future<ExpensesManager> instance() async {
final appDir = await getApplicationDocumentsDirectory();
final subDir = p.join(appDir.absolute.path, "expenses");
final result = await Directory(subDir).create(recursive: true);
return ExpensesManager._(storagePath: result.absolute.path);
}
/// Get expenses list file path
File get expenseFile => File(p.join(storagePath, "list.json"));
/// Get the files storage path
Directory get filesStoragePath => Directory(p.join(storagePath, "exp_files"));
/// Get the current list of expenses
Future<ExpensesList> getList() async {
// On first save the list does not exists.
if (!await expenseFile.exists()) {
return [];
}
final jsonDec = jsonDecode(await expenseFile.readAsString());
return List<Expense>.from(jsonDec.map((m) => Expense.fromJson(m)));
}
/// Save the list of expenses
Future<void> saveList(ExpensesList list) async {
final jsonDoc = jsonEncode(list.map((t) => t.toJson()).toList());
await expenseFile.writeAsString(jsonDoc);
}
/// Add a new expense to the list
Future<void> add({
required BaseExpenseInfo info,
required List<int> fileContent,
required String fileMimeType,
}) async {
final list = await getList();
final exp = Expense(
id: (list.lastOrNull?.id ?? 0) + Random().nextInt(1000),
label: info.label,
cost: info.cost,
time: info.timeAsSeconds,
mimeType: fileMimeType,
);
// Create files storage directory if required
if (!await filesStoragePath.exists()) {
await filesStoragePath.create(recursive: true);
}
// Save associated file
final file = File(
p.join(filesStoragePath.absolute.path, exp.localFileName),
);
await file.writeAsBytes(fileContent);
// Save the list of expenses
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();
}
}
}