Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
@@ -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++;
}
}