Initial project import
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/services/local_database.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
|
||||
class LedgerInfo {
|
||||
final int id;
|
||||
final String name;
|
||||
final String iconKey;
|
||||
final bool isDefault;
|
||||
final int transactionCount;
|
||||
|
||||
const LedgerInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.iconKey,
|
||||
required this.isDefault,
|
||||
required this.transactionCount,
|
||||
});
|
||||
|
||||
factory LedgerInfo.fromJson(Map<String, dynamic> json) => LedgerInfo(
|
||||
id: (json['id'] as num).toInt(),
|
||||
name: json['name'] as String,
|
||||
iconKey: json['iconKey'] as String? ?? 'wallet',
|
||||
isDefault: json['isDefault'] as bool? ?? false,
|
||||
transactionCount: (json['txCount'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class CurrentLedgerStore extends ChangeNotifier {
|
||||
CurrentLedgerStore._();
|
||||
|
||||
static final instance = CurrentLedgerStore._();
|
||||
final _dio = ApiClient.instance.dio;
|
||||
|
||||
List<LedgerInfo> _ledgers = const [];
|
||||
LedgerInfo? _current;
|
||||
Future<void>? _loading;
|
||||
|
||||
List<LedgerInfo> get ledgers => List.unmodifiable(_ledgers);
|
||||
LedgerInfo? get current => _current;
|
||||
int? get currentId => _current?.id;
|
||||
String get currentName => _current?.name ?? '账本';
|
||||
|
||||
Future<void> ensureLoaded({bool force = false}) {
|
||||
if (!force && _current != null) return Future.value();
|
||||
return _loading ??= _load().whenComplete(() => _loading = null);
|
||||
}
|
||||
|
||||
Future<void> loadCached({bool force = false}) {
|
||||
if (!force && _current != null) return Future.value();
|
||||
return _loading ??= _loadCached().whenComplete(() => _loading = null);
|
||||
}
|
||||
|
||||
Future<void> _loadCached() async {
|
||||
_apply(LocalDatabase.instance.ledgers());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final session = SessionStore.instance;
|
||||
List<dynamic> values;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
values = LocalDatabase.instance.ledgers();
|
||||
} else {
|
||||
try {
|
||||
final response = await _dio.get('/api/ledgers');
|
||||
values = response.data as List;
|
||||
LocalDatabase.instance.cacheLedgers(values);
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||
values = LocalDatabase.instance.ledgers();
|
||||
}
|
||||
}
|
||||
_apply(values);
|
||||
}
|
||||
|
||||
void _apply(List<dynamic> values) {
|
||||
final items = values
|
||||
.map((item) => LedgerInfo.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
_ledgers = items;
|
||||
_current =
|
||||
items.where((item) => item.isDefault).firstOrNull ??
|
||||
(items.isEmpty ? null : items.first);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> select(int id) async {
|
||||
if (_current?.id == id) return;
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || id < 0) {
|
||||
LocalDatabase.instance.selectLedger(id);
|
||||
if (session.isAccount && session.cloudSyncEnabled) {
|
||||
LocalDatabase.instance.enqueueSync('ledger', id, 'select', {'id': id});
|
||||
}
|
||||
await ensureLoaded(force: true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _dio.put('/api/ledgers/$id/default');
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error)) rethrow;
|
||||
LocalDatabase.instance.selectLedger(id);
|
||||
LocalDatabase.instance.enqueueSync('ledger', id, 'select', {'id': id});
|
||||
}
|
||||
await ensureLoaded(force: true);
|
||||
}
|
||||
|
||||
Future<void> create(String name) async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
final id = LocalDatabase.instance.createLedger(name.trim());
|
||||
if (session.isAccount && session.cloudSyncEnabled) {
|
||||
LocalDatabase.instance.enqueueSync('ledger', id, 'create', {
|
||||
'name': name.trim(),
|
||||
'iconKey': 'wallet',
|
||||
});
|
||||
}
|
||||
await ensureLoaded(force: true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _dio.post(
|
||||
'/api/ledgers',
|
||||
data: {'name': name.trim(), 'iconKey': 'wallet'},
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error)) rethrow;
|
||||
final id = LocalDatabase.instance.createLedger(name.trim());
|
||||
LocalDatabase.instance.enqueueSync('ledger', id, 'create', {
|
||||
'name': name.trim(),
|
||||
'iconKey': 'wallet',
|
||||
});
|
||||
}
|
||||
await ensureLoaded(force: true);
|
||||
}
|
||||
|
||||
Future<void> rename(int id, String name, String iconKey) async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || id < 0) {
|
||||
LocalDatabase.instance.renameLedger(id, name.trim(), iconKey);
|
||||
if (session.isAccount && session.cloudSyncEnabled) {
|
||||
LocalDatabase.instance.enqueueSync('ledger', id, 'update', {
|
||||
'name': name.trim(),
|
||||
'iconKey': iconKey,
|
||||
});
|
||||
}
|
||||
await ensureLoaded(force: true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _dio.put(
|
||||
'/api/ledgers/$id',
|
||||
data: {'name': name.trim(), 'iconKey': iconKey},
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error)) rethrow;
|
||||
LocalDatabase.instance.renameLedger(id, name.trim(), iconKey);
|
||||
LocalDatabase.instance.enqueueSync('ledger', id, 'update', {
|
||||
'name': name.trim(),
|
||||
'iconKey': iconKey,
|
||||
});
|
||||
}
|
||||
await ensureLoaded(force: true);
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || id < 0) {
|
||||
LocalDatabase.instance.deleteLedger(id);
|
||||
if (session.isAccount && session.cloudSyncEnabled) {
|
||||
LocalDatabase.instance.enqueueSync('ledger', id, 'delete', {'id': id});
|
||||
}
|
||||
await ensureLoaded(force: true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _dio.delete('/api/ledgers/$id');
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error)) rethrow;
|
||||
LocalDatabase.instance.deleteLedger(id);
|
||||
LocalDatabase.instance.enqueueSync('ledger', id, 'delete', {'id': id});
|
||||
}
|
||||
await ensureLoaded(force: true);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_ledgers = const [];
|
||||
_current = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
|
||||
class GuestMergeResult {
|
||||
final int ledgerId;
|
||||
final int transactionCount;
|
||||
|
||||
const GuestMergeResult({
|
||||
required this.ledgerId,
|
||||
required this.transactionCount,
|
||||
});
|
||||
}
|
||||
|
||||
class GuestMergeService {
|
||||
GuestMergeService._();
|
||||
|
||||
static final _dio = ApiClient.instance.dio;
|
||||
|
||||
static Future<GuestMergeResult> merge(Map<String, dynamic> snapshot) async {
|
||||
final ledgerResponse = await _dio.post(
|
||||
'/api/ledgers',
|
||||
data: {'name': '游客数据', 'iconKey': 'wallet'},
|
||||
);
|
||||
final ledgerId = (ledgerResponse.data['id'] as num).toInt();
|
||||
final categoryMap = <int, int>{};
|
||||
final available = <Map<String, dynamic>>[];
|
||||
for (final type in ['expense', 'income']) {
|
||||
final response = await _dio.get(
|
||||
'/api/categories',
|
||||
queryParameters: {'type': type},
|
||||
);
|
||||
available.addAll(
|
||||
(response.data as List).map((item) => item as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
for (final value in snapshot['categories'] as List<dynamic>? ?? const []) {
|
||||
final category = value as Map<String, dynamic>;
|
||||
final existing = available.where(
|
||||
(item) =>
|
||||
item['type'] == category['type'] &&
|
||||
item['name'] == category['name'],
|
||||
);
|
||||
Map<String, dynamic> remote;
|
||||
if (existing.isNotEmpty) {
|
||||
remote = existing.first;
|
||||
} else {
|
||||
final response = await _dio.post(
|
||||
'/api/categories',
|
||||
data: {
|
||||
'name': category['name'],
|
||||
'iconKey': category['iconKey'],
|
||||
'colorKey': category['colorKey'],
|
||||
'type': category['type'],
|
||||
},
|
||||
);
|
||||
remote = response.data as Map<String, dynamic>;
|
||||
available.add(remote);
|
||||
}
|
||||
categoryMap[(category['id'] as num).toInt()] = (remote['id'] as num)
|
||||
.toInt();
|
||||
}
|
||||
|
||||
int mapCategory(Map<String, dynamic> transaction) {
|
||||
final oldId = (transaction['categoryId'] as num).toInt();
|
||||
final custom = categoryMap[oldId];
|
||||
if (custom != null) return custom;
|
||||
final exact = available.where(
|
||||
(item) =>
|
||||
item['type'] == transaction['type'] &&
|
||||
item['name'] == transaction['categoryName'],
|
||||
);
|
||||
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
|
||||
final fallback = available.firstWhere(
|
||||
(item) => item['type'] == transaction['type'] && item['name'] == '其他',
|
||||
);
|
||||
return (fallback['id'] as num).toInt();
|
||||
}
|
||||
|
||||
var transactionCount = 0;
|
||||
for (final value
|
||||
in snapshot['transactions'] as List<dynamic>? ?? const []) {
|
||||
final transaction = value as Map<String, dynamic>;
|
||||
await _dio.post(
|
||||
'/api/transactions',
|
||||
data: {
|
||||
'ledgerId': ledgerId,
|
||||
'categoryId': mapCategory(transaction),
|
||||
'type': transaction['type'],
|
||||
'amount': transaction['amount'],
|
||||
'note': transaction['note'],
|
||||
'paymentMethod': transaction['paymentMethod'],
|
||||
'occurredAt': transaction['occurredAt'],
|
||||
'source': 'manual',
|
||||
'sourceText': null,
|
||||
},
|
||||
);
|
||||
transactionCount++;
|
||||
}
|
||||
|
||||
for (final value in snapshot['budgets'] as List<dynamic>? ?? const []) {
|
||||
final budget = value as Map<String, dynamic>;
|
||||
final period = (budget['period'] as num).toInt();
|
||||
final recurring = budget['recurring'] == true || period == 0;
|
||||
final current = ShanghaiTime.now;
|
||||
final year = period == 0 ? current.year : period ~/ 100;
|
||||
final month = period == 0 ? current.month : period % 100;
|
||||
final oldCategoryId = (budget['categoryId'] as num?)?.toInt();
|
||||
await _dio.put(
|
||||
'/api/budgets',
|
||||
queryParameters: {'year': year, 'month': month, 'ledgerId': ledgerId},
|
||||
data: {
|
||||
'categoryId': oldCategoryId == null
|
||||
? null
|
||||
: categoryMap[oldCategoryId] ??
|
||||
_findMappedDefault(available, snapshot, oldCategoryId),
|
||||
'amount': budget['amount'],
|
||||
'recurring': recurring,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await CurrentLedgerStore.instance.ensureLoaded(force: true);
|
||||
await CurrentLedgerStore.instance.select(ledgerId);
|
||||
return GuestMergeResult(
|
||||
ledgerId: ledgerId,
|
||||
transactionCount: transactionCount,
|
||||
);
|
||||
}
|
||||
|
||||
static int? _findMappedDefault(
|
||||
List<Map<String, dynamic>> available,
|
||||
Map<String, dynamic> snapshot,
|
||||
int oldCategoryId,
|
||||
) {
|
||||
final transaction = (snapshot['transactions'] as List<dynamic>? ?? const [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.where((item) => item['categoryId'] == oldCategoryId)
|
||||
.firstOrNull;
|
||||
if (transaction == null) return null;
|
||||
final match = available.where(
|
||||
(item) =>
|
||||
item['type'] == transaction['type'] &&
|
||||
item['name'] == transaction['categoryName'],
|
||||
);
|
||||
return match.isEmpty ? null : (match.first['id'] as num).toInt();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:miaoji_zhang/shared/services/local_database.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
|
||||
class LocalExportService {
|
||||
const LocalExportService._();
|
||||
|
||||
static List<int> buildZip() {
|
||||
final snapshot = LocalDatabase.instance.exportSnapshot();
|
||||
final session = SessionStore.instance;
|
||||
return buildZipFromSnapshot(
|
||||
snapshot,
|
||||
profile: {
|
||||
'mode': session.isGuest ? 'guest' : 'account',
|
||||
'userId': session.userId,
|
||||
'username': session.username,
|
||||
'nickname': session.nickname,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static List<int> buildZipFromSnapshot(
|
||||
Map<String, dynamic> snapshot, {
|
||||
Map<String, dynamic> profile = const {},
|
||||
}) {
|
||||
final document = <String, dynamic>{...snapshot, 'profile': profile};
|
||||
final ledgers = _indexById(snapshot['ledgers'] as List<dynamic>);
|
||||
final categories = _indexById(snapshot['categories'] as List<dynamic>);
|
||||
final transactions = (snapshot['transactions'] as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>();
|
||||
final budgets = (snapshot['budgets'] as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>();
|
||||
|
||||
final archive = Archive();
|
||||
archive.addFile(
|
||||
ArchiveFile.bytes(
|
||||
'transactions.csv',
|
||||
_utf8Bom(
|
||||
_csv([
|
||||
const [
|
||||
'id',
|
||||
'ledger',
|
||||
'type',
|
||||
'amount',
|
||||
'category',
|
||||
'note',
|
||||
'payment_method',
|
||||
'source',
|
||||
'occurred_at',
|
||||
'is_deleted',
|
||||
'updated_at',
|
||||
],
|
||||
for (final item in transactions)
|
||||
[
|
||||
item['id'],
|
||||
ledgers[item['ledgerId']]?['name'],
|
||||
item['type'],
|
||||
(item['amount'] as num).toStringAsFixed(2),
|
||||
item['categoryName'],
|
||||
item['note'],
|
||||
item['paymentMethod'],
|
||||
item['source'],
|
||||
item['occurredAt'],
|
||||
item['isDeleted'],
|
||||
item['updatedAt'],
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
archive.addFile(
|
||||
ArchiveFile.bytes(
|
||||
'budgets.csv',
|
||||
_utf8Bom(
|
||||
_csv([
|
||||
const [
|
||||
'ledger',
|
||||
'period',
|
||||
'category',
|
||||
'amount',
|
||||
'recurring',
|
||||
'updated_at',
|
||||
],
|
||||
for (final item in budgets)
|
||||
[
|
||||
ledgers[item['ledgerId']]?['name'],
|
||||
item['period'],
|
||||
item['categoryId'] == null
|
||||
? 'total'
|
||||
: categories[item['categoryId']]?['name'],
|
||||
(item['amount'] as num).toStringAsFixed(2),
|
||||
item['recurring'],
|
||||
item['updatedAt'],
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
archive.addFile(
|
||||
ArchiveFile.string(
|
||||
'jizhi-backup.json',
|
||||
const JsonEncoder.withIndent(' ').convert(document),
|
||||
),
|
||||
);
|
||||
return ZipEncoder().encode(archive);
|
||||
}
|
||||
|
||||
static Map<int, Map<String, dynamic>> _indexById(List<dynamic> items) => {
|
||||
for (final item in items.cast<Map<String, dynamic>>())
|
||||
item['id'] as int: item,
|
||||
};
|
||||
|
||||
static List<int> _utf8Bom(String value) => [
|
||||
0xef,
|
||||
0xbb,
|
||||
0xbf,
|
||||
...utf8.encode(value),
|
||||
];
|
||||
|
||||
static String _csv(List<List<Object?>> rows) =>
|
||||
rows.map((row) => row.map(_csvCell).join(',')).join('\r\n');
|
||||
|
||||
static String _csvCell(Object? value) {
|
||||
final text = value?.toString() ?? '';
|
||||
if (!text.contains(RegExp('[,"\r\n]'))) return text;
|
||||
return '"${text.replaceAll('"', '""')}"';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
|
||||
|
||||
class RecognitionDiagnosticDisplay {
|
||||
final String stageLabel;
|
||||
final String resultLabel;
|
||||
final String summaryLabel;
|
||||
final String reasonLabel;
|
||||
final String? recognitionKindLabel;
|
||||
final String? amountSourceLabel;
|
||||
final String? statusStrengthLabel;
|
||||
final bool statusStrengthPositive;
|
||||
|
||||
const RecognitionDiagnosticDisplay({
|
||||
required this.stageLabel,
|
||||
required this.resultLabel,
|
||||
required this.summaryLabel,
|
||||
required this.reasonLabel,
|
||||
this.recognitionKindLabel,
|
||||
this.amountSourceLabel,
|
||||
this.statusStrengthLabel,
|
||||
this.statusStrengthPositive = false,
|
||||
});
|
||||
|
||||
factory RecognitionDiagnosticDisplay.from(RecognitionDiagnostic diagnostic) {
|
||||
final reason = diagnostic.reason;
|
||||
return RecognitionDiagnosticDisplay(
|
||||
stageLabel: _stageLabel(diagnostic.stage),
|
||||
resultLabel: _resultLabel(diagnostic.result),
|
||||
summaryLabel: _summaryLabel(diagnostic.result, reason),
|
||||
reasonLabel: _reasonLabel(reason),
|
||||
recognitionKindLabel: _recognitionKindLabel(diagnostic.recognitionKind),
|
||||
amountSourceLabel: _amountSourceLabel(diagnostic.amountSource),
|
||||
statusStrengthLabel: _statusStrengthLabel(diagnostic.statusStrength),
|
||||
statusStrengthPositive: diagnostic.statusStrength == 'strong',
|
||||
);
|
||||
}
|
||||
|
||||
static String _stageLabel(String value) {
|
||||
return switch (value) {
|
||||
'capture' => '本地截屏',
|
||||
'ocr' => '本地 OCR',
|
||||
'tree' => '控件树识别',
|
||||
_ => '无障碍事件',
|
||||
};
|
||||
}
|
||||
|
||||
static String _resultLabel(String value) {
|
||||
return switch (value) {
|
||||
'matched' || 'auto_ready' => '已识别',
|
||||
'confirm' => '待确认',
|
||||
'started' => '处理中',
|
||||
'failed' => '失败',
|
||||
_ => '未触发入账',
|
||||
};
|
||||
}
|
||||
|
||||
static String _summaryLabel(String result, String reason) {
|
||||
if (reason == 'duplicate_result_surface') return '已合并';
|
||||
if (_captureFailureReasons.contains(reason)) return '截图失败';
|
||||
if (_ocrNoResultReasons.contains(reason)) return 'OCR 无结果';
|
||||
if (_ruleRejectedReasons.contains(reason) || result == 'rejected') {
|
||||
return '规则拒绝';
|
||||
}
|
||||
return switch (result) {
|
||||
'matched' || 'auto_ready' => '已识别',
|
||||
'confirm' => '待确认',
|
||||
'started' => '处理中',
|
||||
'failed' => '失败',
|
||||
_ => '没事件',
|
||||
};
|
||||
}
|
||||
|
||||
static String _reasonLabel(String reason) {
|
||||
return switch (reason) {
|
||||
'history_page' => '当前是账单或交易历史页',
|
||||
'blocked_status' => '当前状态为失败、处理中或已取消',
|
||||
'no_text' => '截图中没有识别到文字',
|
||||
'no_success_status' => '没有找到明确或弱完成状态,可开启诊断预览查看脱敏结果',
|
||||
'payment_input_page' => '当前仍是付款输入或确认页面,已拒绝入账',
|
||||
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
|
||||
'missing_amount' => '成功状态已识别,但没有找到金额',
|
||||
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
|
||||
'expected_amount_fallback' => '结果页金额缺失,已使用付款前确认的唯一金额',
|
||||
'red_packet_not_settled' => '红包尚未明确到账或退回,不会自动入账',
|
||||
'duplicate_result_surface' => '同一结果页已处理,本次刷新已忽略',
|
||||
'weak_status_confirm' => '只识别到弱完成状态,组合证据不足,需确认后入账',
|
||||
'combined_high_confidence' => '弱完成状态已通过流程、跳转和金额组合校验',
|
||||
'ambiguous_or_unarmed' => '金额存在歧义或未捕获到完整支付流程',
|
||||
'secure_window' => '当前页面被系统禁止截屏',
|
||||
'window_fallback' => '单窗口截图失败,正在切换整屏截图',
|
||||
'invalid_window' => '支付页面窗口已切换,单窗口截图已失效',
|
||||
'internal_error' => '系统单窗口截图失败,请停留在成功页重试',
|
||||
'display_capture_start_failed' => '整屏截图请求无法启动',
|
||||
'invalid_display' => '当前屏幕暂时无法截取',
|
||||
'capture_timeout' => '系统截屏长时间没有返回结果,已自动结束',
|
||||
'capture_start_failed' => '系统截屏请求无法启动',
|
||||
'ocr_timeout' => '本地 OCR 超时,已自动结束,可在支付成功页重试',
|
||||
'ocr_start_failed' => '本地 OCR 请求无法启动',
|
||||
'ocr_init_failed' => '本地 OCR 组件初始化失败,请重新开启无障碍服务',
|
||||
'ocr_model_unavailable' => '本地中文识别模型不可用,请重新安装内测包',
|
||||
'ocr_unavailable' => '本地 OCR 服务暂不可用,请稍后重试',
|
||||
'ocr_failed' => '本地 OCR 识别失败,请停留在支付成功页重试',
|
||||
'ocr_parse_failed' => '本地 OCR 结果处理失败',
|
||||
'operation_interrupted' => '识别进程曾被系统中断,任务已自动结束',
|
||||
'interval_short' => '系统限制了过于频繁的截屏',
|
||||
'accessibility_unavailable' => '无障碍截屏能力暂不可用',
|
||||
'image_encode_failed' => '截图结果编码失败,请重试',
|
||||
'success' || 'high_confidence' => '成功状态和金额均已确认',
|
||||
_ => reason.isEmpty ? '等待新的支付事件' : reason,
|
||||
};
|
||||
}
|
||||
|
||||
static String? _recognitionKindLabel(String? value) {
|
||||
return switch (value) {
|
||||
'payment' => '扫码支付',
|
||||
'transfer' => '转账',
|
||||
'red_packet_send' => '发出红包',
|
||||
'red_packet_receive' => '红包到账',
|
||||
'red_packet_refund' => '红包退回',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
static String? _amountSourceLabel(String? value) {
|
||||
return switch (value) {
|
||||
'expected' => '使用付款前金额',
|
||||
'result' => '使用结果页金额',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
static String? _statusStrengthLabel(String value) {
|
||||
return switch (value) {
|
||||
'strong' => '明确成功状态',
|
||||
'weak' => '弱完成状态',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
static const _captureFailureReasons = {
|
||||
'secure_window',
|
||||
'invalid_window',
|
||||
'internal_error',
|
||||
'display_capture_start_failed',
|
||||
'invalid_display',
|
||||
'capture_timeout',
|
||||
'capture_start_failed',
|
||||
'interval_short',
|
||||
'accessibility_unavailable',
|
||||
'image_encode_failed',
|
||||
};
|
||||
|
||||
static const _ocrNoResultReasons = {
|
||||
'no_text',
|
||||
'ocr_timeout',
|
||||
'ocr_start_failed',
|
||||
'ocr_init_failed',
|
||||
'ocr_model_unavailable',
|
||||
'ocr_unavailable',
|
||||
'ocr_failed',
|
||||
'ocr_parse_failed',
|
||||
};
|
||||
|
||||
static const _ruleRejectedReasons = {
|
||||
'history_page',
|
||||
'blocked_status',
|
||||
'no_success_status',
|
||||
'payment_input_page',
|
||||
'direction_unknown',
|
||||
'missing_amount',
|
||||
'expected_amount_missing',
|
||||
'red_packet_not_settled',
|
||||
'weak_status_confirm',
|
||||
'ambiguous_or_unarmed',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miaoji_zhang/features/home/pages/transaction_edit_page.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/api/business_api.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/screenshot_channel.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
import 'package:miaoji_zhang/shared/services/transaction_events.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||
|
||||
class RecognitionImportService {
|
||||
RecognitionImportService._();
|
||||
|
||||
static bool _processing = false;
|
||||
|
||||
static Future<void> configureNativeContext() async {
|
||||
final session = SessionStore.instance;
|
||||
await ScreenshotChannel.configureRecognitionContext(
|
||||
hasAccount: session.isAccount,
|
||||
aiAllowed: session.aiEnabled,
|
||||
baseUrl: ApiClient.baseUrl,
|
||||
token: session.isAccount ? await ApiClient.instance.readToken() : null,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> importAutomatic() async {
|
||||
if (_processing || !SessionStore.instance.hasSession) return;
|
||||
_processing = true;
|
||||
try {
|
||||
await CurrentLedgerStore.instance.ensureLoaded();
|
||||
final candidates = await ScreenshotChannel.drainRecognitionCandidates();
|
||||
for (final candidate in candidates.where((item) => item.canAutoImport)) {
|
||||
await _import(candidate);
|
||||
}
|
||||
} finally {
|
||||
_processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> handleAction(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> action,
|
||||
) async {
|
||||
final kind = action['action']?.toString();
|
||||
if (kind == 'ready') {
|
||||
await importAutomatic();
|
||||
return;
|
||||
}
|
||||
if (kind == 'recognition_undo') {
|
||||
final transactionId = (action['transactionId'] as num?)?.toInt();
|
||||
final candidateId = action['candidateId']?.toString();
|
||||
if (transactionId == null || candidateId == null) return;
|
||||
try {
|
||||
await TxApi.delete(transactionId);
|
||||
await ScreenshotChannel.acknowledgeRecognitionCandidate(
|
||||
candidateId,
|
||||
'undone',
|
||||
);
|
||||
TransactionEvents.notifyChanged();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('已撤销这笔智能识别账单')));
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kind == 'recognition_edit') {
|
||||
final transactionId = (action['transactionId'] as num?)?.toInt();
|
||||
final cached = transactionId == null
|
||||
? null
|
||||
: LocalDatabase.instance.transaction(transactionId);
|
||||
if (!context.mounted) return;
|
||||
if (cached == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('这笔账单尚未同步到本机,请稍后重试')));
|
||||
return;
|
||||
}
|
||||
final updated = await Navigator.of(context).push<TxItem>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
TransactionEditPage(transaction: TxItem.fromJson(cached)),
|
||||
),
|
||||
);
|
||||
if (updated != null) TransactionEvents.notifyChanged();
|
||||
return;
|
||||
}
|
||||
if (kind != 'recognition_confirm' || !context.mounted) return;
|
||||
|
||||
await CurrentLedgerStore.instance.ensureLoaded();
|
||||
final candidates = await ScreenshotChannel.drainRecognitionCandidates();
|
||||
if (!context.mounted) return;
|
||||
final requestedId = action['candidateId']?.toString();
|
||||
final candidate = candidates.where((item) {
|
||||
return item.state == 'pending_confirm' &&
|
||||
(requestedId == null || item.id == requestedId);
|
||||
}).firstOrNull;
|
||||
if (candidate == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('这条识别结果已处理或已过期')));
|
||||
return;
|
||||
}
|
||||
final confirmed = await _showConfirmation(context, candidate);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await _import(candidate);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('识别结果已入账')));
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<TxItem> _import(RecognitionCandidate candidate) async {
|
||||
if (candidate.type != 'income' && candidate.type != 'expense') {
|
||||
throw StateError('识别结果缺少明确的收支类型');
|
||||
}
|
||||
final categories = await TxApi.categories(candidate.type);
|
||||
if (categories.isEmpty) throw StateError('当前账本没有可用分类');
|
||||
final category =
|
||||
categories
|
||||
.where((item) => item.name == candidate.categoryHint)
|
||||
.firstOrNull ??
|
||||
categories.where((item) => item.name == '其他').firstOrNull ??
|
||||
categories.first;
|
||||
final occurredUtc = validOccurredAtUtc(candidate.occurredAtEpochMs);
|
||||
final transaction = await TxApi.create(
|
||||
categoryId: category.id,
|
||||
type: candidate.type,
|
||||
amount: candidate.amount,
|
||||
note: candidate.merchant?.trim().isNotEmpty == true
|
||||
? candidate.merchant!.trim()
|
||||
: (candidate.note ?? '智能识别'),
|
||||
paymentMethod: candidate.appName,
|
||||
source: candidate.source,
|
||||
sourceText: candidate.sourceText,
|
||||
occurredAt: ShanghaiTime.toCivil(occurredUtc),
|
||||
clientRequestId: candidate.clientRequestId,
|
||||
);
|
||||
await ScreenshotChannel.acknowledgeRecognitionCandidate(
|
||||
candidate.id,
|
||||
'imported',
|
||||
transactionId: transaction.id,
|
||||
);
|
||||
TransactionEvents.notifyChanged();
|
||||
return transaction;
|
||||
}
|
||||
|
||||
static DateTime validOccurredAtUtc(int epochMs, {DateTime? now}) {
|
||||
final current = (now ?? DateTime.now()).toUtc();
|
||||
final parsed = DateTime.fromMillisecondsSinceEpoch(epochMs, isUtc: true);
|
||||
final oldest = current.subtract(const Duration(days: 1));
|
||||
final newest = current.add(const Duration(minutes: 5));
|
||||
return parsed.isBefore(oldest) || parsed.isAfter(newest) ? current : parsed;
|
||||
}
|
||||
|
||||
static Future<bool?> _showConfirmation(
|
||||
BuildContext context,
|
||||
RecognitionCandidate candidate,
|
||||
) {
|
||||
final palette = context.jz;
|
||||
return showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.card,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const JzSheetHeader(
|
||||
title: '确认识别结果',
|
||||
subtitle: '信息来自本机解析,确认后才会写入账本',
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.background,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: palette.line),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: candidate.type == 'income'
|
||||
? palette.primaryBackground
|
||||
: palette.expenseBackground,
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: Icon(
|
||||
candidate.type == 'income'
|
||||
? Icons.south_west_rounded
|
||||
: Icons.north_east_rounded,
|
||||
color: candidate.type == 'income'
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
candidate.merchant ?? candidate.appName,
|
||||
style: TextStyle(
|
||||
color: palette.text,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${candidate.appName} · ${candidate.type == 'income' ? '收入' : '支出'}',
|
||||
style: TextStyle(
|
||||
color: palette.text2,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${candidate.type == 'income' ? '+' : '-'}¥${candidate.amount.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color: candidate.type == 'income'
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: JzActionButton(
|
||||
label: '稍后处理',
|
||||
secondary: true,
|
||||
onPressed: () => Navigator.pop(sheetContext, false),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: JzActionButton(
|
||||
label: '确认入账',
|
||||
onPressed: () => Navigator.pop(sheetContext, true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class RecognitionDiagnostic {
|
||||
final DateTime at;
|
||||
final String appName, stage, result, reason, statusStrength;
|
||||
final String? recognitionKind, amountSource, resultFingerprint;
|
||||
final int nodeCount, amountCandidates;
|
||||
final int? ocrMs;
|
||||
final bool? expectedAmountMatched, resultTransitionObserved;
|
||||
final List<String> ocrPreview;
|
||||
final DateTime? previewExpiresAt;
|
||||
|
||||
const RecognitionDiagnostic({
|
||||
required this.at,
|
||||
required this.appName,
|
||||
required this.stage,
|
||||
required this.result,
|
||||
required this.reason,
|
||||
required this.nodeCount,
|
||||
required this.amountCandidates,
|
||||
this.ocrMs,
|
||||
this.statusStrength = 'none',
|
||||
this.recognitionKind,
|
||||
this.amountSource,
|
||||
this.resultFingerprint,
|
||||
this.expectedAmountMatched,
|
||||
this.resultTransitionObserved,
|
||||
this.ocrPreview = const [],
|
||||
this.previewExpiresAt,
|
||||
});
|
||||
|
||||
factory RecognitionDiagnostic.fromJson(Map<String, dynamic> value) {
|
||||
final epoch = (value['at'] as num?)?.toInt() ?? 0;
|
||||
return RecognitionDiagnostic(
|
||||
at: DateTime.fromMillisecondsSinceEpoch(epoch),
|
||||
appName: value['appName']?.toString() ?? '支付应用',
|
||||
stage: value['stage']?.toString() ?? 'event',
|
||||
result: value['result']?.toString() ?? 'unknown',
|
||||
reason: value['reason']?.toString() ?? '',
|
||||
nodeCount: (value['nodeCount'] as num?)?.toInt() ?? 0,
|
||||
amountCandidates: (value['amountCandidates'] as num?)?.toInt() ?? 0,
|
||||
ocrMs: (value['ocrMs'] as num?)?.toInt(),
|
||||
statusStrength: value['statusStrength']?.toString() ?? 'none',
|
||||
recognitionKind: value['recognitionKind']?.toString(),
|
||||
amountSource: value['amountSource']?.toString(),
|
||||
resultFingerprint: value['resultFingerprint']?.toString(),
|
||||
expectedAmountMatched: value['expectedAmountMatched'] as bool?,
|
||||
resultTransitionObserved: value['resultTransitionObserved'] as bool?,
|
||||
ocrPreview:
|
||||
(value['ocrPreview'] as List<dynamic>?)
|
||||
?.map((item) => item.toString())
|
||||
.toList(growable: false) ??
|
||||
const [],
|
||||
previewExpiresAt: (value['previewExpiresAt'] as num?) == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(
|
||||
(value['previewExpiresAt'] as num).toInt(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecognitionStatus {
|
||||
final bool accessibilityAuthorized;
|
||||
final bool accessibilityConnected;
|
||||
final bool notificationAuthorized;
|
||||
final bool notificationConnected;
|
||||
final bool postNotificationsGranted;
|
||||
final bool accessibilityEvents;
|
||||
final bool notificationEvents;
|
||||
final bool aiScreenshot;
|
||||
final bool ocrDiagnosticPreview;
|
||||
final DateTime? ocrDiagnosticPreviewExpiresAt;
|
||||
final bool batteryOptimizationIgnored;
|
||||
final String manufacturer;
|
||||
final String? latestStatus;
|
||||
final RecognitionDiagnostic? latestDiagnostic;
|
||||
|
||||
const RecognitionStatus({
|
||||
required this.accessibilityAuthorized,
|
||||
required this.accessibilityConnected,
|
||||
required this.notificationAuthorized,
|
||||
required this.notificationConnected,
|
||||
required this.postNotificationsGranted,
|
||||
required this.accessibilityEvents,
|
||||
required this.notificationEvents,
|
||||
required this.aiScreenshot,
|
||||
this.ocrDiagnosticPreview = false,
|
||||
this.ocrDiagnosticPreviewExpiresAt,
|
||||
this.batteryOptimizationIgnored = false,
|
||||
this.manufacturer = '',
|
||||
this.latestStatus,
|
||||
this.latestDiagnostic,
|
||||
});
|
||||
|
||||
factory RecognitionStatus.fromMap(Map<Object?, Object?> value) {
|
||||
final rawSettings = value['settings']?.toString();
|
||||
final rawDiagnostic = value['latestDiagnostic']?.toString();
|
||||
final settings = rawSettings == null || rawSettings.isEmpty
|
||||
? const <String, dynamic>{}
|
||||
: jsonDecode(rawSettings) as Map<String, dynamic>;
|
||||
return RecognitionStatus(
|
||||
accessibilityAuthorized:
|
||||
value['accessibilityAuthorized'] as bool? ?? false,
|
||||
accessibilityConnected: value['accessibilityConnected'] as bool? ?? false,
|
||||
notificationAuthorized: value['notificationAuthorized'] as bool? ?? false,
|
||||
notificationConnected: value['notificationConnected'] as bool? ?? false,
|
||||
postNotificationsGranted:
|
||||
value['postNotificationsGranted'] as bool? ?? true,
|
||||
accessibilityEvents: settings['accessibilityEvents'] as bool? ?? false,
|
||||
notificationEvents: settings['notificationEvents'] as bool? ?? false,
|
||||
aiScreenshot: settings['aiScreenshot'] as bool? ?? false,
|
||||
ocrDiagnosticPreview: settings['ocrDiagnosticPreview'] as bool? ?? false,
|
||||
ocrDiagnosticPreviewExpiresAt:
|
||||
(settings['ocrDiagnosticPreviewExpiresAt'] as num?) == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(
|
||||
(settings['ocrDiagnosticPreviewExpiresAt'] as num).toInt(),
|
||||
),
|
||||
batteryOptimizationIgnored:
|
||||
value['batteryOptimizationIgnored'] as bool? ?? false,
|
||||
manufacturer: value['manufacturer']?.toString() ?? '',
|
||||
latestStatus: value['latestStatus']?.toString(),
|
||||
latestDiagnostic: rawDiagnostic == null || rawDiagnostic.isEmpty
|
||||
? null
|
||||
: RecognitionDiagnostic.fromJson(
|
||||
jsonDecode(rawDiagnostic) as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecognitionCandidate {
|
||||
final String id, clientRequestId, state, confidence, type, source, appName;
|
||||
final double amount;
|
||||
final String? merchant, orderId, sourceText, note;
|
||||
final String recognitionKind, amountSource;
|
||||
final String? categoryHint, resultFingerprint;
|
||||
final int occurredAtEpochMs;
|
||||
|
||||
RecognitionCandidate.fromJson(Map<String, dynamic> value)
|
||||
: id = value['id'] as String,
|
||||
clientRequestId = value['clientRequestId'] as String,
|
||||
state = value['state'] as String,
|
||||
confidence = value['confidence'] as String,
|
||||
type = value['type'] as String,
|
||||
source = value['source'] as String,
|
||||
appName = value['appName'] as String? ?? '支付应用',
|
||||
amount = (value['amount'] as num).toDouble(),
|
||||
merchant = value['merchant'] as String?,
|
||||
orderId = value['orderId'] as String?,
|
||||
sourceText = value['sourceText'] as String?,
|
||||
note = value['note'] as String?,
|
||||
recognitionKind = value['recognitionKind']?.toString() ?? 'payment',
|
||||
categoryHint = value['categoryHint']?.toString(),
|
||||
amountSource = value['amountSource']?.toString() ?? 'result',
|
||||
resultFingerprint = value['resultFingerprint']?.toString(),
|
||||
occurredAtEpochMs = (value['occurredAtEpochMs'] as num).toInt();
|
||||
|
||||
bool get canAutoImport => state == 'auto_ready' && confidence == 'auto';
|
||||
}
|
||||
|
||||
class SpeechEvent {
|
||||
final String type;
|
||||
final String? text;
|
||||
final double rms;
|
||||
|
||||
const SpeechEvent({required this.type, this.text, this.rms = 0});
|
||||
}
|
||||
|
||||
/// Android native capabilities for screenshots, AI progress and speech.
|
||||
class ScreenshotChannel {
|
||||
static const _channel = MethodChannel('com.miaoji/screenshot');
|
||||
static void Function(String path)? _screenshotReady;
|
||||
static void Function(String error)? _screenshotError;
|
||||
static void Function(SpeechEvent event)? _speechEvent;
|
||||
static void Function(Map<String, dynamic> action)? _recognitionAction;
|
||||
static bool _handlerInstalled = false;
|
||||
|
||||
static Future<void> cleanupStaleScreenshots() async {
|
||||
try {
|
||||
await _channel.invokeMethod<int>('cleanupStaleScreenshots');
|
||||
} on PlatformException {
|
||||
// Cleanup is best-effort and will run again on the next launch.
|
||||
} on MissingPluginException {
|
||||
// Screenshot storage is Android-only.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> capture() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<String>('captureScreenshot');
|
||||
} on PlatformException catch (e) {
|
||||
throw Exception(e.message ?? '截屏失败,请重试');
|
||||
} on MissingPluginException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> startAiProgress() async {
|
||||
try {
|
||||
await _channel.invokeMethod<bool>('startAiProgress');
|
||||
} on MissingPluginException {
|
||||
// Non-Android platforms do not expose native progress notifications.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> updateAiProgress(int count) async {
|
||||
try {
|
||||
await _channel.invokeMethod<bool>('updateAiProgress', {'count': count});
|
||||
} on MissingPluginException {
|
||||
// Non-Android platforms do not expose native progress notifications.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> finishAiProgress(int count, double total) async {
|
||||
try {
|
||||
await _channel.invokeMethod<bool>('finishAiProgress', {
|
||||
'count': count,
|
||||
'total': total,
|
||||
});
|
||||
} on MissingPluginException {
|
||||
// Non-Android platforms do not expose native progress notifications.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> failAiProgress(String message) async {
|
||||
try {
|
||||
await _channel.invokeMethod<bool>('failAiProgress', {'message': message});
|
||||
} on MissingPluginException {
|
||||
// Non-Android platforms do not expose native progress notifications.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> recognizeSpeech() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<String>('recognizeSpeech');
|
||||
} on PlatformException catch (e) {
|
||||
throw Exception(e.message ?? '语音识别不可用');
|
||||
} on MissingPluginException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> startSpeechRecognition(
|
||||
void Function(SpeechEvent event) onEvent,
|
||||
) async {
|
||||
_speechEvent = onEvent;
|
||||
_installHandler();
|
||||
try {
|
||||
await _channel.invokeMethod<bool>('startSpeechRecognition');
|
||||
} on PlatformException catch (e) {
|
||||
_speechEvent = null;
|
||||
throw Exception(e.message ?? '语音识别不可用');
|
||||
} on MissingPluginException {
|
||||
_speechEvent = null;
|
||||
throw Exception('当前设备不支持语音识别');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> stopSpeechRecognition() async {
|
||||
try {
|
||||
await _channel.invokeMethod<bool>('stopSpeechRecognition');
|
||||
} on MissingPluginException {
|
||||
// No native recognizer to stop.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> cancelSpeechRecognition() async {
|
||||
_speechEvent = null;
|
||||
try {
|
||||
await _channel.invokeMethod<bool>('cancelSpeechRecognition');
|
||||
} on MissingPluginException {
|
||||
// No native recognizer to cancel.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> isAccessibilityEnabled() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('isAccessibilityEnabled') ??
|
||||
false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> openAccessibilitySettings() async {
|
||||
try {
|
||||
await _channel.invokeMethod('openAccessibilitySettings');
|
||||
} on MissingPluginException {
|
||||
// Accessibility shortcut is Android-only.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> openQuickSettings() async {
|
||||
try {
|
||||
await _channel.invokeMethod('openQuickSettings');
|
||||
} on MissingPluginException {
|
||||
// Quick settings tiles are Android-only.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<RecognitionStatus> recognitionStatus() async {
|
||||
try {
|
||||
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
|
||||
'getRecognitionStatus',
|
||||
);
|
||||
return RecognitionStatus.fromMap(raw ?? const {});
|
||||
} on MissingPluginException {
|
||||
return const RecognitionStatus(
|
||||
accessibilityAuthorized: false,
|
||||
accessibilityConnected: false,
|
||||
notificationAuthorized: false,
|
||||
notificationConnected: false,
|
||||
postNotificationsGranted: true,
|
||||
accessibilityEvents: false,
|
||||
notificationEvents: false,
|
||||
aiScreenshot: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> clearRecognitionDiagnostic() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??
|
||||
false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> setRecognitionToggle(String key, bool enabled) async {
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('setRecognitionToggle', {
|
||||
'key': key,
|
||||
'enabled': enabled,
|
||||
}) ??
|
||||
false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> configureRecognitionContext({
|
||||
required bool hasAccount,
|
||||
required bool aiAllowed,
|
||||
required String baseUrl,
|
||||
String? token,
|
||||
}) async {
|
||||
try {
|
||||
await _channel.invokeMethod<bool>('configureRecognitionContext', {
|
||||
'hasAccount': hasAccount,
|
||||
'aiAllowed': aiAllowed,
|
||||
'baseUrl': baseUrl,
|
||||
'token': token,
|
||||
});
|
||||
} on MissingPluginException {
|
||||
// Native background recognition is Android-only.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<RecognitionCandidate>> drainRecognitionCandidates() async {
|
||||
try {
|
||||
final values =
|
||||
await _channel.invokeMethod<List<Object?>>(
|
||||
'drainRecognitionCandidates',
|
||||
) ??
|
||||
const [];
|
||||
return values
|
||||
.whereType<String>()
|
||||
.map(
|
||||
(value) => RecognitionCandidate.fromJson(
|
||||
jsonDecode(value) as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
} on MissingPluginException {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> acknowledgeRecognitionCandidate(
|
||||
String id,
|
||||
String state, {
|
||||
int? transactionId,
|
||||
}) async {
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('ackRecognitionCandidate', {
|
||||
'id': id,
|
||||
'state': state,
|
||||
if (transactionId != null) 'transactionId': transactionId,
|
||||
}) ??
|
||||
false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> requestNotificationPermission() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>(
|
||||
'requestNotificationPermission',
|
||||
) ??
|
||||
false;
|
||||
} on MissingPluginException {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> openNotificationAccessSettings() async {
|
||||
try {
|
||||
await _channel.invokeMethod('openNotificationAccessSettings');
|
||||
} on MissingPluginException {
|
||||
// Notification access is Android-only.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> openBatteryOptimizationSettings() async {
|
||||
try {
|
||||
await _channel.invokeMethod('openBatteryOptimizationSettings');
|
||||
} on MissingPluginException {
|
||||
// Background battery settings are Android-only.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> openBackgroundStartupSettings() async {
|
||||
try {
|
||||
await _channel.invokeMethod('openBackgroundStartupSettings');
|
||||
} on MissingPluginException {
|
||||
// Vendor background startup settings are Android-only.
|
||||
}
|
||||
}
|
||||
|
||||
static void onRecognitionAction(
|
||||
void Function(Map<String, dynamic> action) callback,
|
||||
) {
|
||||
_recognitionAction = callback;
|
||||
_installHandler();
|
||||
}
|
||||
|
||||
static Future<void> onScreenshotReady(
|
||||
void Function(String path) callback, {
|
||||
void Function(String error)? onError,
|
||||
}) async {
|
||||
_screenshotReady = callback;
|
||||
_screenshotError = onError;
|
||||
_installHandler();
|
||||
}
|
||||
|
||||
static void _installHandler() {
|
||||
if (_handlerInstalled) return;
|
||||
_handlerInstalled = true;
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
if (call.method == 'onScreenshotReady') {
|
||||
final path = call.arguments as String?;
|
||||
if (path != null) _screenshotReady?.call(path);
|
||||
} else if (call.method == 'onScreenshotError') {
|
||||
_screenshotError?.call(call.arguments?.toString() ?? '截屏失败,请重试');
|
||||
} else if (call.method == 'onSpeechEvent') {
|
||||
final raw = Map<Object?, Object?>.from(call.arguments as Map);
|
||||
final type = raw['type']?.toString() ?? 'error';
|
||||
final event = SpeechEvent(
|
||||
type: type,
|
||||
text: raw['text']?.toString(),
|
||||
rms: (raw['rms'] as num?)?.toDouble() ?? 0,
|
||||
);
|
||||
_speechEvent?.call(event);
|
||||
if (type == 'final' || type == 'error' || type == 'cancelled') {
|
||||
_speechEvent = null;
|
||||
}
|
||||
} else if (call.method == 'onRecognitionAction') {
|
||||
final raw = Map<Object?, Object?>.from(call.arguments as Map);
|
||||
_recognitionAction?.call(
|
||||
raw.map((key, value) => MapEntry(key.toString(), value)),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miaoji_zhang/shared/services/local_database.dart';
|
||||
|
||||
class OnlineFeatureRequiredException implements Exception {
|
||||
final String message;
|
||||
const OnlineFeatureRequiredException([this.message = '此功能需要登录并连接网络后使用']);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class SessionStore extends ChangeNotifier {
|
||||
SessionStore._();
|
||||
|
||||
static final instance = SessionStore._();
|
||||
|
||||
SharedPreferences? _preferences;
|
||||
String _mode = 'none';
|
||||
int? _userId;
|
||||
String? _username;
|
||||
String? _nickname;
|
||||
String _appMode = 'normal';
|
||||
bool _onboardingDone = true;
|
||||
bool _cloudSyncEnabled = true;
|
||||
bool _needsReauth = false;
|
||||
bool _aiEnabled = false;
|
||||
|
||||
bool get isGuest => _mode == 'guest';
|
||||
bool get isAccount => _mode == 'account' && _userId != null;
|
||||
bool get hasSession => isGuest || isAccount;
|
||||
bool get cloudSyncEnabled => isAccount && _cloudSyncEnabled;
|
||||
bool get needsReauth => _needsReauth;
|
||||
bool get aiEnabled => isAccount && _aiEnabled;
|
||||
bool get shouldUseLocalOnly => isGuest || !cloudSyncEnabled || needsReauth;
|
||||
int? get userId => _userId;
|
||||
String? get username => _username;
|
||||
String? get nickname => _nickname;
|
||||
String get appMode => _appMode;
|
||||
bool get onboardingDone => _onboardingDone;
|
||||
String get namespace => isGuest ? 'guest' : 'user_${_userId ?? 'none'}';
|
||||
|
||||
Future<void> initialize() async {
|
||||
_preferences ??= await SharedPreferences.getInstance();
|
||||
final preferences = _preferences!;
|
||||
_mode = preferences.getString('session_mode') ?? 'none';
|
||||
_userId = preferences.getInt('session_user_id');
|
||||
_username = preferences.getString('session_username');
|
||||
_nickname = preferences.getString('session_nickname');
|
||||
_appMode = preferences.getString('session_app_mode') ?? 'normal';
|
||||
_onboardingDone = preferences.getBool('session_onboarding_done') ?? true;
|
||||
_cloudSyncEnabled = preferences.getBool('session_cloud_sync') ?? true;
|
||||
_needsReauth = preferences.getBool('session_needs_reauth') ?? false;
|
||||
_aiEnabled =
|
||||
preferences.getBool('session_ai_enabled') ?? (_mode == 'account');
|
||||
if (hasSession) {
|
||||
await LocalDatabase.instance.openNamespace(namespace);
|
||||
} else if (_mode != 'none') {
|
||||
_mode = 'none';
|
||||
await preferences.setString('session_mode', 'none');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startGuest() async {
|
||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
||||
_mode = 'guest';
|
||||
_userId = null;
|
||||
_username = '游客';
|
||||
_nickname = '游客';
|
||||
_appMode = 'normal';
|
||||
_onboardingDone = true;
|
||||
_cloudSyncEnabled = false;
|
||||
_needsReauth = false;
|
||||
_aiEnabled = false;
|
||||
await preferences.setString('session_mode', _mode);
|
||||
await preferences.remove('session_user_id');
|
||||
await preferences.setString('session_username', _username!);
|
||||
await preferences.setString('session_nickname', _nickname!);
|
||||
await preferences.setString('session_app_mode', _appMode);
|
||||
await preferences.setBool('session_onboarding_done', true);
|
||||
await preferences.setBool('session_cloud_sync', false);
|
||||
await preferences.setBool('session_needs_reauth', false);
|
||||
await LocalDatabase.instance.openNamespace(namespace);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> activateAccount({
|
||||
required int userId,
|
||||
required String username,
|
||||
String? nickname,
|
||||
required String appMode,
|
||||
required bool onboardingDone,
|
||||
required bool aiEnabled,
|
||||
}) async {
|
||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
||||
_mode = 'account';
|
||||
_userId = userId;
|
||||
_username = username;
|
||||
_nickname = nickname;
|
||||
_appMode = appMode;
|
||||
_onboardingDone = onboardingDone;
|
||||
_aiEnabled = aiEnabled;
|
||||
_cloudSyncEnabled = preferences.getBool('cloud_sync_user_$userId') ?? true;
|
||||
_needsReauth = false;
|
||||
await preferences.setString('session_mode', _mode);
|
||||
await preferences.setInt('session_user_id', userId);
|
||||
await preferences.setString('session_username', username);
|
||||
if (nickname == null) {
|
||||
await preferences.remove('session_nickname');
|
||||
} else {
|
||||
await preferences.setString('session_nickname', nickname);
|
||||
}
|
||||
await preferences.setString('session_app_mode', appMode);
|
||||
await preferences.setBool('session_onboarding_done', onboardingDone);
|
||||
await preferences.setBool('session_ai_enabled', aiEnabled);
|
||||
await preferences.setBool('session_cloud_sync', _cloudSyncEnabled);
|
||||
await preferences.setBool('session_needs_reauth', false);
|
||||
await LocalDatabase.instance.openNamespace(namespace);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateNickname(String? nickname) async {
|
||||
_nickname = nickname;
|
||||
notifyListeners();
|
||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
||||
if (nickname == null) {
|
||||
await preferences.remove('session_nickname');
|
||||
} else {
|
||||
await preferences.setString('session_nickname', nickname);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateCachedProfile({
|
||||
required String appMode,
|
||||
required bool onboardingDone,
|
||||
String? nickname,
|
||||
bool? aiEnabled,
|
||||
}) async {
|
||||
_appMode = appMode;
|
||||
_onboardingDone = onboardingDone;
|
||||
_nickname = nickname ?? _nickname;
|
||||
_aiEnabled = aiEnabled ?? _aiEnabled;
|
||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
||||
await preferences.setString('session_app_mode', _appMode);
|
||||
await preferences.setBool('session_onboarding_done', _onboardingDone);
|
||||
await preferences.setBool('session_ai_enabled', _aiEnabled);
|
||||
if (_nickname != null) {
|
||||
await preferences.setString('session_nickname', _nickname!);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setAiEnabled(bool enabled) async {
|
||||
if (_aiEnabled == enabled) return;
|
||||
_aiEnabled = enabled;
|
||||
if (!enabled) _appMode = 'normal';
|
||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
||||
await preferences.setBool('session_ai_enabled', enabled);
|
||||
if (!enabled) await preferences.setString('session_app_mode', 'normal');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setCloudSyncEnabled(bool enabled) async {
|
||||
if (!isAccount) return;
|
||||
_cloudSyncEnabled = enabled;
|
||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
||||
await preferences.setBool('session_cloud_sync', enabled);
|
||||
await preferences.setBool('cloud_sync_user_$_userId', enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> markNeedsReauth() async {
|
||||
if (!isAccount || _needsReauth) return;
|
||||
_needsReauth = true;
|
||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
||||
await preferences.setBool('session_needs_reauth', true);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> clearActiveSession() async {
|
||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
||||
_mode = 'none';
|
||||
_userId = null;
|
||||
_username = null;
|
||||
_nickname = null;
|
||||
_appMode = 'normal';
|
||||
_onboardingDone = true;
|
||||
_cloudSyncEnabled = true;
|
||||
_needsReauth = false;
|
||||
_aiEnabled = false;
|
||||
await preferences.setString('session_mode', 'none');
|
||||
await preferences.remove('session_user_id');
|
||||
await preferences.remove('session_username');
|
||||
await preferences.remove('session_nickname');
|
||||
await preferences.remove('session_needs_reauth');
|
||||
LocalDatabase.instance.close();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void requireOnline([String? message]) {
|
||||
if (shouldUseLocalOnly) {
|
||||
throw OnlineFeatureRequiredException(message ?? '此功能需要登录并连接网络后使用');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
class ShanghaiTime {
|
||||
ShanghaiTime._();
|
||||
|
||||
static const offset = Duration(hours: 8);
|
||||
|
||||
/// A device-timezone-independent civil clock for Asia/Shanghai.
|
||||
static DateTime get now => toCivil(DateTime.now());
|
||||
|
||||
static DateTime toCivil(DateTime instant) {
|
||||
final shifted = instant.toUtc().add(offset);
|
||||
return DateTime(
|
||||
shifted.year,
|
||||
shifted.month,
|
||||
shifted.day,
|
||||
shifted.hour,
|
||||
shifted.minute,
|
||||
shifted.second,
|
||||
shifted.millisecond,
|
||||
shifted.microsecond,
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime civilToUtc(DateTime civil) => DateTime.utc(
|
||||
civil.year,
|
||||
civil.month,
|
||||
civil.day,
|
||||
civil.hour,
|
||||
civil.minute,
|
||||
civil.second,
|
||||
civil.millisecond,
|
||||
civil.microsecond,
|
||||
).subtract(offset);
|
||||
|
||||
/// Parses API/cache instants. Legacy values without a zone were stored as UTC.
|
||||
static DateTime parseUtcInstant(String value) {
|
||||
final parsed = DateTime.parse(value);
|
||||
if (value.endsWith('Z') || RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(value)) {
|
||||
return parsed.toUtc();
|
||||
}
|
||||
return DateTime.utc(
|
||||
parsed.year,
|
||||
parsed.month,
|
||||
parsed.day,
|
||||
parsed.hour,
|
||||
parsed.minute,
|
||||
parsed.second,
|
||||
parsed.millisecond,
|
||||
parsed.microsecond,
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime parseCivil(String value) => toCivil(parseUtcInstant(value));
|
||||
|
||||
static DateTime startOfDayUtc(DateTime civil) =>
|
||||
civilToUtc(DateTime(civil.year, civil.month, civil.day));
|
||||
|
||||
static ({DateTime start, DateTime end}) monthRangeUtc(int year, int month) =>
|
||||
(
|
||||
start: civilToUtc(DateTime(year, month, 1)),
|
||||
end: civilToUtc(DateTime(year, month + 1, 1)),
|
||||
);
|
||||
|
||||
static ({DateTime start, DateTime end}) yearRangeUtc(int year) => (
|
||||
start: civilToUtc(DateTime(year, 1, 1)),
|
||||
end: civilToUtc(DateTime(year + 1, 1, 1)),
|
||||
);
|
||||
|
||||
static ({DateTime start, DateTime end}) weekRangeUtc(DateTime civil) {
|
||||
final date = DateTime(civil.year, civil.month, civil.day);
|
||||
final start = date.subtract(Duration(days: date.weekday - DateTime.monday));
|
||||
return (
|
||||
start: civilToUtc(start),
|
||||
end: civilToUtc(start.add(const Duration(days: 7))),
|
||||
);
|
||||
}
|
||||
|
||||
static String formatCivil(DateTime value) =>
|
||||
'${value.year.toString().padLeft(4, '0')}-'
|
||||
'${value.month.toString().padLeft(2, '0')}-'
|
||||
'${value.day.toString().padLeft(2, '0')} '
|
||||
'${value.hour.toString().padLeft(2, '0')}:'
|
||||
'${value.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
static String formatDateTime(DateTime instant) {
|
||||
final value = toCivil(instant);
|
||||
return formatCivil(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.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';
|
||||
|
||||
enum CloudSyncPhase {
|
||||
idle,
|
||||
syncing,
|
||||
synced,
|
||||
pending,
|
||||
offline,
|
||||
disabled,
|
||||
reauthenticate,
|
||||
failed,
|
||||
}
|
||||
|
||||
class SyncService extends ChangeNotifier {
|
||||
SyncService._();
|
||||
|
||||
static final instance = SyncService._();
|
||||
static final conflictRevision = ValueNotifier<int>(0);
|
||||
|
||||
final _dio = ApiClient.instance.dio;
|
||||
Future<void>? _running;
|
||||
CloudSyncPhase _phase = CloudSyncPhase.idle;
|
||||
DateTime? _lastSyncedAt;
|
||||
int _pendingCount = 0;
|
||||
bool _connectivityInterrupted = false;
|
||||
bool _hadFailures = false;
|
||||
|
||||
int get conflictCount => LocalDatabase.instance.conflicts().length;
|
||||
CloudSyncPhase get phase => _phase;
|
||||
DateTime? get lastSyncedAt => _lastSyncedAt;
|
||||
int get pendingCount => _pendingCount;
|
||||
|
||||
String get statusLabel => switch (_phase) {
|
||||
CloudSyncPhase.syncing => '正在同步',
|
||||
CloudSyncPhase.synced => '已同步',
|
||||
CloudSyncPhase.pending => '$_pendingCount 项待同步',
|
||||
CloudSyncPhase.offline => '网络不可用,$_pendingCount 项待同步',
|
||||
CloudSyncPhase.disabled => '云同步已关闭',
|
||||
CloudSyncPhase.reauthenticate => '登录已过期,等待重新登录',
|
||||
CloudSyncPhase.failed => '同步失败,点击重试',
|
||||
CloudSyncPhase.idle => _pendingCount > 0 ? '$_pendingCount 项待同步' : '等待同步',
|
||||
};
|
||||
|
||||
void refreshLocalStatus() {
|
||||
final session = SessionStore.instance;
|
||||
if (!session.isAccount || !session.cloudSyncEnabled) {
|
||||
_setPhase(CloudSyncPhase.disabled, 0);
|
||||
return;
|
||||
}
|
||||
if (session.needsReauth) {
|
||||
_setPhase(CloudSyncPhase.reauthenticate, _safePendingCount());
|
||||
return;
|
||||
}
|
||||
final pending = _safePendingCount();
|
||||
_setPhase(
|
||||
pending > 0 ? CloudSyncPhase.pending : CloudSyncPhase.idle,
|
||||
pending,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> run() {
|
||||
final session = SessionStore.instance;
|
||||
if (!session.isAccount || !session.cloudSyncEnabled) {
|
||||
_setPhase(CloudSyncPhase.disabled, 0);
|
||||
return Future.value();
|
||||
}
|
||||
if (session.needsReauth) {
|
||||
_setPhase(CloudSyncPhase.reauthenticate, _safePendingCount());
|
||||
return Future.value();
|
||||
}
|
||||
final active = _running;
|
||||
if (active != null) return active;
|
||||
_connectivityInterrupted = false;
|
||||
_hadFailures = false;
|
||||
_setPhase(CloudSyncPhase.syncing, _safePendingCount());
|
||||
return _running = _execute().whenComplete(() => _running = null);
|
||||
}
|
||||
|
||||
Future<void> _execute() async {
|
||||
try {
|
||||
await _runCore();
|
||||
if (SessionStore.instance.needsReauth) {
|
||||
_setPhase(CloudSyncPhase.reauthenticate, _safePendingCount());
|
||||
return;
|
||||
}
|
||||
final pending = _safePendingCount();
|
||||
if (_connectivityInterrupted) {
|
||||
_setPhase(CloudSyncPhase.offline, pending);
|
||||
} else if (_hadFailures) {
|
||||
_setPhase(CloudSyncPhase.failed, pending);
|
||||
} else if (pending > 0) {
|
||||
_setPhase(CloudSyncPhase.pending, pending);
|
||||
} else {
|
||||
_lastSyncedAt = DateTime.now();
|
||||
_setPhase(CloudSyncPhase.synced, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
final pending = _safePendingCount();
|
||||
_setPhase(
|
||||
isConnectivityError(error)
|
||||
? CloudSyncPhase.offline
|
||||
: CloudSyncPhase.failed,
|
||||
pending,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _safePendingCount() {
|
||||
try {
|
||||
return LocalDatabase.instance.pendingSync().length;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _setPhase(CloudSyncPhase phase, int pending) {
|
||||
if (_phase == phase && _pendingCount == pending) return;
|
||||
_phase = phase;
|
||||
_pendingCount = pending;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _runCore() async {
|
||||
final database = LocalDatabase.instance;
|
||||
for (final item in database.pendingSync()) {
|
||||
final queueId = item['id'] as int;
|
||||
try {
|
||||
await _process(item);
|
||||
database.completeSync(queueId);
|
||||
} on _DependencyPending {
|
||||
continue;
|
||||
} on DioException catch (error) {
|
||||
if (error.response?.statusCode == 409) {
|
||||
final data = error.response?.data;
|
||||
final remote = data is Map && data['server'] is Map
|
||||
? Map<String, dynamic>.from(data['server'] as Map)
|
||||
: <String, dynamic>{};
|
||||
database.addConflict(
|
||||
item['entityType'] as String,
|
||||
item['entityId'] as int,
|
||||
item['operation'] as String,
|
||||
item['payload'] as Map<String, dynamic>,
|
||||
remote,
|
||||
);
|
||||
database.completeSync(queueId);
|
||||
conflictRevision.value++;
|
||||
continue;
|
||||
}
|
||||
if (error.response?.statusCode == 401) {
|
||||
await SessionStore.instance.markNeedsReauth();
|
||||
return;
|
||||
}
|
||||
if (isConnectivityError(error)) {
|
||||
_connectivityInterrupted = true;
|
||||
return;
|
||||
}
|
||||
database.failSync(queueId);
|
||||
_hadFailures = true;
|
||||
} catch (_) {
|
||||
database.failSync(queueId);
|
||||
_hadFailures = true;
|
||||
}
|
||||
}
|
||||
await CurrentLedgerStore.instance.ensureLoaded(force: true);
|
||||
}
|
||||
|
||||
Future<void> _process(Map<String, dynamic> item) async {
|
||||
final entityType = item['entityType'] as String;
|
||||
final entityId = item['entityId'] as int;
|
||||
final operation = item['operation'] as String;
|
||||
final payload = Map<String, dynamic>.from(
|
||||
item['payload'] as Map<String, dynamic>,
|
||||
);
|
||||
switch (entityType) {
|
||||
case 'ledger':
|
||||
await _syncLedger(entityId, operation, payload);
|
||||
return;
|
||||
case 'category':
|
||||
await _syncCategory(entityId, operation, payload);
|
||||
return;
|
||||
case 'transaction':
|
||||
await _syncTransaction(entityId, operation, payload);
|
||||
return;
|
||||
case 'budget':
|
||||
await _syncBudget(operation, payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncLedger(
|
||||
int entityId,
|
||||
String operation,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
if (operation == 'create') {
|
||||
final response = await _dio.post('/api/ledgers', data: payload);
|
||||
LocalDatabase.instance.remapLedger(
|
||||
entityId,
|
||||
Map<String, dynamic>.from(response.data as Map),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final remoteId = _resolvedId('ledger', entityId);
|
||||
if (operation == 'update') {
|
||||
await _dio.put('/api/ledgers/$remoteId', data: payload);
|
||||
} else if (operation == 'delete') {
|
||||
await _dio.delete('/api/ledgers/$remoteId');
|
||||
} else if (operation == 'select') {
|
||||
await _dio.put('/api/ledgers/$remoteId/default');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncCategory(
|
||||
int entityId,
|
||||
String operation,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
if (operation == 'create') {
|
||||
final response = await _dio.post('/api/categories', data: payload);
|
||||
LocalDatabase.instance.remapCategory(
|
||||
entityId,
|
||||
Map<String, dynamic>.from(response.data as Map),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (operation == 'reorder') {
|
||||
final ids = (payload['categoryIds'] as List)
|
||||
.map((value) => _resolvedId('category', (value as num).toInt()))
|
||||
.toList();
|
||||
await _dio.put(
|
||||
'/api/categories/reorder',
|
||||
data: {'type': payload['type'], 'categoryIds': ids},
|
||||
);
|
||||
return;
|
||||
}
|
||||
final remoteId = _resolvedId('category', entityId);
|
||||
if (operation == 'update') {
|
||||
await _dio.put('/api/categories/$remoteId', data: payload);
|
||||
} else if (operation == 'delete') {
|
||||
await _dio.delete('/api/categories/$remoteId');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncTransaction(
|
||||
int entityId,
|
||||
String operation,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final data = Map<String, dynamic>.from(payload);
|
||||
if (data['ledgerId'] is num) {
|
||||
data['ledgerId'] = _resolvedId(
|
||||
'ledger',
|
||||
(data['ledgerId'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
if (data['categoryId'] is num) {
|
||||
data['categoryId'] = _resolvedId(
|
||||
'category',
|
||||
(data['categoryId'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
if (operation == 'create') {
|
||||
final response = await _dio.post('/api/transactions', data: data);
|
||||
LocalDatabase.instance.replaceLocalTransaction(
|
||||
entityId,
|
||||
Map<String, dynamic>.from(response.data as Map),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final remoteId = _resolvedId('transaction', entityId);
|
||||
if (operation == 'update') {
|
||||
final response = await _dio.put(
|
||||
'/api/transactions/$remoteId',
|
||||
data: data,
|
||||
);
|
||||
LocalDatabase.instance.cacheTransaction(
|
||||
Map<String, dynamic>.from(response.data as Map),
|
||||
);
|
||||
} else if (operation == 'delete') {
|
||||
await _dio.delete(
|
||||
'/api/transactions/$remoteId',
|
||||
queryParameters: {
|
||||
if (data['baseUpdatedAt'] != null)
|
||||
'baseUpdatedAt': data['baseUpdatedAt'],
|
||||
},
|
||||
);
|
||||
} else if (operation == 'restore') {
|
||||
final response = await _dio.post(
|
||||
'/api/transactions/$remoteId/restore',
|
||||
queryParameters: {
|
||||
if (data['baseUpdatedAt'] != null)
|
||||
'baseUpdatedAt': data['baseUpdatedAt'],
|
||||
},
|
||||
);
|
||||
LocalDatabase.instance.cacheTransaction(
|
||||
Map<String, dynamic>.from(response.data as Map),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncBudget(
|
||||
String operation,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final ledgerId = _resolvedId(
|
||||
'ledger',
|
||||
(payload['ledgerId'] as num).toInt(),
|
||||
);
|
||||
if (operation == 'batch') {
|
||||
final items = (payload['items'] as List).map((value) {
|
||||
final item = Map<String, dynamic>.from(value as Map);
|
||||
if (item['categoryId'] is num) {
|
||||
item['categoryId'] = _resolvedId(
|
||||
'category',
|
||||
(item['categoryId'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
await _dio.put(
|
||||
'/api/budgets/batch',
|
||||
queryParameters: {
|
||||
'year': payload['year'],
|
||||
'month': payload['month'],
|
||||
'ledgerId': ledgerId,
|
||||
},
|
||||
data: {'recurring': payload['recurring'], 'items': items},
|
||||
);
|
||||
return;
|
||||
}
|
||||
final categoryId = payload['categoryId'] is num
|
||||
? _resolvedId('category', (payload['categoryId'] as num).toInt())
|
||||
: null;
|
||||
await _dio.put(
|
||||
'/api/budgets',
|
||||
queryParameters: {
|
||||
'year': payload['year'],
|
||||
'month': payload['month'],
|
||||
'ledgerId': ledgerId,
|
||||
},
|
||||
data: {
|
||||
'categoryId': categoryId,
|
||||
'amount': payload['amount'],
|
||||
'recurring': payload['recurring'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int _resolvedId(String entityType, int value) {
|
||||
final resolved = LocalDatabase.instance.remoteId(entityType, value);
|
||||
if (resolved == null) throw const _DependencyPending();
|
||||
return resolved;
|
||||
}
|
||||
|
||||
Future<void> resolveConflict(
|
||||
Map<String, dynamic> conflict, {
|
||||
required bool keepLocal,
|
||||
}) async {
|
||||
final database = LocalDatabase.instance;
|
||||
final remote = conflict['remote'] as Map<String, dynamic>;
|
||||
if (keepLocal && conflict['entityType'] == 'transaction') {
|
||||
final entityId = conflict['entityId'] as int;
|
||||
final remoteId = _resolvedId('transaction', entityId);
|
||||
final operation = conflict['operation'] as String;
|
||||
final payload = Map<String, dynamic>.from(
|
||||
conflict['local'] as Map<String, dynamic>,
|
||||
)..remove('baseUpdatedAt');
|
||||
if (operation == 'update') {
|
||||
final response = await _dio.put(
|
||||
'/api/transactions/$remoteId',
|
||||
data: payload,
|
||||
);
|
||||
database.cacheTransaction(
|
||||
Map<String, dynamic>.from(response.data as Map),
|
||||
);
|
||||
} else if (operation == 'delete') {
|
||||
await _dio.delete('/api/transactions/$remoteId');
|
||||
} else if (operation == 'restore') {
|
||||
final response = await _dio.post('/api/transactions/$remoteId/restore');
|
||||
database.cacheTransaction(
|
||||
Map<String, dynamic>.from(response.data as Map),
|
||||
);
|
||||
}
|
||||
} else if (!keepLocal && remote.isNotEmpty) {
|
||||
database.cacheTransaction(remote);
|
||||
}
|
||||
database.resolveConflict(conflict['id'] as int);
|
||||
conflictRevision.value++;
|
||||
}
|
||||
}
|
||||
|
||||
class _DependencyPending implements Exception {
|
||||
const _DependencyPending();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class TransactionEvents {
|
||||
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
||||
|
||||
static void notifyChanged() {
|
||||
revision.value++;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user