Initial upload: fin65-mobile
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../models/account.dart';
|
||||
import '../models/category.dart';
|
||||
import '../models/dashboard.dart';
|
||||
import '../models/transaction_item.dart';
|
||||
import 'offline_store.dart';
|
||||
|
||||
class ApiService {
|
||||
ApiService({OfflineStore? store}) : _store = store ?? OfflineStore.instance;
|
||||
|
||||
final OfflineStore _store;
|
||||
static const String _baseUrlKey = 'api_base_url';
|
||||
|
||||
Future<String> getBaseUrl() async {
|
||||
return await _store.getSetting(_baseUrlKey) ?? AppConfig.defaultApiBaseUrl;
|
||||
}
|
||||
|
||||
Future<void> setBaseUrl(String value) async {
|
||||
await _store.setSetting(
|
||||
_baseUrlKey,
|
||||
value.trim().replaceAll(RegExp(r'/$'), ''),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uri> _uri(String path) async {
|
||||
final baseUrl = await getBaseUrl();
|
||||
return Uri.parse('$baseUrl$path');
|
||||
}
|
||||
|
||||
Future<dynamic> _getJson(String path) async {
|
||||
final res = await http.get(await _uri(path)).timeout(const Duration(seconds: 5));
|
||||
return _handleResponse(res);
|
||||
}
|
||||
|
||||
Future<dynamic> _postJson(String path, Map<String, dynamic> body) async {
|
||||
final res = await http
|
||||
.post(
|
||||
await _uri(path),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
return _handleResponse(res);
|
||||
}
|
||||
|
||||
dynamic _handleResponse(http.Response res) {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
if (res.body.isEmpty) return null;
|
||||
return jsonDecode(utf8.decode(res.bodyBytes));
|
||||
}
|
||||
|
||||
String message = 'Erreur HTTP ${res.statusCode}';
|
||||
try {
|
||||
final body = jsonDecode(utf8.decode(res.bodyBytes));
|
||||
message = body['detail']?.toString() ?? body.toString();
|
||||
} catch (_) {}
|
||||
throw Exception(message);
|
||||
}
|
||||
|
||||
Future<bool> healthCheck() async {
|
||||
final data = await _getJson('/health');
|
||||
return data is Map && data['status'] == 'ok';
|
||||
}
|
||||
|
||||
Future<DashboardData> getDashboard() async {
|
||||
final data = await _getJson('/dashboard');
|
||||
return DashboardData.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<List<Account>> getAccounts() async {
|
||||
final data = await _getJson('/accounts?active_only=true') as List<dynamic>;
|
||||
return data.map((e) => Account.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<List<Category>> getCategories() async {
|
||||
final data = await _getJson('/categories') as List<dynamic>;
|
||||
return data.map((e) => Category.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<List<TransactionItem>> getTransactions({int limit = AppConfig.defaultTransactionLimit}) async {
|
||||
final data = await _getJson('/transactions?limit=$limit') as List<dynamic>;
|
||||
return data.map((e) => TransactionItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<TransactionItem> createTransactionFromPayload(Map<String, dynamic> payload) async {
|
||||
final cleanPayload = Map<String, dynamic>.from(payload);
|
||||
cleanPayload.removeWhere((_, value) => value == null);
|
||||
|
||||
final data = await _postJson('/transactions', cleanPayload);
|
||||
return TransactionItem.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<TransactionItem> createTransaction({
|
||||
required String label,
|
||||
required double amount,
|
||||
required String type,
|
||||
required int accountId,
|
||||
int? targetAccountId,
|
||||
int? categoryId,
|
||||
String? note,
|
||||
DateTime? date,
|
||||
}) async {
|
||||
return createTransactionFromPayload({
|
||||
'date': (date ?? DateTime.now()).toIso8601String(),
|
||||
'label': label,
|
||||
'amount': amount,
|
||||
'type': type,
|
||||
'account_id': accountId,
|
||||
'target_account_id': targetAccountId,
|
||||
'category_id': categoryId,
|
||||
'created_from': 'android',
|
||||
'note': note,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/account.dart';
|
||||
import '../models/category.dart';
|
||||
import '../models/dashboard.dart';
|
||||
import '../models/pending_operation.dart';
|
||||
import '../models/transaction_item.dart';
|
||||
|
||||
class OfflineStore {
|
||||
OfflineStore._();
|
||||
|
||||
static final OfflineStore instance = OfflineStore._();
|
||||
|
||||
Database? _database;
|
||||
|
||||
Future<Database> get database async {
|
||||
final existing = _database;
|
||||
if (existing != null) return existing;
|
||||
|
||||
final dbPath = await getDatabasesPath();
|
||||
final path = p.join(dbPath, 'fin65_mobile.db');
|
||||
|
||||
final db = await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
onCreate: _createSchema,
|
||||
);
|
||||
|
||||
_database = db;
|
||||
return db;
|
||||
}
|
||||
|
||||
Future<void> _createSchema(Database db, int version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE accounts_cache (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
initial_balance REAL NOT NULL,
|
||||
app_balance REAL NOT NULL,
|
||||
is_active INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE categories_cache (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
parent_id INTEGER,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE transactions_cache (
|
||||
id INTEGER PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
target_account_id INTEGER,
|
||||
category_id INTEGER,
|
||||
provision_id INTEGER,
|
||||
date TEXT NOT NULL,
|
||||
note TEXT,
|
||||
is_pending INTEGER NOT NULL DEFAULT 0,
|
||||
local_operation_id INTEGER,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE dashboard_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE pending_operations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
operation_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<String?> getSetting(String key) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'settings',
|
||||
where: 'key = ?',
|
||||
whereArgs: [key],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['value'] as String?;
|
||||
}
|
||||
|
||||
Future<void> setSetting(String key, String value) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
'settings',
|
||||
{'key': key, 'value': value},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> getSyncState(String key) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'sync_state',
|
||||
where: 'key = ?',
|
||||
whereArgs: [key],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['value'] as String?;
|
||||
}
|
||||
|
||||
Future<void> setSyncState(String key, String value) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
'sync_state',
|
||||
{'key': key, 'value': value},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<DateTime?> getLastSyncAt() async {
|
||||
final value = await getSyncState('last_successful_sync_at');
|
||||
return value == null ? null : DateTime.tryParse(value);
|
||||
}
|
||||
|
||||
Future<void> saveServerSnapshot({
|
||||
required DashboardData dashboard,
|
||||
required List<Account> accounts,
|
||||
required List<Category> categories,
|
||||
required List<TransactionItem> transactions,
|
||||
}) async {
|
||||
final db = await database;
|
||||
final now = DateTime.now().toIso8601String();
|
||||
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('accounts_cache');
|
||||
await txn.delete('categories_cache');
|
||||
await txn.delete('transactions_cache');
|
||||
await txn.delete('dashboard_cache');
|
||||
|
||||
for (final account in accounts) {
|
||||
await txn.insert(
|
||||
'accounts_cache',
|
||||
account.toDbMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
for (final category in categories) {
|
||||
await txn.insert(
|
||||
'categories_cache',
|
||||
category.toDbMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
for (final transaction in transactions) {
|
||||
await txn.insert(
|
||||
'transactions_cache',
|
||||
transaction.toDbMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await txn.insert(
|
||||
'dashboard_cache',
|
||||
{
|
||||
'id': 1,
|
||||
'json': jsonEncode(dashboard.toJson()),
|
||||
'updated_at': now,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
|
||||
await txn.insert(
|
||||
'sync_state',
|
||||
{
|
||||
'key': 'last_successful_sync_at',
|
||||
'value': now,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Account>> loadAccounts() async {
|
||||
final db = await database;
|
||||
final rows = await db.query('accounts_cache', orderBy: 'name COLLATE NOCASE ASC');
|
||||
return rows.map(Account.fromDbMap).toList();
|
||||
}
|
||||
|
||||
Future<List<Category>> loadCategories() async {
|
||||
final db = await database;
|
||||
final rows = await db.query('categories_cache', orderBy: 'type ASC, name COLLATE NOCASE ASC');
|
||||
return rows.map(Category.fromDbMap).toList();
|
||||
}
|
||||
|
||||
Future<List<TransactionItem>> loadTransactions({int limit = 50}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'transactions_cache',
|
||||
orderBy: 'date DESC, id DESC',
|
||||
limit: limit,
|
||||
);
|
||||
return rows.map(TransactionItem.fromDbMap).toList();
|
||||
}
|
||||
|
||||
Future<DashboardData?> loadDashboard() async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'dashboard_cache',
|
||||
where: 'id = 1',
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
|
||||
final decoded = jsonDecode(rows.first['json'] as String) as Map<String, dynamic>;
|
||||
return DashboardData.fromJson(decoded);
|
||||
}
|
||||
|
||||
Future<int> enqueueCreateTransaction(Map<String, dynamic> payload) async {
|
||||
final db = await database;
|
||||
return db.insert(
|
||||
'pending_operations',
|
||||
{
|
||||
'operation_type': 'create_transaction',
|
||||
'payload_json': jsonEncode(payload),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'status': 'pending',
|
||||
'error': null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<PendingOperation>> loadPendingOperations() async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'pending_operations',
|
||||
where: 'status = ?',
|
||||
whereArgs: ['pending'],
|
||||
orderBy: 'created_at ASC, id ASC',
|
||||
);
|
||||
return rows.map(PendingOperation.fromDbMap).toList();
|
||||
}
|
||||
|
||||
Future<int> pendingCount() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery(
|
||||
"SELECT COUNT(*) AS count FROM pending_operations WHERE status = 'pending'",
|
||||
);
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
Future<void> deletePendingOperation(int id) async {
|
||||
final db = await database;
|
||||
await db.delete(
|
||||
'pending_operations',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markPendingError(int id, String error) async {
|
||||
final db = await database;
|
||||
await db.update(
|
||||
'pending_operations',
|
||||
{
|
||||
'error': error,
|
||||
'status': 'pending',
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
import 'offline_store.dart';
|
||||
|
||||
class SecurityService {
|
||||
SecurityService({
|
||||
OfflineStore? store,
|
||||
LocalAuthentication? localAuth,
|
||||
}) : _store = store ?? OfflineStore.instance,
|
||||
_localAuth = localAuth ?? LocalAuthentication();
|
||||
|
||||
final OfflineStore _store;
|
||||
final LocalAuthentication _localAuth;
|
||||
|
||||
static const String _pinEnabledKey = 'security_pin_enabled';
|
||||
static const String _pinHashKey = 'security_pin_hash';
|
||||
static const String _biometricEnabledKey = 'security_biometric_enabled';
|
||||
static const String _salt = 'FIN65_LOCAL_PIN_V1';
|
||||
|
||||
Future<bool> isPinEnabled() async {
|
||||
final enabled = await _store.getSetting(_pinEnabledKey);
|
||||
final hash = await _store.getSetting(_pinHashKey);
|
||||
return enabled == 'true' && hash != null && hash.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<void> setPin(String pin) async {
|
||||
_validatePin(pin);
|
||||
await _store.setSetting(_pinHashKey, _hashPin(pin));
|
||||
await _store.setSetting(_pinEnabledKey, 'true');
|
||||
}
|
||||
|
||||
Future<bool> verifyPin(String pin) async {
|
||||
if (!_isValidPinShape(pin)) return false;
|
||||
final storedHash = await _store.getSetting(_pinHashKey);
|
||||
if (storedHash == null || storedHash.isEmpty) return false;
|
||||
return storedHash == _hashPin(pin);
|
||||
}
|
||||
|
||||
Future<void> disablePin() async {
|
||||
await _store.setSetting(_pinHashKey, '');
|
||||
await _store.setSetting(_pinEnabledKey, 'false');
|
||||
await setBiometricEnabled(false);
|
||||
}
|
||||
|
||||
Future<bool> isBiometricAvailable() async {
|
||||
try {
|
||||
final supported = await _localAuth.isDeviceSupported();
|
||||
final canCheck = await _localAuth.canCheckBiometrics;
|
||||
final biometrics = await _localAuth.getAvailableBiometrics();
|
||||
return supported && canCheck && biometrics.isNotEmpty;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<BiometricType>> getAvailableBiometrics() async {
|
||||
try {
|
||||
return await _localAuth.getAvailableBiometrics();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isBiometricEnabled() async {
|
||||
final enabled = await _store.getSetting(_biometricEnabledKey);
|
||||
final pinEnabled = await isPinEnabled();
|
||||
return pinEnabled && enabled == 'true';
|
||||
}
|
||||
|
||||
Future<void> setBiometricEnabled(bool enabled) async {
|
||||
if (enabled) {
|
||||
final pinEnabled = await isPinEnabled();
|
||||
if (!pinEnabled) {
|
||||
throw Exception('Active d’abord un PIN avant la biométrie.');
|
||||
}
|
||||
|
||||
final available = await isBiometricAvailable();
|
||||
if (!available) {
|
||||
throw Exception('Aucune empreinte/biométrie disponible sur cet appareil.');
|
||||
}
|
||||
}
|
||||
|
||||
await _store.setSetting(_biometricEnabledKey, enabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
Future<bool> authenticateWithBiometrics() async {
|
||||
final enabled = await isBiometricEnabled();
|
||||
if (!enabled) return false;
|
||||
|
||||
try {
|
||||
return await _localAuth.authenticate(
|
||||
localizedReason: 'Déverrouiller FIN65',
|
||||
biometricOnly: true,
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _hashPin(String pin) {
|
||||
return sha256.convert(utf8.encode('$_salt::$pin')).toString();
|
||||
}
|
||||
|
||||
void _validatePin(String pin) {
|
||||
if (!_isValidPinShape(pin)) {
|
||||
throw Exception('Le PIN doit contenir 4 Ã 8 chiffres.');
|
||||
}
|
||||
}
|
||||
|
||||
bool _isValidPinShape(String pin) {
|
||||
return RegExp(r'^\d{4,8}$').hasMatch(pin);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
import 'offline_store.dart';
|
||||
|
||||
class SecurityService {
|
||||
SecurityService({
|
||||
OfflineStore? store,
|
||||
LocalAuthentication? localAuth,
|
||||
}) : _store = store ?? OfflineStore.instance,
|
||||
_localAuth = localAuth ?? LocalAuthentication();
|
||||
|
||||
final OfflineStore _store;
|
||||
final LocalAuthentication _localAuth;
|
||||
|
||||
static const String _pinEnabledKey = 'security_pin_enabled';
|
||||
static const String _pinHashKey = 'security_pin_hash';
|
||||
static const String _biometricEnabledKey = 'security_biometric_enabled';
|
||||
static const String _salt = 'FIN65_LOCAL_PIN_V1';
|
||||
|
||||
Future<bool> isPinEnabled() async {
|
||||
final enabled = await _store.getSetting(_pinEnabledKey);
|
||||
final hash = await _store.getSetting(_pinHashKey);
|
||||
return enabled == 'true' && hash != null && hash.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<void> setPin(String pin) async {
|
||||
_validatePin(pin);
|
||||
await _store.setSetting(_pinHashKey, _hashPin(pin));
|
||||
await _store.setSetting(_pinEnabledKey, 'true');
|
||||
}
|
||||
|
||||
Future<bool> verifyPin(String pin) async {
|
||||
if (!_isValidPinShape(pin)) return false;
|
||||
final storedHash = await _store.getSetting(_pinHashKey);
|
||||
if (storedHash == null || storedHash.isEmpty) return false;
|
||||
return storedHash == _hashPin(pin);
|
||||
}
|
||||
|
||||
Future<void> disablePin() async {
|
||||
await _store.setSetting(_pinHashKey, '');
|
||||
await _store.setSetting(_pinEnabledKey, 'false');
|
||||
await setBiometricEnabled(false);
|
||||
}
|
||||
|
||||
Future<bool> isBiometricAvailable() async {
|
||||
try {
|
||||
final supported = await _localAuth.isDeviceSupported();
|
||||
final canCheck = await _localAuth.canCheckBiometrics;
|
||||
final biometrics = await _localAuth.getAvailableBiometrics();
|
||||
return supported && canCheck && biometrics.isNotEmpty;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<BiometricType>> getAvailableBiometrics() async {
|
||||
try {
|
||||
return await _localAuth.getAvailableBiometrics();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isBiometricEnabled() async {
|
||||
final enabled = await _store.getSetting(_biometricEnabledKey);
|
||||
final pinEnabled = await isPinEnabled();
|
||||
return pinEnabled && enabled == 'true';
|
||||
}
|
||||
|
||||
Future<void> setBiometricEnabled(bool enabled) async {
|
||||
if (enabled) {
|
||||
final pinEnabled = await isPinEnabled();
|
||||
if (!pinEnabled) {
|
||||
throw Exception('Active d’abord un PIN avant la biométrie.');
|
||||
}
|
||||
|
||||
final available = await isBiometricAvailable();
|
||||
if (!available) {
|
||||
throw Exception('Aucune empreinte/biométrie disponible sur cet appareil.');
|
||||
}
|
||||
}
|
||||
|
||||
await _store.setSetting(_biometricEnabledKey, enabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
Future<bool> authenticateWithBiometrics() async {
|
||||
final enabled = await isBiometricEnabled();
|
||||
if (!enabled) return false;
|
||||
|
||||
try {
|
||||
return await _localAuth.authenticate(
|
||||
localizedReason: 'Déverrouiller FIN65',
|
||||
options: const AuthenticationOptions(
|
||||
biometricOnly: true,
|
||||
stickyAuth: true,
|
||||
useErrorDialogs: true,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _hashPin(String pin) {
|
||||
return sha256.convert(utf8.encode('$_salt::$pin')).toString();
|
||||
}
|
||||
|
||||
void _validatePin(String pin) {
|
||||
if (!_isValidPinShape(pin)) {
|
||||
throw Exception('Le PIN doit contenir 4 à 8 chiffres.');
|
||||
}
|
||||
}
|
||||
|
||||
bool _isValidPinShape(String pin) {
|
||||
return RegExp(r'^\d{4,8}$').hasMatch(pin);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
import 'offline_store.dart';
|
||||
|
||||
class SecurityService {
|
||||
SecurityService({
|
||||
OfflineStore? store,
|
||||
LocalAuthentication? localAuth,
|
||||
}) : _store = store ?? OfflineStore.instance,
|
||||
_localAuth = localAuth ?? LocalAuthentication();
|
||||
|
||||
final OfflineStore _store;
|
||||
final LocalAuthentication _localAuth;
|
||||
|
||||
static const String _pinEnabledKey = 'security_pin_enabled';
|
||||
static const String _pinHashKey = 'security_pin_hash';
|
||||
static const String _biometricEnabledKey = 'security_biometric_enabled';
|
||||
static const String _salt = 'FIN65_LOCAL_PIN_V1';
|
||||
|
||||
Future<bool> isPinEnabled() async {
|
||||
final enabled = await _store.getSetting(_pinEnabledKey);
|
||||
final hash = await _store.getSetting(_pinHashKey);
|
||||
return enabled == 'true' && hash != null && hash.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<void> setPin(String pin) async {
|
||||
_validatePin(pin);
|
||||
await _store.setSetting(_pinHashKey, _hashPin(pin));
|
||||
await _store.setSetting(_pinEnabledKey, 'true');
|
||||
}
|
||||
|
||||
Future<bool> verifyPin(String pin) async {
|
||||
if (!_isValidPinShape(pin)) return false;
|
||||
final storedHash = await _store.getSetting(_pinHashKey);
|
||||
if (storedHash == null || storedHash.isEmpty) return false;
|
||||
return storedHash == _hashPin(pin);
|
||||
}
|
||||
|
||||
Future<void> disablePin() async {
|
||||
await _store.setSetting(_pinHashKey, '');
|
||||
await _store.setSetting(_pinEnabledKey, 'false');
|
||||
await setBiometricEnabled(false);
|
||||
}
|
||||
|
||||
Future<bool> isBiometricAvailable() async {
|
||||
try {
|
||||
final supported = await _localAuth.isDeviceSupported();
|
||||
final canCheck = await _localAuth.canCheckBiometrics;
|
||||
final biometrics = await _localAuth.getAvailableBiometrics();
|
||||
return supported && canCheck && biometrics.isNotEmpty;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<BiometricType>> getAvailableBiometrics() async {
|
||||
try {
|
||||
return await _localAuth.getAvailableBiometrics();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isBiometricEnabled() async {
|
||||
final enabled = await _store.getSetting(_biometricEnabledKey);
|
||||
final pinEnabled = await isPinEnabled();
|
||||
return pinEnabled && enabled == 'true';
|
||||
}
|
||||
|
||||
Future<void> setBiometricEnabled(bool enabled) async {
|
||||
if (enabled) {
|
||||
final pinEnabled = await isPinEnabled();
|
||||
if (!pinEnabled) {
|
||||
throw Exception('Active d’abord un PIN avant la biométrie.');
|
||||
}
|
||||
|
||||
final available = await isBiometricAvailable();
|
||||
if (!available) {
|
||||
throw Exception('Aucune empreinte/biométrie disponible sur cet appareil.');
|
||||
}
|
||||
}
|
||||
|
||||
await _store.setSetting(_biometricEnabledKey, enabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
Future<bool> authenticateWithBiometrics() async {
|
||||
final enabled = await isBiometricEnabled();
|
||||
if (!enabled) return false;
|
||||
|
||||
try {
|
||||
return await _localAuth.authenticate(
|
||||
localizedReason: 'Déverrouiller FIN65',
|
||||
biometricOnly: true,
|
||||
stickyAuth: true,
|
||||
useErrorDialogs: true,
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _hashPin(String pin) {
|
||||
return sha256.convert(utf8.encode('$_salt::$pin')).toString();
|
||||
}
|
||||
|
||||
void _validatePin(String pin) {
|
||||
if (!_isValidPinShape(pin)) {
|
||||
throw Exception('Le PIN doit contenir 4 Ã 8 chiffres.');
|
||||
}
|
||||
}
|
||||
|
||||
bool _isValidPinShape(String pin) {
|
||||
return RegExp(r'^\d{4,8}$').hasMatch(pin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import '../config/app_config.dart';
|
||||
import '../models/account.dart';
|
||||
import '../models/category.dart';
|
||||
import '../models/dashboard.dart';
|
||||
import '../models/pending_operation.dart';
|
||||
import '../models/transaction_item.dart';
|
||||
import 'api_service.dart';
|
||||
import 'offline_store.dart';
|
||||
|
||||
class SyncSnapshot {
|
||||
final DashboardData? dashboard;
|
||||
final List<Account> accounts;
|
||||
final List<Category> categories;
|
||||
final List<TransactionItem> transactions;
|
||||
final int pendingCount;
|
||||
final DateTime? lastSyncAt;
|
||||
final bool isOnline;
|
||||
final String? message;
|
||||
|
||||
SyncSnapshot({
|
||||
required this.dashboard,
|
||||
required this.accounts,
|
||||
required this.categories,
|
||||
required this.transactions,
|
||||
required this.pendingCount,
|
||||
required this.lastSyncAt,
|
||||
required this.isOnline,
|
||||
this.message,
|
||||
});
|
||||
}
|
||||
|
||||
class SyncService {
|
||||
SyncService({
|
||||
ApiService? apiService,
|
||||
OfflineStore? offlineStore,
|
||||
}) : store = offlineStore ?? OfflineStore.instance,
|
||||
apiService = apiService ?? ApiService(store: offlineStore ?? OfflineStore.instance);
|
||||
|
||||
final OfflineStore store;
|
||||
final ApiService apiService;
|
||||
|
||||
Future<List<PendingOperation>> loadPendingOperations() {
|
||||
return store.loadPendingOperations();
|
||||
}
|
||||
|
||||
Future<String> getApiUrl() {
|
||||
return apiService.getBaseUrl();
|
||||
}
|
||||
|
||||
Future<bool> testBackend() async {
|
||||
try {
|
||||
return await apiService.healthCheck();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<SyncSnapshot> loadLocalSnapshot({bool isOnline = false, String? message}) async {
|
||||
final dashboard = await store.loadDashboard();
|
||||
final accounts = await store.loadAccounts();
|
||||
final categories = await store.loadCategories();
|
||||
final transactions = await store.loadTransactions(limit: AppConfig.defaultTransactionLimit);
|
||||
final pending = await store.loadPendingOperations();
|
||||
final lastSyncAt = await store.getLastSyncAt();
|
||||
|
||||
return _applyPendingEstimates(
|
||||
dashboard: dashboard,
|
||||
accounts: accounts,
|
||||
categories: categories,
|
||||
transactions: transactions,
|
||||
pending: pending,
|
||||
lastSyncAt: lastSyncAt,
|
||||
isOnline: isOnline,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
Future<SyncSnapshot> synchronize() async {
|
||||
try {
|
||||
await apiService.healthCheck();
|
||||
await _syncPendingOperations();
|
||||
|
||||
final accounts = await apiService.getAccounts();
|
||||
final categories = await apiService.getCategories();
|
||||
final dashboard = await apiService.getDashboard();
|
||||
final transactions = await apiService.getTransactions(limit: AppConfig.defaultTransactionLimit);
|
||||
|
||||
await store.saveServerSnapshot(
|
||||
dashboard: dashboard,
|
||||
accounts: accounts,
|
||||
categories: categories,
|
||||
transactions: transactions,
|
||||
);
|
||||
|
||||
return loadLocalSnapshot(isOnline: true, message: null);
|
||||
} catch (e) {
|
||||
return loadLocalSnapshot(
|
||||
isOnline: false,
|
||||
message: 'Backend indisponible. Données locales affichées.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> createTransactionSmart({
|
||||
required String label,
|
||||
required double amount,
|
||||
required String type,
|
||||
required int accountId,
|
||||
int? targetAccountId,
|
||||
int? categoryId,
|
||||
String? note,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{
|
||||
'date': DateTime.now().toIso8601String(),
|
||||
'label': label,
|
||||
'amount': amount,
|
||||
'type': type,
|
||||
'account_id': accountId,
|
||||
'target_account_id': type == 'transfer' ? targetAccountId : null,
|
||||
'category_id': type == 'transfer' ? null : categoryId,
|
||||
'created_from': 'android',
|
||||
'note': note,
|
||||
};
|
||||
|
||||
try {
|
||||
await apiService.healthCheck();
|
||||
await apiService.createTransactionFromPayload(payload);
|
||||
await synchronize();
|
||||
return true;
|
||||
} catch (_) {
|
||||
// Offline or backend unreachable: the operation is persisted locally in
|
||||
// SQLite. The UI can then immediately reload an estimated snapshot.
|
||||
await store.enqueueCreateTransaction(payload);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncPendingOperations() async {
|
||||
final pending = await store.loadPendingOperations();
|
||||
|
||||
for (final operation in pending) {
|
||||
try {
|
||||
if (operation.operationType == 'create_transaction') {
|
||||
await apiService.createTransactionFromPayload(operation.payload);
|
||||
await store.deletePendingOperation(operation.id);
|
||||
}
|
||||
} catch (e) {
|
||||
await store.markPendingError(operation.id, e.toString());
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SyncSnapshot _applyPendingEstimates({
|
||||
required DashboardData? dashboard,
|
||||
required List<Account> accounts,
|
||||
required List<Category> categories,
|
||||
required List<TransactionItem> transactions,
|
||||
required List<PendingOperation> pending,
|
||||
required DateTime? lastSyncAt,
|
||||
required bool isOnline,
|
||||
String? message,
|
||||
}) {
|
||||
final accountMap = <int, Account>{
|
||||
for (final account in accounts) account.id: account,
|
||||
};
|
||||
|
||||
var adjustedDashboard = dashboard;
|
||||
final pendingTransactions = <TransactionItem>[];
|
||||
|
||||
for (final operation in pending) {
|
||||
if (operation.operationType != 'create_transaction') continue;
|
||||
|
||||
final item = TransactionItem.fromPending(
|
||||
localOperationId: operation.id,
|
||||
payload: operation.payload,
|
||||
);
|
||||
pendingTransactions.add(item);
|
||||
|
||||
final amount = item.amount;
|
||||
final source = accountMap[item.accountId];
|
||||
|
||||
if (item.type == 'expense') {
|
||||
if (source != null) {
|
||||
accountMap[item.accountId] = source.copyWith(
|
||||
appBalance: source.appBalance - amount,
|
||||
);
|
||||
}
|
||||
|
||||
adjustedDashboard = _adjustDashboard(
|
||||
adjustedDashboard,
|
||||
totalDelta: -amount,
|
||||
availableDelta: -amount,
|
||||
incomeDelta: 0,
|
||||
expenseDelta: amount,
|
||||
monthlyBalanceDelta: -amount,
|
||||
);
|
||||
} else if (item.type == 'income') {
|
||||
if (source != null) {
|
||||
accountMap[item.accountId] = source.copyWith(
|
||||
appBalance: source.appBalance + amount,
|
||||
);
|
||||
}
|
||||
|
||||
adjustedDashboard = _adjustDashboard(
|
||||
adjustedDashboard,
|
||||
totalDelta: amount,
|
||||
availableDelta: amount,
|
||||
incomeDelta: amount,
|
||||
expenseDelta: 0,
|
||||
monthlyBalanceDelta: amount,
|
||||
);
|
||||
} else if (item.type == 'transfer') {
|
||||
if (source != null) {
|
||||
accountMap[item.accountId] = source.copyWith(
|
||||
appBalance: source.appBalance - amount,
|
||||
);
|
||||
}
|
||||
|
||||
final targetId = item.targetAccountId;
|
||||
if (targetId != null) {
|
||||
final target = accountMap[targetId];
|
||||
if (target != null) {
|
||||
accountMap[targetId] = target.copyWith(
|
||||
appBalance: target.appBalance + amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final adjustedAccounts = accountMap.values.toList()
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
|
||||
final allTransactions = [
|
||||
...pendingTransactions.reversed,
|
||||
...transactions,
|
||||
];
|
||||
|
||||
return SyncSnapshot(
|
||||
dashboard: adjustedDashboard,
|
||||
accounts: adjustedAccounts,
|
||||
categories: categories,
|
||||
transactions: allTransactions,
|
||||
pendingCount: pending.length,
|
||||
lastSyncAt: lastSyncAt,
|
||||
isOnline: isOnline,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
DashboardData? _adjustDashboard(
|
||||
DashboardData? dashboard, {
|
||||
required double totalDelta,
|
||||
required double availableDelta,
|
||||
required double incomeDelta,
|
||||
required double expenseDelta,
|
||||
required double monthlyBalanceDelta,
|
||||
}) {
|
||||
if (dashboard == null) return null;
|
||||
|
||||
return dashboard.copyWith(
|
||||
totalBalance: dashboard.totalBalance + totalDelta,
|
||||
availableAfterReserves: dashboard.availableAfterReserves + availableDelta,
|
||||
monthlyIncome: dashboard.monthlyIncome + incomeDelta,
|
||||
monthlyExpenses: dashboard.monthlyExpenses + expenseDelta,
|
||||
monthlyBalance: dashboard.monthlyBalance + monthlyBalanceDelta,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user