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; @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 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 expenses(Ref ref) async => ExpensesManager.instance(); class ExpensesManager { final String storagePath; ExpensesManager._({required this.storagePath}); /// Get an instance of this manager static Future 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 getList() async { // On first save the list does not exists. if (!await expenseFile.exists()) { return []; } final jsonDec = jsonDecode(await expenseFile.readAsString()); return List.from(jsonDec.map((m) => Expense.fromJson(m))); } /// Save the list of expenses Future 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 add({ required BaseExpenseInfo info, required List 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 getById(int id) async { final list = await getList(); return list.firstWhere((e) => e.id == id); } /// Load the file associated with an expense Future loadFile(Expense expense) async { final path = p.join(filesStoragePath.absolute.path, expense.localFileName); return File(path).readAsBytes(); } /// Update expense information Future 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 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(); } } }