Files
jizhi/frontend/lib/shared/api/business_api.dart
T

1850 lines
59 KiB
Dart

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 String? transferDirection, counterparty;
final String? provider, providerTransactionId, recognitionOccurrenceId;
final String? evidenceFingerprint, recognitionConfidence;
final DateTime occurredAt, updatedAt;
final bool isDeleted;
TxItem.fromJson(Map<String, dynamic> 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?,
transferDirection = j['transferDirection'] as String?,
counterparty = j['counterparty'] as String?,
provider = j['provider'] as String?,
providerTransactionId = j['providerTransactionId'] as String?,
recognitionOccurrenceId = j['recognitionOccurrenceId'] as String?,
evidenceFingerprint = j['evidenceFingerprint'] as String?,
recognitionConfidence = j['recognitionConfidence'] 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 isTransfer => type == 'transfer';
bool get isIncome =>
type == 'income' || isTransfer && transferDirection == 'in';
bool get isExpense =>
type == 'expense' || isTransfer && transferDirection == 'out';
String get typeLabel => isTransfer
? transferDirection == 'in'
? '转入'
: '转出'
: isIncome
? '收入'
: '支出';
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<TxItem> items;
DayGroup.fromJson(Map<String, dynamic> 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<String, dynamic>))
.toList();
}
class MonthSummary {
final int year, month, count;
final double income, expense, balance;
final List<DayGroup> days;
MonthSummary.fromJson(Map<String, dynamic> 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<String, dynamic>))
.toList();
}
class CategoryItem {
final int id, sortOrder;
final String name, iconKey, colorKey, type;
final bool isCustom;
CategoryItem.fromJson(Map<String, dynamic> 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<String, dynamic> 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<CategoryStat> byCategory;
final List<double> dailyExpenses;
final String? aiAnalysis;
MonthStats.fromJson(Map<String, dynamic> 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<String, dynamic>))
.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<String, dynamic> 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<CategoryStat> byCategory;
final List<PeriodTrendPoint> trend;
final String? analysis;
PeriodStats.fromJson(Map<String, dynamic> 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<String, dynamic>))
.toList(),
trend = (json['trend'] as List)
.map(
(item) => PeriodTrendPoint.fromJson(item as Map<String, dynamic>),
)
.toList(),
analysis = json['analysis'] as String?;
}
class RecognitionBatchDraft {
final String candidateId, clientRequestId, type, source;
final int categoryId;
final double amount;
final DateTime occurredAt;
final String? note, paymentMethod, sourceText;
final String? transferDirection, counterparty;
final String? provider, providerTransactionId, recognitionOccurrenceId;
final String? evidenceFingerprint, recognitionConfidence;
const RecognitionBatchDraft({
required this.candidateId,
required this.clientRequestId,
required this.categoryId,
required this.type,
required this.amount,
required this.occurredAt,
required this.source,
this.note,
this.paymentMethod,
this.sourceText,
this.transferDirection,
this.counterparty,
this.provider,
this.providerTransactionId,
this.recognitionOccurrenceId,
this.evidenceFingerprint,
this.recognitionConfidence,
});
}
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 MonthSummary monthLocal(int year, int month) => MonthSummary.fromJson(
LocalDatabase.instance.monthSummary(year, month, _ledgerId),
);
static Future<MonthSummary> monthRemote(int year, int month) async {
final response = await _dio.get(
'/api/transactions/month',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheMonthSummary(json);
return MonthSummary.fromJson(json);
}
static Future<MonthSummary> 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<String, dynamic>;
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<MonthStats> 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<String, dynamic>);
} 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<String, dynamic>)['expense'])
.toList(),
'aiAnalysis': local['analysis'],
});
}
static Future<PeriodStats> 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<String, dynamic>);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return PeriodStats.fromJson(
LocalDatabase.instance.periodStats(period, anchor, _ledgerId),
);
}
}
static PeriodStats periodStatsLocal(String period, DateTime anchor) =>
PeriodStats.fromJson(
LocalDatabase.instance.periodStats(period, anchor, _ledgerId),
);
static Future<PeriodStats> periodStatsRemote(
String period,
DateTime anchor,
) async {
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<String, dynamic>);
}
static Future<List<CategoryItem>> 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<String, dynamic>))
.toList();
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return LocalDatabase.instance
.categories(type)
.map(CategoryItem.fromJson)
.toList();
}
}
static List<CategoryItem> categoriesLocal(String type) => LocalDatabase
.instance
.categories(type)
.map(CategoryItem.fromJson)
.toList();
static Future<List<CategoryItem>> categoriesRemote(String type) async {
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<String, dynamic>))
.toList();
}
static Future<TxItem> create({
required int categoryId,
required String type,
required double amount,
String? note,
String? paymentMethod,
String? source,
String? sourceText,
DateTime? occurredAt,
String? clientRequestId,
String? transferDirection,
String? counterparty,
String? provider,
String? providerTransactionId,
String? recognitionOccurrenceId,
String? evidenceFingerprint,
String? recognitionConfidence,
}) async {
final payload = <String, dynamic>{
'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,
if (transferDirection != null) 'transferDirection': transferDirection,
if (counterparty != null) 'counterparty': counterparty,
if (provider != null) 'provider': provider,
if (providerTransactionId != null)
'providerTransactionId': providerTransactionId,
if (recognitionOccurrenceId != null)
'recognitionOccurrenceId': recognitionOccurrenceId,
if (evidenceFingerprint != null)
'evidenceFingerprint': evidenceFingerprint,
if (recognitionConfidence != null)
'recognitionConfidence': recognitionConfidence,
};
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<String, dynamic>;
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<Map<String, TxItem>> createRecognitionBatch(
String batchId,
List<RecognitionBatchDraft> drafts,
) async {
if (drafts.isEmpty) return const {};
final localPayloads = drafts
.map(
(draft) => <String, dynamic>{
'ledgerId': _ledgerId,
'categoryId': draft.categoryId,
'type': draft.type,
'amount': draft.amount,
'note': draft.note,
'paymentMethod': draft.paymentMethod,
'source': draft.source,
'sourceText': draft.sourceText,
'occurredAt': ShanghaiTime.civilToUtc(
draft.occurredAt,
).toIso8601String(),
'clientRequestId': draft.clientRequestId,
'transferDirection': draft.transferDirection,
'counterparty': draft.counterparty,
'provider': draft.provider,
'providerTransactionId': draft.providerTransactionId,
'recognitionOccurrenceId': draft.recognitionOccurrenceId,
'evidenceFingerprint': draft.evidenceFingerprint,
'recognitionConfidence': draft.recognitionConfidence,
},
)
.toList(growable: false);
Map<String, TxItem> createLocal() {
final values = LocalDatabase.instance.createTransactionsBatch(
localPayloads,
enqueueSyncChanges: _queueOfflineChanges,
);
return {
for (var index = 0; index < drafts.length; index++)
drafts[index].candidateId: TxItem.fromJson(values[index]),
};
}
final session = SessionStore.instance;
if (session.shouldUseLocalOnly ||
_ledgerId < 0 ||
drafts.any((draft) => draft.categoryId < 0)) {
return createLocal();
}
try {
final response = await _dio.post(
'/api/transactions/recognition-batch',
data: {
'batchId': batchId,
'ledgerId': _ledgerId,
'items': [
for (var index = 0; index < drafts.length; index++)
{
'candidateId': drafts[index].candidateId,
'clientRequestId': drafts[index].clientRequestId,
'categoryId': drafts[index].categoryId,
'type': drafts[index].type,
'amount': drafts[index].amount,
'note': drafts[index].note,
'paymentMethod': drafts[index].paymentMethod,
'occurredAt': localPayloads[index]['occurredAt'],
'source': drafts[index].source,
'sourceText': drafts[index].sourceText,
'transferDirection': drafts[index].transferDirection,
'counterparty': drafts[index].counterparty,
'provider': drafts[index].provider,
'providerTransactionId': drafts[index].providerTransactionId,
'recognitionOccurrenceId':
drafts[index].recognitionOccurrenceId,
'evidenceFingerprint': drafts[index].evidenceFingerprint,
'recognitionConfidence': drafts[index].recognitionConfidence,
},
],
},
);
final result = <String, TxItem>{};
for (final raw in response.data as List<dynamic>) {
final item = Map<String, dynamic>.from(raw as Map);
final transaction = Map<String, dynamic>.from(
item['transaction'] as Map,
);
LocalDatabase.instance.cacheTransaction(transaction);
result[item['candidateId'].toString()] = TxItem.fromJson(transaction);
}
return result;
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return createLocal();
}
}
static Future<void> delete(int id) async {
final baseUpdatedAt = LocalDatabase.instance.transaction(
id,
includeDeleted: true,
)?['updatedAt'];
final session = SessionStore.instance;
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
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<TxItem> update(
int id, {
required int ledgerId,
required int categoryId,
required String type,
required double amount,
required DateTime occurredAt,
String? note,
String? paymentMethod,
String? transferDirection,
String? counterparty,
}) async {
final baseUpdatedAt = LocalDatabase.instance.transaction(
id,
includeDeleted: true,
)?['updatedAt'];
final payload = <String, dynamic>{
'ledgerId': ledgerId,
'categoryId': categoryId,
'type': type,
'amount': amount,
'note': note,
'paymentMethod': paymentMethod,
'transferDirection': transferDirection,
'counterparty': counterparty,
'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<String, dynamic>;
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<List<TxItem>> recycleBin() async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline) {
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<String, dynamic>))
.toList();
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return LocalDatabase.instance
.recycleBin(_ledgerId)
.map(TxItem.fromJson)
.toList();
}
}
static List<TxItem> recycleBinLocal() => LocalDatabase.instance
.recycleBin(_ledgerId)
.map(TxItem.fromJson)
.toList();
static Future<List<TxItem>> recycleBinRemote() async {
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<String, dynamic>))
.toList();
}
static Future<TxItem> restore(int id) async {
final baseUpdatedAt = LocalDatabase.instance.transaction(
id,
includeDeleted: true,
)?['updatedAt'];
final session = SessionStore.instance;
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
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<String, dynamic>;
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<void> 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<void> 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<String, dynamic> 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<String, dynamic>),
createdAt = ShanghaiTime.parseCivil(j['createdAt'] as String);
}
class ChatApi {
static final _dio = ApiClient.instance.dio;
static Future<List<ChatMsg>> 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<String, dynamic>))
.toList();
}
static Future<void> clearContext() async {
SessionStore.instance.requireOnline('AI 聊天需要登录并连接网络');
await _dio.post('/api/chat/context/clear');
}
static Future<List<ChatMsg>> 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<String, dynamic>))
.toList();
}
static Future<void> sendStream(
String content, {
required void Function(String full) onToken,
void Function(List<ChatMsg> 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<List<int>>(
(bytes) => bytes,
);
final completer = Completer<void>();
final frames = SseFrameAccumulator();
var fullText = '';
int? messageId;
String? streamError;
List<ChatMsg>? 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<ChatMsg>?) _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<ChatMsg>? messages;
final rawMessages = data['messages'];
if (rawMessages is List) {
messages = rawMessages
.map(
(item) =>
ChatMsg.fromJson(Map<String, dynamic>.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<String, dynamic> 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<BudgetItem> categories;
BudgetsData.fromJson(Map<String, dynamic> j)
: total = j['total'] == null
? null
: BudgetItem.fromJson(j['total'] as Map<String, dynamic>),
categories = (j['categories'] as List)
.map((e) => BudgetItem.fromJson(e as Map<String, dynamic>))
.toList();
}
class BudgetRecommendationItem {
final int categoryId;
final String categoryName, categoryIcon, colorKey, confidence;
final double suggestedAmount, currentSpent, historicalAverage;
BudgetRecommendationItem.fromJson(Map<String, dynamic> 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<String> warnings;
final List<BudgetRecommendationItem> items;
BudgetRecommendations.fromJson(Map<String, dynamic> 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<String, dynamic>),
)
.toList();
}
class BudgetRefinementTurn {
final String role, content;
const BudgetRefinementTurn({required this.role, required this.content});
Map<String, dynamic> toJson() => {'role': role, 'content': content};
}
class BudgetApi {
static final _dio = ApiClient.instance.dio;
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
static BudgetsData getLocal(int year, int month) => BudgetsData.fromJson(
LocalDatabase.instance.budgets(year, month, _ledgerId),
);
static Future<BudgetsData> getRemote(int year, int month) async {
final response = await _dio.get(
'/api/budgets',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
);
return BudgetsData.fromJson(response.data as Map<String, dynamic>);
}
static Future<BudgetsData> 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<String, dynamic>);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return BudgetsData.fromJson(
LocalDatabase.instance.budgets(year, month, _ledgerId),
);
}
}
static Future<void> 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<BudgetRecommendations> 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<String, dynamic>,
);
}
static Future<BudgetRecommendations> refineRecommendation(
int year,
int month, {
required String instruction,
required double suggestedTotal,
required Map<int, double> amounts,
required List<BudgetRefinementTurn> 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<String, dynamic>,
);
}
static Future<void> applyBatch(
int year,
int month, {
required bool recurring,
required Map<int?, double> 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<String, dynamic> 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<ReportCategoryRank> fromReport(Map<String, dynamic> json) {
final values = (json['categoryRanking'] as List<dynamic>? ?? const [])
.map(
(item) => ReportCategoryRank.fromJson(item as Map<String, dynamic>),
)
.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<ReportCategoryRank> categoryRanking;
final String roast;
MonthlyReport.fromJson(Map<String, dynamic> 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<ReportCategoryRank> categoryRanking;
final String commentary;
PeriodReport.fromJson(Map<String, dynamic> 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 PeriodReport local(String period, DateTime anchor) =>
_localPeriod(period, anchor);
static Future<PeriodReport> remote(String period, DateTime anchor) async {
if (period == 'month') {
return PeriodReport.fromMonthly(await monthly(anchor.year, anchor.month));
}
final path = period == 'week'
? '/api/reports/weekly'
: '/api/reports/yearly';
final query = period == 'week'
? {
'date':
'${anchor.year.toString().padLeft(4, '0')}-'
'${anchor.month.toString().padLeft(2, '0')}-'
'${anchor.day.toString().padLeft(2, '0')}',
'ledgerId': _ledgerId,
}
: {'year': anchor.year, 'ledgerId': _ledgerId};
final response = await _dio.get(path, queryParameters: query);
return PeriodReport.fromJson(response.data as Map<String, dynamic>);
}
static Future<PeriodReport> 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<String, dynamic>);
} catch (error) {
if (!isConnectivityError(error) || !SessionStore.instance.isAccount)
rethrow;
return _localPeriod('week', date);
}
}
static Future<PeriodReport> 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<PeriodReport> 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<String, dynamic>);
} catch (error) {
if (!isConnectivityError(error) || !SessionStore.instance.isAccount)
rethrow;
return _localPeriod('year', DateTime(year, 1, 1));
}
}
static Future<MonthlyReport> 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<String, dynamic>);
}
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<List<TxItem>> 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<String, dynamic>))
.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<String, dynamic> 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 = <Map<String, String>>[
{'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<StickerItem>> 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<String, dynamic>))
.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<String, dynamic>.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, transferDirection, counterparty;
ParsedDraft.fromJson(Map<String, dynamic> 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?,
transferDirection = j['transferDirection'] as String?,
counterparty = j['counterparty'] as String?,
note = j['note'] as String,
type = switch (j['type']?.toString().toLowerCase()) {
'income' => 'income',
'expense' => 'expense',
'transfer' => 'transfer',
_ => 'unknown',
};
bool get isIncome =>
type == 'income' || type == 'transfer' && transferDirection == 'in';
String get typeLabel => type == 'transfer'
? transferDirection == 'in'
? '转入'
: '转出'
: isIncome
? '收入'
: '支出';
}
class ParseApi {
static final _dio = ApiClient.instance.dio;
static Future<ParsedDraft> 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<String, dynamic>);
}
static Future<TxItem> confirm(
ParsedDraft d,
String source,
String sourceText,
) async {
if (d.type != 'income' && d.type != 'expense' && d.type != 'transfer') {
throw StateError('请先确认账单类型');
}
return TxApi.create(
categoryId: d.categoryId,
type: d.type,
amount: d.amount,
note: d.note,
paymentMethod: d.paymentMethod,
source: source,
sourceText: sourceText,
transferDirection: d.transferDirection,
counterparty: d.counterparty,
);
}
}
class CategoryApi {
static final _dio = ApiClient.instance.dio;
static Future<CategoryItem> 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 ||
ApiClient.availability.value == BackendAvailability.offline) {
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<String, dynamic>;
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<CategoryItem> 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 ||
ApiClient.availability.value == BackendAvailability.offline ||
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);
}
try {
final response = await _dio.put('/api/categories/$id', data: payload);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheCategories([json]);
return CategoryItem.fromJson(json);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
final local = LocalDatabase.instance.updateCategory(
id,
name,
iconKey,
colorKey,
sortOrder,
);
LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
return CategoryItem.fromJson(local);
}
}
static Future<void> reorder(String type, List<int> categoryIds) async {
LocalDatabase.instance.reorderCategories(type, categoryIds);
final session = SessionStore.instance;
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
categoryIds.any((id) => id < 0)) {
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
'type': type,
'categoryIds': categoryIds,
});
}
return;
}
try {
await _dio.put(
'/api/categories/reorder',
data: {'type': type, 'categoryIds': categoryIds},
);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
'type': type,
'categoryIds': categoryIds,
});
}
}
static Future<void> delete(int id) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
id < 0) {
LocalDatabase.instance.deleteCategory(id);
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('category', id, 'delete', {
'id': id,
});
}
return;
}
try {
await _dio.delete('/api/categories/$id');
LocalDatabase.instance.deleteCategory(id);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
LocalDatabase.instance.deleteCategory(id);
LocalDatabase.instance.enqueueSync('category', id, 'delete', {'id': id});
}
}
}