import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/api/sse_frame_accumulator.dart'; import 'package:miaoji_zhang/shared/services/current_ledger_store.dart'; import 'package:miaoji_zhang/shared/services/local_database.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; import 'package:shared_preferences/shared_preferences.dart'; class TxItem { final int id, ledgerId, categoryId; final String categoryName, categoryIcon, categoryColor, type, source; final double amount; final String? note, paymentMethod, sourceText; final DateTime occurredAt, updatedAt; final bool isDeleted; TxItem.fromJson(Map j) : id = (j['id'] as num).toInt(), ledgerId = (j['ledgerId'] as num).toInt(), categoryId = (j['categoryId'] as num).toInt(), categoryName = j['categoryName'] as String, categoryIcon = j['categoryIcon'] as String, categoryColor = j['categoryColor'] as String? ?? 'mint', type = j['type'] as String, amount = (j['amount'] as num).toDouble(), note = j['note'] as String?, paymentMethod = j['paymentMethod'] as String?, source = j['source'] as String, sourceText = j['sourceText'] as String?, occurredAt = ShanghaiTime.parseCivil(j['occurredAt'] as String), updatedAt = DateTime.parse( j['updatedAt'] as String? ?? j['occurredAt'] as String, ).toUtc(), isDeleted = j['isDeleted'] as bool? ?? false; bool get isAi => const { 'ai_chat', 'voice', 'ocr', 'screenshot', 'recognition_ai', }.contains(source); bool get isRecognition => source == 'accessibility' || source == 'notification' || source == 'local_ocr'; bool get isIncome => type == 'income'; String get sourceLabel => switch (source) { 'ai_chat' => 'AI 聊天', 'voice' => 'AI 语音解析', 'ocr' => 'AI 图片识别', 'screenshot' => 'AI 截屏识别', 'recognition_ai' => '智能识别 · AI 补全', 'accessibility' => '智能识别 · 页面事件', 'notification' => '智能识别 · 通知', 'local_ocr' => '智能识别 · 本地视觉', _ => '手动记账', }; } class DayGroup { final DateTime date; final double expense, income; final List items; DayGroup.fromJson(Map j) : date = DateTime.parse(j['date'] as String), expense = (j['expense'] as num).toDouble(), income = (j['income'] as num).toDouble(), items = (j['items'] as List) .map((e) => TxItem.fromJson(e as Map)) .toList(); } class MonthSummary { final int year, month, count; final double income, expense, balance; final List days; MonthSummary.fromJson(Map j) : year = j['year'] as int, month = j['month'] as int, count = j['count'] as int, income = (j['income'] as num).toDouble(), expense = (j['expense'] as num).toDouble(), balance = (j['balance'] as num).toDouble(), days = (j['days'] as List) .map((e) => DayGroup.fromJson(e as Map)) .toList(); } class CategoryItem { final int id, sortOrder; final String name, iconKey, colorKey, type; final bool isCustom; CategoryItem.fromJson(Map j) : id = (j['id'] as num).toInt(), name = j['name'] as String, iconKey = j['iconKey'] as String, colorKey = j['colorKey'] as String? ?? 'mint', type = j['type'] as String, sortOrder = j['sortOrder'] as int, isCustom = j['isCustom'] as bool; } class CategoryStat { final int categoryId; final String name, iconKey, colorKey; final double amount, percent; CategoryStat.fromJson(Map j) : categoryId = (j['categoryId'] as num).toInt(), name = j['name'] as String, iconKey = j['iconKey'] as String, colorKey = j['colorKey'] as String? ?? 'mint', amount = (j['amount'] as num).toDouble(), percent = (j['percent'] as num).toDouble(); } class MonthStats { final int year, month; final double totalExpense, totalIncome; final List byCategory; final List dailyExpenses; final String? aiAnalysis; MonthStats.fromJson(Map j) : year = j['year'] as int, month = j['month'] as int, totalExpense = (j['totalExpense'] as num).toDouble(), totalIncome = (j['totalIncome'] as num).toDouble(), byCategory = (j['byCategory'] as List) .map((e) => CategoryStat.fromJson(e as Map)) .toList(), dailyExpenses = (j['dailyExpenses'] as List) .map((e) => (e as num).toDouble()) .toList(), aiAnalysis = j['aiAnalysis'] as String?; } class PeriodTrendPoint { final String label; final DateTime date; final double expense, income; PeriodTrendPoint.fromJson(Map json) : label = json['label'] as String, date = DateTime.parse(json['date'] as String), expense = (json['expense'] as num).toDouble(), income = (json['income'] as num).toDouble(); } class PeriodStats { final String periodType, periodLabel; final DateTime startDate, endDate; final double totalExpense, totalIncome, balance; final int count; final List byCategory; final List trend; final String? analysis; PeriodStats.fromJson(Map json) : periodType = json['periodType'] as String, periodLabel = json['periodLabel'] as String, startDate = DateTime.parse(json['startDate'] as String), endDate = DateTime.parse(json['endDate'] as String), totalExpense = (json['totalExpense'] as num).toDouble(), totalIncome = (json['totalIncome'] as num).toDouble(), balance = (json['balance'] as num).toDouble(), count = (json['count'] as num).toInt(), byCategory = (json['byCategory'] as List) .map((item) => CategoryStat.fromJson(item as Map)) .toList(), trend = (json['trend'] as List) .map( (item) => PeriodTrendPoint.fromJson(item as Map), ) .toList(), analysis = json['analysis'] as String?; } class TxApi { static final _dio = ApiClient.instance.dio; static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1; static bool get _queueOfflineChanges => SessionStore.instance.isAccount && SessionStore.instance.cloudSyncEnabled; static Future month(int year, int month) async { final session = SessionStore.instance; if (session.shouldUseLocalOnly) { return MonthSummary.fromJson( LocalDatabase.instance.monthSummary(year, month, _ledgerId), ); } try { final response = await _dio.get( '/api/transactions/month', queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId}, ); final json = response.data as Map; LocalDatabase.instance.cacheMonthSummary(json); return MonthSummary.fromJson(json); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; return MonthSummary.fromJson( LocalDatabase.instance.monthSummary(year, month, _ledgerId), ); } } static Future stats(int year, int month) async { final session = SessionStore.instance; if (!session.shouldUseLocalOnly) { try { final response = await _dio.get( '/api/transactions/stats', queryParameters: { 'year': year, 'month': month, 'ledgerId': _ledgerId, }, ); return MonthStats.fromJson(response.data as Map); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; } } final local = LocalDatabase.instance.periodStats( 'month', DateTime(year, month, 1), _ledgerId, ); return MonthStats.fromJson({ 'year': year, 'month': month, 'totalExpense': local['totalExpense'], 'totalIncome': local['totalIncome'], 'byCategory': local['byCategory'], 'dailyExpenses': (local['trend'] as List) .map((item) => (item as Map)['expense']) .toList(), 'aiAnalysis': local['analysis'], }); } static Future periodStats(String period, DateTime anchor) async { final session = SessionStore.instance; if (session.shouldUseLocalOnly) { return PeriodStats.fromJson( LocalDatabase.instance.periodStats(period, anchor, _ledgerId), ); } try { final response = await _dio.get( '/api/transactions/stats/period', queryParameters: { 'period': period, 'anchor': '${anchor.year.toString().padLeft(4, '0')}-' '${anchor.month.toString().padLeft(2, '0')}-' '${anchor.day.toString().padLeft(2, '0')}', 'ledgerId': _ledgerId, }, ); return PeriodStats.fromJson(response.data as Map); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; return PeriodStats.fromJson( LocalDatabase.instance.periodStats(period, anchor, _ledgerId), ); } } static Future> categories(String type) async { final session = SessionStore.instance; if (session.shouldUseLocalOnly) { return LocalDatabase.instance .categories(type) .map(CategoryItem.fromJson) .toList(); } try { final response = await _dio.get( '/api/categories', queryParameters: {'type': type}, ); final values = response.data as List; LocalDatabase.instance.cacheCategories(values, replaceType: type); return values .map((item) => CategoryItem.fromJson(item as Map)) .toList(); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; return LocalDatabase.instance .categories(type) .map(CategoryItem.fromJson) .toList(); } } static Future create({ required int categoryId, required String type, required double amount, String? note, String? paymentMethod, String? source, String? sourceText, DateTime? occurredAt, String? clientRequestId, }) async { final payload = { 'ledgerId': _ledgerId, 'categoryId': categoryId, 'type': type, 'amount': amount, 'note': note, 'paymentMethod': paymentMethod, 'source': source, 'sourceText': sourceText, 'occurredAt': ShanghaiTime.civilToUtc( occurredAt ?? ShanghaiTime.now, ).toIso8601String(), if (clientRequestId != null) 'clientRequestId': clientRequestId, }; final session = SessionStore.instance; if (session.shouldUseLocalOnly || categoryId < 0 || _ledgerId < 0) { final local = LocalDatabase.instance.createTransaction(payload); if (_queueOfflineChanges) { LocalDatabase.instance.enqueueSync( 'transaction', local['id'] as int, 'create', payload, ); } return TxItem.fromJson(local); } try { final response = await _dio.post('/api/transactions', data: payload); final json = response.data as Map; LocalDatabase.instance.cacheTransaction(json); return TxItem.fromJson(json); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; final local = LocalDatabase.instance.createTransaction(payload); LocalDatabase.instance.enqueueSync( 'transaction', local['id'] as int, 'create', payload, ); return TxItem.fromJson(local); } } static Future delete(int id) async { final baseUpdatedAt = LocalDatabase.instance.transaction( id, includeDeleted: true, )?['updatedAt']; final session = SessionStore.instance; if (session.shouldUseLocalOnly || id < 0) { LocalDatabase.instance.softDeleteTransaction(id); if (_queueOfflineChanges) { LocalDatabase.instance.enqueueSync('transaction', id, 'delete', { 'id': id, 'baseUpdatedAt': baseUpdatedAt, }); } return; } try { await _dio.delete('/api/transactions/$id'); LocalDatabase.instance.softDeleteTransaction(id); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; LocalDatabase.instance.softDeleteTransaction(id); LocalDatabase.instance.enqueueSync('transaction', id, 'delete', { 'id': id, }); } } static Future update( int id, { required int ledgerId, required int categoryId, required String type, required double amount, required DateTime occurredAt, String? note, String? paymentMethod, }) async { final baseUpdatedAt = LocalDatabase.instance.transaction( id, includeDeleted: true, )?['updatedAt']; final payload = { 'ledgerId': ledgerId, 'categoryId': categoryId, 'type': type, 'amount': amount, 'note': note, 'paymentMethod': paymentMethod, 'occurredAt': ShanghaiTime.civilToUtc(occurredAt).toIso8601String(), if (baseUpdatedAt != null) 'baseUpdatedAt': baseUpdatedAt, }; final session = SessionStore.instance; if (session.shouldUseLocalOnly || id < 0 || categoryId < 0 || ledgerId < 0) { final local = LocalDatabase.instance.updateTransaction(id, payload); if (_queueOfflineChanges) { LocalDatabase.instance.enqueueSync( 'transaction', id, 'update', payload, ); } return TxItem.fromJson(local); } try { final response = await _dio.put('/api/transactions/$id', data: payload); final json = response.data as Map; LocalDatabase.instance.cacheTransaction(json); return TxItem.fromJson(json); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; final local = LocalDatabase.instance.updateTransaction(id, payload); LocalDatabase.instance.enqueueSync('transaction', id, 'update', payload); return TxItem.fromJson(local); } } static Future> recycleBin() async { final session = SessionStore.instance; if (session.shouldUseLocalOnly) { return LocalDatabase.instance .recycleBin(_ledgerId) .map(TxItem.fromJson) .toList(); } try { final response = await _dio.get( '/api/transactions/recycle-bin', queryParameters: {'ledgerId': _ledgerId}, ); final values = response.data as List; LocalDatabase.instance.cacheTransactions(values); return values .map((item) => TxItem.fromJson(item as Map)) .toList(); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; return LocalDatabase.instance .recycleBin(_ledgerId) .map(TxItem.fromJson) .toList(); } } static Future restore(int id) async { final baseUpdatedAt = LocalDatabase.instance.transaction( id, includeDeleted: true, )?['updatedAt']; final session = SessionStore.instance; if (session.shouldUseLocalOnly || id < 0) { final local = LocalDatabase.instance.restoreTransaction(id); if (_queueOfflineChanges) { LocalDatabase.instance.enqueueSync('transaction', id, 'restore', { 'id': id, 'baseUpdatedAt': baseUpdatedAt, }); } return TxItem.fromJson(local); } try { final response = await _dio.post('/api/transactions/$id/restore'); final json = response.data as Map; LocalDatabase.instance.cacheTransaction(json); return TxItem.fromJson(json); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; final local = LocalDatabase.instance.restoreTransaction(id); LocalDatabase.instance.enqueueSync('transaction', id, 'restore', { 'id': id, }); return TxItem.fromJson(local); } } static Future permanentlyDelete(int id) async { if (SessionStore.instance.shouldUseLocalOnly || id < 0) { LocalDatabase.instance.permanentlyDeleteTransaction(id); return; } await _dio.delete('/api/transactions/$id/permanent'); LocalDatabase.instance.permanentlyDeleteTransaction(id); } static Future clearRecycleBin() async { if (SessionStore.instance.shouldUseLocalOnly) { LocalDatabase.instance.clearRecycleBin(_ledgerId); return; } await _dio.delete( '/api/transactions/recycle-bin', queryParameters: {'ledgerId': _ledgerId}, ); LocalDatabase.instance.clearRecycleBin(_ledgerId); } } class ChatMsg { final int id; final String role, type, content; final TxItem? transaction; final DateTime createdAt; ChatMsg.fromJson(Map j) : id = (j['id'] as num).toInt(), role = j['role'] as String, type = j['type'] as String, content = j['content'] as String, transaction = j['transaction'] == null ? null : TxItem.fromJson(j['transaction'] as Map), createdAt = ShanghaiTime.parseCivil(j['createdAt'] as String); } class ChatApi { static final _dio = ApiClient.instance.dio; static Future> history({int limit = 50}) async { SessionStore.instance.requireOnline('AI 聊天需要登录并连接网络'); final res = await _dio.get( '/api/chat/messages', queryParameters: {'limit': limit}, ); return (res.data as List) .map((e) => ChatMsg.fromJson(e as Map)) .toList(); } static Future clearContext() async { SessionStore.instance.requireOnline('AI 聊天需要登录并连接网络'); await _dio.post('/api/chat/context/clear'); } static Future> send( String content, { String type = 'text', }) async { SessionStore.instance.requireOnline('AI 聊天需要登录并连接网络'); final res = await _dio.post( '/api/chat/messages', data: { 'content': content, 'type': type, if (CurrentLedgerStore.instance.currentId case final id?) 'ledgerId': id, }, ); return (res.data['messages'] as List) .map((e) => ChatMsg.fromJson(e as Map)) .toList(); } static Future sendStream( String content, { required void Function(String full) onToken, void Function(List msgs)? onDone, void Function(String err)? onError, }) async { SessionStore.instance.requireOnline('AI 聊天需要登录并连接网络'); try { final resp = await _dio.post( '/api/chat/messages/stream', data: { 'content': content, if (CurrentLedgerStore.instance.currentId case final id?) 'ledgerId': id, }, options: Options( responseType: ResponseType.stream, receiveTimeout: const Duration(minutes: 3), headers: {'Accept': 'text/event-stream'}, ), ); final stream = (resp.data as ResponseBody).stream.map>( (bytes) => bytes, ); final completer = Completer(); final frames = SseFrameAccumulator(); var fullText = ''; int? messageId; String? streamError; List? completedMessages; void handleEvent(String event) { final parsed = _parseSseEvent(event, fullText); fullText = parsed.$1; if (parsed.$2) onToken(fullText); if (parsed.$3 != null) messageId = parsed.$3; if (parsed.$5 != null) completedMessages = parsed.$5; if (parsed.$4 != null) { streamError = parsed.$4; onError?.call(parsed.$4!); } } stream .transform(const Utf8Decoder(allowMalformed: true)) .listen( (chunk) { for (final event in frames.add(chunk)) { handleEvent(event); } }, onDone: () { for (final event in frames.close()) { handleEvent(event); } if (!completer.isCompleted) completer.complete(); }, onError: (e) { onError?.call(e.toString()); if (!completer.isCompleted) completer.completeError(e); }, ); await completer.future; if (completedMessages != null) { onDone?.call(completedMessages!); return; } if (fullText.isEmpty) { if (streamError == null) onError?.call('未收到 AI 回复,请稍后重试'); return; } final message = ChatMsg.fromJson({ 'id': messageId ?? -1, 'role': 'assistant', 'type': 'text', 'content': fullText, 'transaction': null, 'createdAt': DateTime.now().toUtc().toIso8601String(), }); onDone?.call([message]); } catch (e) { onError?.call(e.toString()); } } static (String, bool, int?, String?, List?) _parseSseEvent( String event, String currentText, ) { final payload = StringBuffer(); for (final rawLine in event.split('\n')) { final line = rawLine.trimRight(); if (line.startsWith('data:')) { payload.write(line.substring(5).trimLeft()); } } if (payload.isEmpty) return (currentText, false, null, null, null); try { final data = jsonDecode(payload.toString()); if (data is Map) { if (data['t'] != null) { return (currentText + data['t'].toString(), true, null, null, null); } if (data['done'] != null) { var full = currentText; int? id; List? messages; final rawMessages = data['messages']; if (rawMessages is List) { messages = rawMessages .map( (item) => ChatMsg.fromJson(Map.from(item as Map)), ) .toList(); for (final message in messages) { if (message.type == 'text' && message.role == 'assistant') { full = message.content; id = message.id; } } } else { final done = data['done']; if (done is bool && done) { if (data['c'] != null) full = data['c'].toString(); id = (data['id'] as num?)?.toInt(); } else { final rawDone = done.toString(); final splitIndex = rawDone.lastIndexOf('|'); if (splitIndex > -1) { full = rawDone.substring(0, splitIndex); id = int.tryParse(rawDone.substring(splitIndex + 1)); if (id == null) full = rawDone; } else { full = rawDone; } } } return (full, false, id, null, messages); } if (data['error'] != null) { return (currentText, false, null, data['error'].toString(), null); } } } catch (_) {} return (currentText, false, null, null, null); } } class BudgetItem { final int? categoryId; final String? categoryName, categoryIcon, categoryColor; final double amount, spent; final bool isRecurring; BudgetItem.fromJson(Map j) : categoryId = (j['categoryId'] as num?)?.toInt(), categoryName = j['categoryName'] as String?, categoryIcon = j['categoryIcon'] as String?, categoryColor = j['categoryColor'] as String? ?? 'mint', amount = (j['amount'] as num).toDouble(), spent = (j['spent'] as num).toDouble(), isRecurring = j['isRecurring'] as bool? ?? false; double get ratio => amount == 0 ? 0 : (spent / amount).clamp(0, 1); } class BudgetsData { final BudgetItem? total; final List categories; BudgetsData.fromJson(Map j) : total = j['total'] == null ? null : BudgetItem.fromJson(j['total'] as Map), categories = (j['categories'] as List) .map((e) => BudgetItem.fromJson(e as Map)) .toList(); } class BudgetRecommendationItem { final int categoryId; final String categoryName, categoryIcon, colorKey, confidence; final double suggestedAmount, currentSpent, historicalAverage; BudgetRecommendationItem.fromJson(Map j) : categoryId = (j['categoryId'] as num).toInt(), categoryName = j['categoryName'] as String, categoryIcon = j['categoryIcon'] as String, colorKey = j['colorKey'] as String? ?? 'mint', suggestedAmount = (j['suggestedAmount'] as num).toDouble(), currentSpent = (j['currentSpent'] as num).toDouble(), historicalAverage = (j['historicalAverage'] as num).toDouble(), confidence = j['confidence'] as String; } class BudgetRecommendations { final int year, month; final double suggestedTotal; final String message, adjustmentSummary; final List warnings; final List items; BudgetRecommendations.fromJson(Map j) : year = (j['year'] as num).toInt(), month = (j['month'] as num).toInt(), suggestedTotal = (j['suggestedTotal'] as num).toDouble(), message = j['message'] as String, adjustmentSummary = j['adjustmentSummary'] as String? ?? '', warnings = (j['warnings'] as List? ?? const []) .map((item) => item.toString()) .toList(), items = (j['items'] as List) .map( (e) => BudgetRecommendationItem.fromJson(e as Map), ) .toList(); } class BudgetRefinementTurn { final String role, content; const BudgetRefinementTurn({required this.role, required this.content}); Map toJson() => {'role': role, 'content': content}; } class BudgetApi { static final _dio = ApiClient.instance.dio; static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1; static Future get(int year, int month) async { final session = SessionStore.instance; if (session.shouldUseLocalOnly) { return BudgetsData.fromJson( LocalDatabase.instance.budgets(year, month, _ledgerId), ); } try { final response = await _dio.get( '/api/budgets', queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId}, ); return BudgetsData.fromJson(response.data as Map); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; return BudgetsData.fromJson( LocalDatabase.instance.budgets(year, month, _ledgerId), ); } } static Future upsert( int year, int month, { int? categoryId, required double amount, bool recurring = false, }) async { final payload = { 'categoryId': categoryId, 'amount': amount, 'recurring': recurring, }; final session = SessionStore.instance; if (session.shouldUseLocalOnly || (categoryId ?? 0) < 0 || _ledgerId < 0) { LocalDatabase.instance.upsertBudget( year, month, _ledgerId, categoryId, amount, recurring, ); if (session.isAccount && session.cloudSyncEnabled) { LocalDatabase.instance.enqueueSync( 'budget', categoryId ?? 0, 'upsert', {...payload, 'year': year, 'month': month, 'ledgerId': _ledgerId}, ); } return; } try { await _dio.put( '/api/budgets', queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId}, data: payload, ); LocalDatabase.instance.upsertBudget( year, month, _ledgerId, categoryId, amount, recurring, ); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; LocalDatabase.instance.upsertBudget( year, month, _ledgerId, categoryId, amount, recurring, ); LocalDatabase.instance.enqueueSync('budget', categoryId ?? 0, 'upsert', { ...payload, 'year': year, 'month': month, 'ledgerId': _ledgerId, }); } } static Future recommendations( int year, int month, ) async { SessionStore.instance.requireOnline('AI 预算建议需要登录并连接网络'); final response = await _dio.get( '/api/budgets/recommendations', queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId}, ); return BudgetRecommendations.fromJson( response.data as Map, ); } static Future refineRecommendation( int year, int month, { required String instruction, required double suggestedTotal, required Map amounts, required List history, }) async { SessionStore.instance.requireOnline('调整 AI 预算建议需要登录并连接网络'); final response = await _dio.post( '/api/budgets/recommendations/refine', data: { 'ledgerId': _ledgerId, 'year': year, 'month': month, 'instruction': instruction, 'currentDraft': { 'suggestedTotal': suggestedTotal, 'items': amounts.entries .map((entry) => {'categoryId': entry.key, 'amount': entry.value}) .toList(), }, 'history': history.take(12).map((turn) => turn.toJson()).toList(), }, ); return BudgetRecommendations.fromJson( response.data as Map, ); } static Future applyBatch( int year, int month, { required bool recurring, required Map amounts, }) async { final session = SessionStore.instance; if (session.shouldUseLocalOnly || _ledgerId < 0) { LocalDatabase.instance.applyBudgets( year, month, _ledgerId, recurring, amounts, ); if (session.isAccount && session.cloudSyncEnabled) { LocalDatabase.instance.enqueueSync('budget', 0, 'batch', { 'year': year, 'month': month, 'ledgerId': _ledgerId, 'recurring': recurring, 'items': amounts.entries .map((entry) => {'categoryId': entry.key, 'amount': entry.value}) .toList(), }); } return; } await _dio.put( '/api/budgets/batch', queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId}, data: { 'recurring': recurring, 'items': amounts.entries .map((entry) => {'categoryId': entry.key, 'amount': entry.value}) .toList(), }, ); LocalDatabase.instance.applyBudgets( year, month, _ledgerId, recurring, amounts, ); } } class ReportCategoryRank { final String name; final String iconKey; final String colorKey; final double amount; final double percent; const ReportCategoryRank({ required this.name, required this.iconKey, required this.colorKey, required this.amount, required this.percent, }); factory ReportCategoryRank.fromJson(Map json) => ReportCategoryRank( name: json['name'] as String, iconKey: json['iconKey'] as String? ?? 'tag', colorKey: json['colorKey'] as String? ?? 'mint', amount: (json['amount'] as num).toDouble(), percent: (json['percent'] as num).toDouble(), ); static List fromReport(Map json) { final values = (json['categoryRanking'] as List? ?? const []) .map( (item) => ReportCategoryRank.fromJson(item as Map), ) .toList(); if (values.isNotEmpty || json['topCategory'] == null) return values; return [ ReportCategoryRank( name: json['topCategory'] as String, iconKey: 'tag', colorKey: 'mint', amount: (json['topCategoryAmount'] as num).toDouble(), percent: (json['topCategoryPercent'] as num).toDouble(), ), ]; } } class MonthlyReport { final int year, month, count; final double income, expense, balance, aiRatio, topDayAmount, topCategoryAmount, topCategoryPercent; final String? topDay, topDayNote, topCategory; final List categoryRanking; final String roast; MonthlyReport.fromJson(Map j) : year = j['year'] as int, month = j['month'] as int, count = j['count'] as int, income = (j['income'] as num).toDouble(), expense = (j['expense'] as num).toDouble(), balance = (j['balance'] as num).toDouble(), aiRatio = (j['aiRatio'] as num).toDouble(), topDay = j['topDay'] as String?, topDayAmount = (j['topDayAmount'] as num).toDouble(), topDayNote = j['topDayNote'] as String?, topCategory = j['topCategory'] as String?, topCategoryAmount = (j['topCategoryAmount'] as num).toDouble(), topCategoryPercent = (j['topCategoryPercent'] as num).toDouble(), categoryRanking = ReportCategoryRank.fromReport(j), roast = j['roast'] as String; } class PeriodReport { final String periodType, periodLabel; final DateTime startDate, endDate; final int count; final double income, expense, balance, aiRatio, peakAmount, topCategoryAmount, topCategoryPercent; final String? peakLabel, peakNote, topCategory; final List categoryRanking; final String commentary; PeriodReport.fromJson(Map j) : periodType = j['periodType'] as String, periodLabel = j['periodLabel'] as String, startDate = DateTime.parse(j['startDate'] as String), endDate = DateTime.parse(j['endDate'] as String), count = (j['count'] as num).toInt(), income = (j['income'] as num).toDouble(), expense = (j['expense'] as num).toDouble(), balance = (j['balance'] as num).toDouble(), aiRatio = (j['aiRatio'] as num).toDouble(), peakLabel = j['peakLabel'] as String?, peakAmount = (j['peakAmount'] as num).toDouble(), peakNote = j['peakNote'] as String?, topCategory = j['topCategory'] as String?, topCategoryAmount = (j['topCategoryAmount'] as num).toDouble(), topCategoryPercent = (j['topCategoryPercent'] as num).toDouble(), categoryRanking = ReportCategoryRank.fromReport(j), commentary = j['commentary'] as String; PeriodReport.fromMonthly(MonthlyReport report) : periodType = 'monthly', periodLabel = '${report.year}年${report.month}月', startDate = DateTime(report.year, report.month), endDate = DateTime(report.year, report.month + 1), count = report.count, income = report.income, expense = report.expense, balance = report.balance, aiRatio = report.aiRatio, peakLabel = report.topDay, peakAmount = report.topDayAmount, peakNote = report.topDayNote, topCategory = report.topCategory, topCategoryAmount = report.topCategoryAmount, topCategoryPercent = report.topCategoryPercent, categoryRanking = report.categoryRanking, commentary = report.roast; } class ReportApi { static final _dio = ApiClient.instance.dio; static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1; static Future weekly(DateTime date) async { if (SessionStore.instance.shouldUseLocalOnly) { return _localPeriod('week', date); } try { final value = '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; final response = await _dio.get( '/api/reports/weekly', queryParameters: {'date': value, 'ledgerId': _ledgerId}, ); return PeriodReport.fromJson(response.data as Map); } catch (error) { if (!isConnectivityError(error) || !SessionStore.instance.isAccount) rethrow; return _localPeriod('week', date); } } static Future monthlyPeriod(int year, int month) async { if (SessionStore.instance.shouldUseLocalOnly) { return _localPeriod('month', DateTime(year, month, 1)); } try { return PeriodReport.fromMonthly(await monthly(year, month)); } catch (error) { if (!isConnectivityError(error) || !SessionStore.instance.isAccount) rethrow; return _localPeriod('month', DateTime(year, month, 1)); } } static Future yearly(int year) async { if (SessionStore.instance.shouldUseLocalOnly) { return _localPeriod('year', DateTime(year, 1, 1)); } try { final response = await _dio.get( '/api/reports/yearly', queryParameters: {'year': year, 'ledgerId': _ledgerId}, ); return PeriodReport.fromJson(response.data as Map); } catch (error) { if (!isConnectivityError(error) || !SessionStore.instance.isAccount) rethrow; return _localPeriod('year', DateTime(year, 1, 1)); } } static Future monthly(int year, int month) async { final response = await _dio.get( '/api/reports/monthly', queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId}, ); return MonthlyReport.fromJson(response.data as Map); } static PeriodReport _localPeriod(String period, DateTime anchor) => PeriodReport.fromJson( LocalDatabase.instance.periodReport(period, anchor, _ledgerId), ); } class SearchApi { static final _dio = ApiClient.instance.dio; static Future> search({ String? q, bool aiOnly = false, int? categoryId, String? type, double? minAmount, double? maxAmount, DateTime? from, DateTime? to, DateTime? beforeOccurredAt, int? beforeId, int limit = 50, }) async { final ledgerId = CurrentLedgerStore.instance.currentId ?? 1; final session = SessionStore.instance; if (session.shouldUseLocalOnly) { return LocalDatabase.instance .searchTransactions( ledgerId: ledgerId, query: q, aiOnly: aiOnly, categoryId: categoryId, type: type, minAmount: minAmount, maxAmount: maxAmount, from: from, to: to, beforeOccurredAt: beforeOccurredAt, beforeId: beforeId, limit: limit, ) .map(TxItem.fromJson) .toList(); } try { final response = await _dio.get( '/api/search', queryParameters: { if (q != null && q.isNotEmpty) 'q': q, 'ledgerId': ledgerId, if (categoryId != null) 'categoryId': categoryId, if (type != null) 'type': type, if (aiOnly) 'aiOnly': true, if (minAmount != null) 'minAmount': minAmount, if (maxAmount != null) 'maxAmount': maxAmount, if (from != null) 'from': ShanghaiTime.civilToUtc(from).toIso8601String(), if (to != null) 'to': ShanghaiTime.civilToUtc(to).toIso8601String(), if (beforeOccurredAt != null) 'beforeOccurredAt': ShanghaiTime.civilToUtc( beforeOccurredAt, ).toIso8601String(), if (beforeId != null) 'beforeId': beforeId, 'limit': limit, }, ); final values = response.data as List; LocalDatabase.instance.cacheTransactions(values); return values .map((item) => TxItem.fromJson(item as Map)) .toList(); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; return LocalDatabase.instance .searchTransactions( ledgerId: ledgerId, query: q, aiOnly: aiOnly, categoryId: categoryId, type: type, minAmount: minAmount, maxAmount: maxAmount, from: from, to: to, beforeOccurredAt: beforeOccurredAt, beforeId: beforeId, limit: limit, ) .map(TxItem.fromJson) .toList(); } } } class StickerItem { final String key, label, groupKey; StickerItem.fromJson(Map j) : key = j['key'] as String, label = j['label'] as String, groupKey = j['groupKey'] as String; } class StickerApi { static final _dio = ApiClient.instance.dio; static const _cacheKey = 'global_stickers_v1'; static const _fallback = >[ {'key': 'salary', 'label': '发工资啦', 'groupKey': 'default'}, {'key': 'forgive', 'label': '求原谅', 'groupKey': 'default'}, {'key': 'fire', 'label': '剁手警告', 'groupKey': 'default'}, {'key': 'empty_wallet', 'label': '钱包空空', 'groupKey': 'default'}, {'key': 'angry', 'label': '生气', 'groupKey': 'default'}, {'key': 'happy', 'label': '开心', 'groupKey': 'default'}, {'key': 'treat', 'label': '犒劳自己', 'groupKey': 'default'}, {'key': 'shopping_joy', 'label': '购物狂喜', 'groupKey': 'default'}, ]; static Future> list() async { final preferences = await SharedPreferences.getInstance(); try { final res = await _dio.get('/api/stickers'); final values = (res.data as List) .map((e) => StickerItem.fromJson(e as Map)) .toList(); if (values.isNotEmpty) { await preferences.setString( _cacheKey, jsonEncode( values .map( (item) => { 'key': item.key, 'label': item.label, 'groupKey': item.groupKey, }, ) .toList(), ), ); return values; } } catch (_) {} final cached = preferences.getString(_cacheKey); if (cached != null) { try { final values = (jsonDecode(cached) as List) .map( (item) => StickerItem.fromJson(Map.from(item as Map)), ) .toList(); if (values.isNotEmpty) return values; } catch (_) { await preferences.remove(_cacheKey); } } return _fallback.map(StickerItem.fromJson).toList(); } } class ParsedDraft { final bool matched; final int categoryId; final String categoryName, categoryIcon, note, type; final double amount; final String? paymentMethod; ParsedDraft.fromJson(Map j) : matched = j['matched'] as bool, categoryId = (j['categoryId'] as num).toInt(), categoryName = j['categoryName'] as String, categoryIcon = j['categoryIcon'] as String, amount = (j['amount'] as num).toDouble(), paymentMethod = j['paymentMethod'] as String?, note = j['note'] as String, type = switch (j['type']?.toString().toLowerCase()) { 'income' => 'income', 'expense' => 'expense', _ => 'unknown', }; } class ParseApi { static final _dio = ApiClient.instance.dio; static Future parse(String text, {String? source}) async { SessionStore.instance.requireOnline('AI 文本和语音解析需要登录并连接网络'); final res = await _dio.post( '/api/parse', data: {'text': text, if (source != null) 'source': source}, ); return ParsedDraft.fromJson(res.data as Map); } static Future confirm( ParsedDraft d, String source, String sourceText, ) async { if (d.type != 'income' && d.type != 'expense') { throw StateError('请先确认收入或支出类型'); } return TxApi.create( categoryId: d.categoryId, type: d.type, amount: d.amount, note: d.note, paymentMethod: d.paymentMethod, source: source, sourceText: sourceText, ); } } class CategoryApi { static final _dio = ApiClient.instance.dio; static Future create( String name, String iconKey, String colorKey, String type, ) async { final payload = { 'name': name, 'iconKey': iconKey, 'colorKey': colorKey, 'type': type, }; final session = SessionStore.instance; if (session.shouldUseLocalOnly) { final local = LocalDatabase.instance.createCategory( name, iconKey, colorKey, type, ); if (session.isAccount && session.cloudSyncEnabled) { LocalDatabase.instance.enqueueSync( 'category', local['id'] as int, 'create', payload, ); } return CategoryItem.fromJson(local); } try { final response = await _dio.post('/api/categories', data: payload); final json = response.data as Map; LocalDatabase.instance.cacheCategories([json]); return CategoryItem.fromJson(json); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; final local = LocalDatabase.instance.createCategory( name, iconKey, colorKey, type, ); LocalDatabase.instance.enqueueSync( 'category', local['id'] as int, 'create', payload, ); return CategoryItem.fromJson(local); } } static Future update( int id, { required String name, required String iconKey, required String colorKey, required int sortOrder, }) async { final payload = { 'name': name, 'iconKey': iconKey, 'colorKey': colorKey, 'sortOrder': sortOrder, }; final session = SessionStore.instance; if (session.shouldUseLocalOnly || id < 0) { final local = LocalDatabase.instance.updateCategory( id, name, iconKey, colorKey, sortOrder, ); if (session.isAccount && session.cloudSyncEnabled) { LocalDatabase.instance.enqueueSync('category', id, 'update', payload); } return CategoryItem.fromJson(local); } final response = await _dio.put('/api/categories/$id', data: payload); final json = response.data as Map; LocalDatabase.instance.cacheCategories([json]); return CategoryItem.fromJson(json); } static Future reorder(String type, List categoryIds) async { LocalDatabase.instance.reorderCategories(type, categoryIds); final session = SessionStore.instance; if (session.shouldUseLocalOnly || categoryIds.any((id) => id < 0)) { if (session.isAccount && session.cloudSyncEnabled) { LocalDatabase.instance.enqueueSync('category', 0, 'reorder', { 'type': type, 'categoryIds': categoryIds, }); } return; } await _dio.put( '/api/categories/reorder', data: {'type': type, 'categoryIds': categoryIds}, ); } static Future delete(int id) async { final session = SessionStore.instance; if (session.shouldUseLocalOnly || id < 0) { LocalDatabase.instance.deleteCategory(id); if (session.isAccount && session.cloudSyncEnabled) { LocalDatabase.instance.enqueueSync('category', id, 'delete', { 'id': id, }); } return; } await _dio.delete('/api/categories/$id'); LocalDatabase.instance.deleteCategory(id); } }