1850 lines
59 KiB
Dart
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});
|
|
}
|
|
}
|
|
}
|