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
+131
View File
@@ -0,0 +1,131 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
class ApiClient {
ApiClient._();
static final ApiClient instance = ApiClient._();
static const _storage = FlutterSecureStorage();
static const _tokenKey = 'auth_token';
static final sessionExpired = ValueNotifier<int>(0);
static bool _handlingUnauthorized = false;
static const _internalBuild = bool.fromEnvironment('INTERNAL_BUILD');
static const _internalTestBaseUrl = 'https://lt.frp-say.com:38012';
static const _configuredBaseUrl = String.fromEnvironment('API_BASE_URL');
static const _temporaryFrpHost = 'lt.frp-say.com';
static const _temporaryFrpPort = 38012;
static const _temporaryFrpCertificateSha1 =
'509C3210161E72FB9DA5183D3D2152F63871C31F';
static const String baseUrl = _configuredBaseUrl != ''
? _configuredBaseUrl
: _internalBuild
? _internalTestBaseUrl
: 'https://api.invalid';
static bool get isInternalBuild => _internalBuild;
late final Dio dio = _createDio();
Dio _createDio() {
final client =
Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 120),
),
)
..interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _storage.read(key: _tokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
final unauthorized = error.response?.statusCode == 401;
final data = error.response?.data;
final aiDenied =
error.response?.statusCode == 403 &&
data is Map &&
data['code'] == 'AI_PERMISSION_DENIED';
if (aiDenied) {
await SessionStore.instance.setAiEnabled(false);
}
final isAuthRequest = error.requestOptions.path.startsWith(
'/api/auth/',
);
if (unauthorized && !isAuthRequest && !_handlingUnauthorized) {
_handlingUnauthorized = true;
await SessionStore.instance.markNeedsReauth();
sessionExpired.value++;
Future<void>.delayed(
const Duration(seconds: 1),
() => _handlingUnauthorized = false,
);
}
handler.next(error);
},
),
);
final apiUri = Uri.tryParse(baseUrl);
if (_internalBuild &&
apiUri?.scheme == 'https' &&
apiUri?.host == _temporaryFrpHost &&
apiUri?.port == _temporaryFrpPort) {
client.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final httpClient = HttpClient();
httpClient.badCertificateCallback = (certificate, host, port) {
final certificateSha1 = certificate.sha1
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
return host == _temporaryFrpHost &&
port == _temporaryFrpPort &&
certificateSha1 == _temporaryFrpCertificateSha1;
};
return httpClient;
},
);
}
return client;
}
Future<void> saveToken(String token) =>
_storage.write(key: _tokenKey, value: token);
Future<String?> readToken() => _storage.read(key: _tokenKey);
Future<void> clearToken() => _storage.delete(key: _tokenKey);
}
bool isConnectivityError(Object error) =>
error is DioException &&
(error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.connectionError ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout);
String apiErrorMessage(Object e) {
if (e is OnlineFeatureRequiredException) return e.message;
if (e is StateError) return e.message;
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data['message'] != null)
return data['message'] as String;
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.receiveTimeout) {
return '无法连接服务器,请检查网络';
}
}
return '出错了,请稍后再试';
}
+265
View File
@@ -0,0 +1,265 @@
import 'package:dio/dio.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_export_service.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
class AiCompanion {
final String avatarKey;
final String personaKey;
final String? customName;
final int roastLevel, stickerFrequency, proactiveLevel;
AiCompanion.fromJson(Map<String, dynamic> json)
: avatarKey = json['avatarKey'] as String,
personaKey = json['personaKey'] as String,
customName = json['customName'] as String?,
roastLevel = json['roastLevel'] as int,
stickerFrequency = json['stickerFrequency'] as int,
proactiveLevel = json['proactiveLevel'] as int;
}
class UserProfile {
final int userId;
final String username;
final String? nickname;
final String appMode;
final AiCompanion? aiCompanion;
final bool onboardingDone;
final bool aiEnabled;
UserProfile.fromJson(Map<String, dynamic> json)
: userId = (json['userId'] as num).toInt(),
username = json['username'] as String,
nickname = json['nickname'] as String?,
appMode = json['appMode'] as String,
aiCompanion = json['aiCompanion'] == null
? null
: AiCompanion.fromJson(json['aiCompanion'] as Map<String, dynamic>),
onboardingDone = json['onboardingDone'] as bool,
aiEnabled =
((json['permissions'] as Map<String, dynamic>?)?['ai'] as bool?) ??
true;
const UserProfile.local({
required this.userId,
required this.username,
required this.nickname,
required this.appMode,
required this.onboardingDone,
this.aiEnabled = false,
}) : aiCompanion = null;
}
class AuthApi {
static final _dio = ApiClient.instance.dio;
static Future<void> register(
String username,
String password, {
required bool agreedToTerms,
}) async {
final response = await _dio.post(
'/api/auth/register',
data: {
'username': username,
'password': password,
'agreedToTerms': agreedToTerms,
},
);
await ApiClient.instance.saveToken(response.data['token'] as String);
await me();
}
static Future<bool> login(String username, String password) async {
final response = await _dio.post(
'/api/auth/login',
data: {'username': username, 'password': password},
);
await ApiClient.instance.saveToken(response.data['token'] as String);
return response.data['accountClosureCancelled'] as bool? ?? false;
}
static Future<UserProfile> me() async {
final session = SessionStore.instance;
if (session.isGuest || (session.isAccount && session.shouldUseLocalOnly)) {
return _localProfile();
}
try {
final response = await _dio.get('/api/users/me');
final profile = UserProfile.fromJson(
response.data as Map<String, dynamic>,
);
await _cacheProfile(profile);
return profile;
} catch (error) {
if (session.isAccount &&
(isConnectivityError(error) || session.needsReauth)) {
return _localProfile();
}
rethrow;
}
}
static Future<UserProfile> completeOnboarding({
required String appMode,
required String avatarKey,
required String personaKey,
String? customName,
}) async {
SessionStore.instance.requireOnline('完成账号初始化需要连接网络');
final response = await _dio.post(
'/api/users/me/onboarding',
data: {
'appMode': appMode,
'avatarKey': avatarKey,
'personaKey': personaKey,
'customName': customName,
},
);
final profile = UserProfile.fromJson(response.data as Map<String, dynamic>);
await _cacheProfile(profile);
return profile;
}
static Future<UserProfile> switchMode(String appMode) async {
SessionStore.instance.requireOnline('切换 AI 模式需要登录并连接网络');
final response = await _dio.put(
'/api/users/me/mode',
data: {'appMode': appMode},
);
final profile = UserProfile.fromJson(response.data as Map<String, dynamic>);
await _cacheProfile(profile);
return profile;
}
static Future<UserProfile> updateCompanion({
String? avatarKey,
String? personaKey,
String? customName,
int? roastLevel,
int? stickerFrequency,
int? proactiveLevel,
}) async {
SessionStore.instance.requireOnline('AI 伙伴设置需要登录并连接网络');
final response = await _dio.put(
'/api/users/me/companion',
data: {
'avatarKey': avatarKey,
'personaKey': personaKey,
'customName': customName,
'roastLevel': roastLevel,
'stickerFrequency': stickerFrequency,
'proactiveLevel': proactiveLevel,
},
);
final profile = UserProfile.fromJson(response.data as Map<String, dynamic>);
await _cacheProfile(profile);
return profile;
}
static Future<UserProfile> updateProfile(String nickname) async {
final session = SessionStore.instance;
final normalized = nickname.trim();
if (session.isGuest) {
await session.updateNickname(normalized);
return _localProfile();
}
session.requireOnline('修改云端资料需要连接网络');
final previous = session.nickname;
await session.updateNickname(normalized);
try {
final response = await _dio.put(
'/api/users/me/profile',
data: {'nickname': normalized},
);
final profile = UserProfile.fromJson(
response.data as Map<String, dynamic>,
);
await _cacheProfile(profile);
return profile;
} catch (_) {
await session.updateNickname(previous);
rethrow;
}
}
static Future<void> changePassword(
String currentPassword,
String newPassword,
) async {
SessionStore.instance.requireOnline('修改密码需要连接网络');
final response = await _dio.put(
'/api/users/me/password',
data: {'currentPassword': currentPassword, 'newPassword': newPassword},
);
await ApiClient.instance.saveToken(response.data['token'] as String);
}
static Future<List<int>> exportData() async {
if (SessionStore.instance.shouldUseLocalOnly) {
return LocalExportService.buildZip();
}
try {
final response = await _dio.get<List<int>>(
'/api/users/me/export',
options: Options(responseType: ResponseType.bytes),
);
return response.data ?? const [];
} catch (error) {
if (isConnectivityError(error)) return LocalExportService.buildZip();
rethrow;
}
}
static Future<DateTime> requestAccountClosure(
String password,
String confirmationText,
) async {
SessionStore.instance.requireOnline('注销账号需要连接网络');
final response = await _dio.post(
'/api/users/me/closure',
data: {'password': password, 'confirmationText': confirmationText},
);
final scheduledAt = ShanghaiTime.parseCivil(
response.data['scheduledAt'] as String,
);
await logout();
return scheduledAt;
}
static Future<void> deleteAccount(String password) async {
SessionStore.instance.requireOnline('注销账号需要连接网络');
await _dio.delete('/api/users/me', data: {'password': password});
await logout();
}
static Future<void> logout() async {
CurrentLedgerStore.instance.clear();
await ApiClient.instance.clearToken();
await SessionStore.instance.clearActiveSession();
}
static UserProfile _localProfile() {
final session = SessionStore.instance;
return UserProfile.local(
userId: session.userId ?? 0,
username: session.username ?? '游客',
nickname: session.nickname,
appMode: session.isGuest ? 'normal' : session.appMode,
onboardingDone: session.isGuest || session.onboardingDone,
aiEnabled: session.aiEnabled,
);
}
static Future<void> _cacheProfile(UserProfile profile) =>
SessionStore.instance.activateAccount(
userId: profile.userId,
username: profile.username,
nickname: profile.nickname,
appMode: profile.appMode,
onboardingDone: profile.onboardingDone,
aiEnabled: profile.aiEnabled,
);
}
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
class BrandConfig {
final String appName, slogan, logoUrl;
final bool voiceEnabled;
final bool imageEnabled;
final bool screenshotEnabled;
final bool stickersEnabled;
final bool aiAutoBookEnabled;
BrandConfig.fromJson(Map<String, dynamic> j)
: appName = _appName(j['appName']),
slogan = j['slogan'] as String? ?? '',
logoUrl = j['logoUrl'] as String? ?? '',
voiceEnabled = _feature(j, 'voice'),
imageEnabled = _feature(j, 'image'),
screenshotEnabled = _feature(j, 'screenshot'),
stickersEnabled = _feature(j, 'stickers'),
aiAutoBookEnabled = _feature(j, 'aiAutoBook');
static String _appName(Object? value) {
final name = value?.toString().trim();
return name == null || name.isEmpty || name == '喵记账' || name == '喵记'
? '记之'
: name;
}
static bool _feature(Map<String, dynamic> json, String key) {
final features = json['features'];
return features is Map ? features[key] as bool? ?? true : true;
}
}
class AvatarItem {
final String key, defaultName, speechTic;
final String? imageUrl;
AvatarItem.fromJson(Map<String, dynamic> j)
: key = j['key'] as String,
defaultName = j['defaultName'] as String,
speechTic = j['speechTic'] as String? ?? '',
imageUrl = j['imageUrl'] as String?;
}
class PersonaItem {
final String key, name, description, sampleLine;
PersonaItem.fromJson(Map<String, dynamic> j)
: key = j['key'] as String,
name = j['name'] as String,
description = j['description'] as String? ?? '',
sampleLine = j['sampleLine'] as String? ?? '';
}
class CompanionDisplay {
final String name;
final String avatarKey;
const CompanionDisplay({required this.name, required this.avatarKey});
}
class PublicConfigApi {
static final _dio = ApiClient.instance.dio;
static BrandConfig? _cachedBrand;
static Future<void> init() async {
if (!SessionStore.instance.isGuest) {
try {
final b = await _dio.get('/api/public/brand');
_cachedBrand = BrandConfig.fromJson(b.data as Map<String, dynamic>);
} catch (_) {}
}
await refreshCompanion();
}
static String get appName => _cachedBrand?.appName ?? '记之';
static String get slogan => _cachedBrand?.slogan ?? '';
static bool get voiceEnabled => _cachedBrand?.voiceEnabled ?? true;
static bool get imageEnabled => _cachedBrand?.imageEnabled ?? true;
static bool get screenshotEnabled => _cachedBrand?.screenshotEnabled ?? true;
static bool get stickersEnabled => _cachedBrand?.stickersEnabled ?? true;
static bool get aiAutoBookEnabled => _cachedBrand?.aiAutoBookEnabled ?? true;
static final companionNotifier = ValueNotifier<CompanionDisplay>(
const CompanionDisplay(name: 'AI', avatarKey: 'cat'),
);
static String get companionName => companionNotifier.value.name;
static String get companionAvatarKey => companionNotifier.value.avatarKey;
static Future<void> refreshCompanion() async {
final session = SessionStore.instance;
if (!session.isAccount || session.shouldUseLocalOnly) {
companionNotifier.value = const CompanionDisplay(
name: 'AI',
avatarKey: 'cat',
);
return;
}
try {
final responses = await Future.wait([
_dio.get('/api/users/me'),
_dio.get('/api/public/avatars'),
]);
final profile = responses[0].data as Map<String, dynamic>;
final avatars = responses[1].data as List;
final companion = profile['aiCompanion'] as Map<String, dynamic>?;
final selectedAvatarKey = companion?['avatarKey'] as String?;
final customName = _normalizeName(companion?['customName'] as String?);
var avatarKey = selectedAvatarKey;
String? defaultName;
for (final item in avatars) {
final avatar = item as Map<String, dynamic>;
final key = avatar['key'] as String?;
if ((avatarKey == null || avatarKey.isEmpty) &&
key != null &&
key.isNotEmpty) {
avatarKey = key;
}
if (key == selectedAvatarKey) {
defaultName = _normalizeName(avatar['defaultName'] as String?);
break;
}
}
companionNotifier.value = CompanionDisplay(
name: customName ?? defaultName ?? '小账喵',
avatarKey: avatarKey ?? 'cat',
);
} catch (_) {
try {
final a = await _dio.get('/api/public/avatars');
final list = a.data as List;
if (list.isEmpty) return;
final first = list.first as Map<String, dynamic>;
companionNotifier.value = CompanionDisplay(
name: _normalizeName(first['defaultName'] as String?) ?? '小账喵',
avatarKey: first['key'] as String? ?? 'cat',
);
} catch (_) {}
}
}
static Future<List<AvatarItem>> avatars() async {
final res = await _dio.get('/api/public/avatars');
return (res.data as List)
.map((e) => AvatarItem.fromJson(e as Map<String, dynamic>))
.toList();
}
static Future<List<PersonaItem>> personas() async {
final res = await _dio.get('/api/public/personas');
return (res.data as List)
.map((e) => PersonaItem.fromJson(e as Map<String, dynamic>))
.toList();
}
static String? _normalizeName(String? value) {
final text = value?.trim();
if (text == null || text.isEmpty) return null;
return text;
}
}
@@ -0,0 +1,22 @@
class SseFrameAccumulator {
String _pending = '';
List<String> add(String chunk) {
_pending = (_pending + chunk).replaceAll('\r\n', '\n');
final frames = <String>[];
var separator = _pending.indexOf('\n\n');
while (separator >= 0) {
final frame = _pending.substring(0, separator);
_pending = _pending.substring(separator + 2);
if (frame.trim().isNotEmpty) frames.add(frame);
separator = _pending.indexOf('\n\n');
}
return frames;
}
List<String> close() {
final trailing = _pending.trim();
_pending = '';
return trailing.isEmpty ? const [] : [trailing];
}
}
@@ -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++;
}
}
+359
View File
@@ -0,0 +1,359 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@immutable
class JzPalette extends ThemeExtension<JzPalette> {
final Color text;
final Color text2;
final Color text3;
final Color background;
final Color card;
final Color line;
final Color primaryBackground;
final Color aiBackground;
final Color expenseBackground;
final Color warningBackground;
const JzPalette({
required this.text,
required this.text2,
required this.text3,
required this.background,
required this.card,
required this.line,
required this.primaryBackground,
required this.aiBackground,
required this.expenseBackground,
required this.warningBackground,
});
@override
JzPalette copyWith({
Color? text,
Color? text2,
Color? text3,
Color? background,
Color? card,
Color? line,
Color? primaryBackground,
Color? aiBackground,
Color? expenseBackground,
Color? warningBackground,
}) => JzPalette(
text: text ?? this.text,
text2: text2 ?? this.text2,
text3: text3 ?? this.text3,
background: background ?? this.background,
card: card ?? this.card,
line: line ?? this.line,
primaryBackground: primaryBackground ?? this.primaryBackground,
aiBackground: aiBackground ?? this.aiBackground,
expenseBackground: expenseBackground ?? this.expenseBackground,
warningBackground: warningBackground ?? this.warningBackground,
);
@override
JzPalette lerp(covariant JzPalette? other, double t) {
if (other == null) return this;
return JzPalette(
text: Color.lerp(text, other.text, t)!,
text2: Color.lerp(text2, other.text2, t)!,
text3: Color.lerp(text3, other.text3, t)!,
background: Color.lerp(background, other.background, t)!,
card: Color.lerp(card, other.card, t)!,
line: Color.lerp(line, other.line, t)!,
primaryBackground: Color.lerp(
primaryBackground,
other.primaryBackground,
t,
)!,
aiBackground: Color.lerp(aiBackground, other.aiBackground, t)!,
expenseBackground: Color.lerp(
expenseBackground,
other.expenseBackground,
t,
)!,
warningBackground: Color.lerp(
warningBackground,
other.warningBackground,
t,
)!,
);
}
}
extension JzThemeContext on BuildContext {
JzPalette get jz => Theme.of(this).extension<JzPalette>()!;
}
class AppTheme {
AppTheme._();
static const Color primary = Color(0xFF00B386);
static const Color primaryDeep = Color(0xFF009973);
static const Color primaryBg = Color(0xFFE6F7F1);
static const Color primaryMid = Color(0xFF4DA6FF);
static const Color primaryLight = Color(0xFF80DCC2);
static const Color ai = Color(0xFF5B6BF5);
static const Color aiBg = Color(0xFFEEF0FE);
static const Color red = Color(0xFFF0642D);
static const Color redBg = Color(0xFFFFF3EC);
static const Color orange = Color(0xFFF5A623);
static const Color orangeBg = Color(0xFFFFF7E8);
static const Color text = Color(0xFF191F26);
static const Color text2 = Color(0xFF5E6772);
static const Color text3 = Color(0xFF9AA3AD);
static const Color bg = Color(0xFFF6F7F9);
static const Color card = Colors.white;
static const Color line = Color(0xFFEEF0F3);
static const _lightPalette = JzPalette(
text: text,
text2: text2,
text3: text3,
background: bg,
card: card,
line: line,
primaryBackground: primaryBg,
aiBackground: aiBg,
expenseBackground: redBg,
warningBackground: orangeBg,
);
static const _darkPalette = JzPalette(
text: Color(0xFFE8EFED),
text2: Color(0xFFAAB6B2),
text3: Color(0xFF74817D),
background: Color(0xFF111615),
card: Color(0xFF1B2220),
line: Color(0xFF2B3532),
primaryBackground: Color(0xFF143A31),
aiBackground: Color(0xFF272B4A),
expenseBackground: Color(0xFF40251F),
warningBackground: Color(0xFF3B2E1B),
);
static ThemeData get light => _build(Brightness.light, _lightPalette);
static ThemeData get dark => _build(Brightness.dark, _darkPalette);
static ThemeData _build(Brightness brightness, JzPalette p) {
final dark = brightness == Brightness.dark;
final scheme = dark
? ColorScheme.dark(
primary: primary,
secondary: ai,
surface: p.card,
error: red,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: p.text,
primaryContainer: p.primaryBackground,
onPrimaryContainer: const Color(0xFF9BE7D1),
secondaryContainer: p.aiBackground,
onSecondaryContainer: const Color(0xFFC5CAFF),
outline: p.line,
outlineVariant: p.line,
)
: ColorScheme.light(
primary: primary,
secondary: ai,
surface: p.card,
error: red,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: p.text,
primaryContainer: p.primaryBackground,
onPrimaryContainer: primaryDeep,
secondaryContainer: p.aiBackground,
onSecondaryContainer: ai,
outline: p.line,
outlineVariant: p.line,
);
final baseText = ThemeData(
brightness: brightness,
).textTheme.apply(bodyColor: p.text, displayColor: p.text);
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: p.background,
fontFamily: 'PingFang SC',
textTheme: baseText,
extensions: [p],
dividerColor: p.line,
disabledColor: p.text3,
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
foregroundColor: p.text,
elevation: 0,
centerTitle: true,
titleTextStyle: TextStyle(
color: p.text,
fontSize: 16,
fontWeight: FontWeight.w600,
),
systemOverlayStyle: dark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark,
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: p.card,
selectedItemColor: primary,
unselectedItemColor: p.text3,
type: BottomNavigationBarType.fixed,
elevation: 0,
),
cardTheme: CardThemeData(
color: p.card,
surfaceTintColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: p.line, width: 0.5),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: p.card,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: p.line),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: p.line),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: primary, width: 1.4),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: red),
),
hintStyle: TextStyle(color: p.text3, fontSize: 13.5),
labelStyle: TextStyle(color: p.text2, fontSize: 13),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 13,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
disabledBackgroundColor: p.line,
disabledForegroundColor: p.text3,
elevation: 0,
minimumSize: const Size(0, 46),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(vertical: 14),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
disabledBackgroundColor: p.line,
disabledForegroundColor: p.text3,
elevation: 0,
minimumSize: const Size(0, 46),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: p.text,
side: BorderSide(color: p.line),
minimumSize: const Size(0, 46),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: dark ? const Color(0xFF77D7BD) : primaryDeep,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: p.text2,
highlightColor: p.primaryBackground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected) ? primary : p.card,
),
side: BorderSide(color: p.text3),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
),
switchTheme: SwitchThemeData(
trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? p.primaryBackground
: p.line,
),
thumbColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected) ? primary : p.text3,
),
trackOutlineColor: const WidgetStatePropertyAll(Colors.transparent),
),
dialogTheme: DialogThemeData(
backgroundColor: p.card,
surfaceTintColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
titleTextStyle: TextStyle(
color: p.text,
fontSize: 18,
fontWeight: FontWeight.w800,
),
contentTextStyle: TextStyle(
color: p.text2,
fontSize: 13.5,
height: 1.6,
),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: p.card,
modalBackgroundColor: p.card,
surfaceTintColor: Colors.transparent,
showDragHandle: false,
),
snackBarTheme: SnackBarThemeData(
backgroundColor: dark ? const Color(0xFFE8EFED) : p.text,
contentTextStyle: TextStyle(
color: dark ? const Color(0xFF111615) : Colors.white,
fontSize: 13,
),
behavior: SnackBarBehavior.floating,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: p.card,
indicatorColor: p.primaryBackground,
),
);
}
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum JzThemePreference { system, light, dark }
class ThemeStore extends ChangeNotifier {
ThemeStore._();
static final instance = ThemeStore._();
static const _key = 'theme_preference';
static const _channel = MethodChannel('com.miaoji/screenshot');
JzThemePreference _preference = JzThemePreference.system;
JzThemePreference get preference => _preference;
ThemeMode get themeMode => switch (_preference) {
JzThemePreference.light => ThemeMode.light,
JzThemePreference.dark => ThemeMode.dark,
JzThemePreference.system => ThemeMode.system,
};
Future<void> initialize() async {
final value = (await SharedPreferences.getInstance()).getString(_key);
_preference = JzThemePreference.values.firstWhere(
(item) => item.name == value,
orElse: () => JzThemePreference.system,
);
await _syncNativeTheme();
}
Future<void> setPreference(JzThemePreference value) async {
if (_preference == value) return;
_preference = value;
notifyListeners();
await (await SharedPreferences.getInstance()).setString(_key, value.name);
await _syncNativeTheme();
}
Future<void> _syncNativeTheme() async {
try {
await _channel.invokeMethod<bool>('setThemeMode', {
'mode': _preference.name,
});
} on MissingPluginException {
// Native startup themes are Android-only.
}
}
}
@@ -0,0 +1,86 @@
import 'package:dio/dio.dart';
import 'package:miaoji_zhang/shared/update/update_config.dart';
import 'package:miaoji_zhang/shared/update/update_models.dart';
enum UpdateFailureKind { unavailable, rateLimited, network, invalidResponse }
class UpdateCheckException implements Exception {
final UpdateFailureKind kind;
final String message;
const UpdateCheckException(this.kind, this.message);
@override
String toString() => message;
}
class UpdateApi {
UpdateApi({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
baseUrl: UpdateConfig.baseUrl,
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 8),
),
);
final Dio _dio;
Future<UpdateCheckResponse> check({
required String platform,
required String channel,
required int currentBuild,
}) async {
try {
final response = await _dio.get<Object?>(
'/api/client/v1/update',
queryParameters: {
'appKey': UpdateConfig.appKey,
'platform': platform,
'channel': channel,
'currentBuild': currentBuild,
},
);
final data = response.data;
if (data is! Map) {
throw const UpdateCheckException(
UpdateFailureKind.invalidResponse,
'更新服务返回格式异常',
);
}
return UpdateCheckResponse.fromJson(Map<String, dynamic>.from(data));
} on DioException catch (error) {
switch (error.response?.statusCode) {
case 404:
throw const UpdateCheckException(
UpdateFailureKind.unavailable,
'当前版本尚未配置更新渠道',
);
case 429:
throw const UpdateCheckException(
UpdateFailureKind.rateLimited,
'检查更新过于频繁,请稍后再试',
);
}
throw const UpdateCheckException(
UpdateFailureKind.network,
'暂时无法连接更新服务,请检查网络后重试',
);
} on UpdateCheckException {
rethrow;
} on FormatException catch (error) {
throw UpdateCheckException(
UpdateFailureKind.invalidResponse,
error.message.toString(),
);
} catch (_) {
throw const UpdateCheckException(
UpdateFailureKind.invalidResponse,
'更新服务返回格式异常',
);
}
}
}
@@ -0,0 +1,27 @@
import 'dart:io';
class UpdateConfig {
UpdateConfig._();
static const baseUrl = String.fromEnvironment(
'UPDATE_BASE_URL',
defaultValue: 'https://version.nxsir.cn',
);
static const appKey = String.fromEnvironment(
'UPDATE_APP_KEY',
defaultValue: 'MvSyHJ7d8mlLbylIub04epC7g5AsCSqB',
);
static const internalBuild = bool.fromEnvironment('INTERNAL_BUILD');
static String? get platform {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
static String get channel => internalBuild ? 'beta' : 'stable';
static bool get supportsUpdates => platform != null;
static bool get canInstallInApp => Platform.isAndroid && internalBuild;
}
@@ -0,0 +1,168 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miaoji_zhang/shared/update/update_api.dart';
import 'package:miaoji_zhang/shared/update/update_config.dart';
import 'package:miaoji_zhang/shared/update/update_downloader.dart';
import 'package:miaoji_zhang/shared/update/update_models.dart';
import 'package:miaoji_zhang/shared/update/update_prompt.dart';
import 'package:miaoji_zhang/shared/version.dart';
class UpdateCoordinator extends ChangeNotifier {
UpdateCoordinator({UpdateApi? api}) : _api = api ?? UpdateApi();
static final instance = UpdateCoordinator();
final UpdateApi _api;
SharedPreferences? _preferences;
GlobalKey<NavigatorState>? _navigatorKey;
Future<UpdateCheckResponse>? _inFlight;
bool _startupScheduled = false;
bool _showingPrompt = false;
bool _checking = false;
AppRelease? _forcedRelease;
bool get checking => _checking;
Future<void> initialize() async {
_preferences ??= await SharedPreferences.getInstance();
await UpdateDownloader().cleanupStale();
}
void bindNavigator(GlobalKey<NavigatorState> navigatorKey) {
_navigatorKey = navigatorKey;
}
void scheduleStartupCheck() {
if (_startupScheduled || !UpdateConfig.supportsUpdates) return;
_startupScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
Future<void>.delayed(
const Duration(milliseconds: 350),
_checkAutomatically,
);
});
}
Future<void> checkManually(BuildContext context) async {
if (!UpdateConfig.supportsUpdates) {
_message(context, '当前平台暂不支持应用内更新检查');
return;
}
try {
final response = await _check();
final release = _availableRelease(response);
if (release == null) {
if (context.mounted) _message(context, '已是最新版');
return;
}
if (response.forceUpdate) _forcedRelease = release;
if (context.mounted) {
await _present(context, release, response.forceUpdate);
}
} on UpdateCheckException catch (error) {
if (context.mounted) _message(context, error.message);
}
}
Future<void> _checkAutomatically() async {
try {
final response = await _check();
final release = _availableRelease(response);
if (release == null) return;
final ignoredId = await _ignoredReleaseId();
if (!UpdatePolicy.shouldPresent(
release: release,
forced: response.forceUpdate,
manual: false,
ignoredReleaseId: ignoredId,
)) {
return;
}
if (response.forceUpdate) _forcedRelease = release;
final context = _navigatorKey?.currentContext;
if (context != null && context.mounted) {
await _present(context, release, response.forceUpdate);
}
} catch (_) {
// Automatic checks never block startup when the update service is unavailable.
}
}
@visibleForTesting
Future<UpdateCheckResponse> debugCheck({required String platform}) =>
_check(platformOverride: platform);
Future<UpdateCheckResponse> _check({String? platformOverride}) {
final existing = _inFlight;
if (existing != null) return existing;
_checking = true;
notifyListeners();
final future = _api
.check(
platform: platformOverride ?? UpdateConfig.platform!,
channel: UpdateConfig.channel,
currentBuild: AppVersion.buildNumber,
)
.whenComplete(() {
_inFlight = null;
_checking = false;
notifyListeners();
});
_inFlight = future;
return future;
}
AppRelease? _availableRelease(UpdateCheckResponse response) =>
UpdatePolicy.availableRelease(
response,
currentBuild: AppVersion.buildNumber,
);
Future<String?> _ignoredReleaseId() async {
final preferences = _preferences ??= await SharedPreferences.getInstance();
return preferences.getString(_ignoredKey);
}
Future<void> _ignore(AppRelease release) async {
final preferences = _preferences ??= await SharedPreferences.getInstance();
await preferences.setString(_ignoredKey, release.id);
}
String get _ignoredKey =>
'ignored_update_${UpdateConfig.platform}_${UpdateConfig.channel}';
Future<void> _present(
BuildContext context,
AppRelease release,
bool forced,
) async {
if (_showingPrompt) return;
_showingPrompt = true;
try {
await showUpdatePrompt(
context,
release: release,
forced: forced,
onIgnore: () => _ignore(release),
);
} finally {
_showingPrompt = false;
if (forced && _forcedRelease != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final retryContext = _navigatorKey?.currentContext;
if (retryContext != null && retryContext.mounted) {
unawaited(_present(retryContext, _forcedRelease!, true));
}
});
}
}
}
void _message(BuildContext context, String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
@@ -0,0 +1,190 @@
import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:miaoji_zhang/shared/update/update_installer.dart';
import 'package:miaoji_zhang/shared/update/update_models.dart';
class UpdateDownloadProgress {
final int received;
final int total;
final double bytesPerSecond;
const UpdateDownloadProgress({
required this.received,
required this.total,
required this.bytesPerSecond,
});
double? get fraction => total > 0 ? (received / total).clamp(0, 1) : null;
}
class UpdateDownloadException implements Exception {
final String message;
const UpdateDownloadException(this.message);
@override
String toString() => message;
}
class UpdateDownloader {
UpdateDownloader({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(minutes: 10),
followRedirects: true,
maxRedirects: 5,
),
);
final Dio _dio;
CancelToken? _cancelToken;
bool get isDownloading => _cancelToken != null;
void cancel() => _cancelToken?.cancel('用户取消下载');
Future<void> cleanupStale() async {
if (!Platform.isAndroid) return;
final directory = await _updateDirectory();
if (!await directory.exists()) return;
final cutoff = DateTime.now().subtract(const Duration(hours: 24));
await for (final entity in directory.list()) {
if (entity is! File) continue;
final stat = await entity.stat();
if (entity.path.endsWith('.part') || stat.modified.isBefore(cutoff)) {
await _delete(entity);
}
}
}
Future<String> downloadAndVerify(
AppRelease release, {
required void Function(UpdateDownloadProgress progress) onProgress,
}) async {
if (_cancelToken != null) {
throw const UpdateDownloadException('更新包正在下载中');
}
final uri = Uri.tryParse(release.downloadUrl);
if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) {
throw const UpdateDownloadException('更新包地址无效,必须使用 HTTPS');
}
final expectedSha = release.sha256?.trim().toLowerCase();
if (expectedSha == null ||
!RegExp(r'^[0-9a-f]{64}$').hasMatch(expectedSha)) {
throw const UpdateDownloadException('发布记录缺少有效的 SHA-256,已禁止安装');
}
final directory = await _updateDirectory();
await directory.create(recursive: true);
final partial = File(
'${directory.path}${Platform.pathSeparator}jizhi-${release.buildNumber}.apk.part',
);
final target = File(
'${directory.path}${Platform.pathSeparator}jizhi-${release.buildNumber}.apk',
);
await _delete(partial);
await _delete(target);
final token = CancelToken();
_cancelToken = token;
final stopwatch = Stopwatch()..start();
var lastBytes = 0;
var lastElapsed = Duration.zero;
var keepTarget = false;
try {
await _dio.download(
release.downloadUrl,
partial.path,
cancelToken: token,
deleteOnError: true,
onReceiveProgress: (received, total) {
final elapsed = stopwatch.elapsed;
final deltaMicros = (elapsed - lastElapsed).inMicroseconds;
final speed = deltaMicros <= 0
? 0.0
: (received - lastBytes) *
Duration.microsecondsPerSecond /
deltaMicros;
if (elapsed - lastElapsed >= const Duration(milliseconds: 250) ||
received == total) {
lastBytes = received;
lastElapsed = elapsed;
onProgress(
UpdateDownloadProgress(
received: received,
total: total > 0 ? total : release.fileSize ?? 0,
bytesPerSecond: speed,
),
);
}
},
);
final actualSize = await partial.length();
if (release.fileSize case final expectedSize?
when expectedSize > 0 && actualSize != expectedSize) {
throw UpdateDownloadException(
'更新包大小不一致(预期 $expectedSize 字节,实际 $actualSize 字节)',
);
}
final actualSha = (await sha256.bind(partial.openRead()).first)
.toString();
if (actualSha.toLowerCase() != expectedSha) {
throw const UpdateDownloadException('更新包完整性校验失败,文件已删除');
}
await partial.rename(target.path);
final inspection = await UpdateInstaller.inspect(
path: target.path,
expectedBuild: release.buildNumber,
);
if (!inspection.valid) {
throw UpdateDownloadException(inspection.message ?? '更新包身份校验失败,文件已删除');
}
keepTarget = true;
return target.path;
} on DioException catch (error) {
if (CancelToken.isCancel(error)) {
throw const UpdateDownloadException('下载已取消');
}
throw const UpdateDownloadException('更新包下载失败,请检查网络后重试');
} on UpdateDownloadException {
rethrow;
} catch (_) {
throw const UpdateDownloadException('更新包处理失败,请重新下载');
} finally {
_cancelToken = null;
stopwatch.stop();
if (!keepTarget) {
await _delete(partial);
await _delete(target);
}
}
}
Future<void> deleteFile(String path) => _delete(File(path));
Future<void> scheduleCleanup(String path) async {
unawaited(
Future<void>.delayed(const Duration(minutes: 10), () async {
await _delete(File(path));
}),
);
}
Future<Directory> _updateDirectory() async {
final temporary = await getTemporaryDirectory();
return Directory('${temporary.path}${Platform.pathSeparator}updates');
}
Future<void> _delete(File file) async {
try {
if (await file.exists()) await file.delete();
} catch (_) {}
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/services.dart';
enum ApkInstallStatus { launched, permissionRequested, unsupported }
class ApkInspection {
final bool valid;
final String? message;
const ApkInspection({required this.valid, this.message});
}
class UpdateInstaller {
UpdateInstaller._();
static const _channel = MethodChannel('com.nx.miaoji/update');
static Future<ApkInspection> inspect({
required String path,
required int expectedBuild,
}) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'inspectApk',
{'path': path, 'expectedBuild': expectedBuild},
);
return ApkInspection(
valid: result?['valid'] == true,
message: result?['message']?.toString(),
);
}
static Future<ApkInstallStatus> install({
required String path,
required int expectedBuild,
}) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'installApk',
{'path': path, 'expectedBuild': expectedBuild},
);
return switch (result?['status']) {
'launched' => ApkInstallStatus.launched,
'permission_requested' => ApkInstallStatus.permissionRequested,
_ => ApkInstallStatus.unsupported,
};
}
}
@@ -0,0 +1,99 @@
class AppRelease {
final String id;
final String versionName;
final int buildNumber;
final String downloadUrl;
final String releaseNotes;
final String? sha256;
final int? fileSize;
final DateTime? publishedAt;
const AppRelease({
required this.id,
required this.versionName,
required this.buildNumber,
required this.downloadUrl,
required this.releaseNotes,
required this.sha256,
required this.fileSize,
required this.publishedAt,
});
factory AppRelease.fromJson(Map<String, dynamic> json) {
final id = json['id']?.toString().trim() ?? '';
final versionName = json['versionName']?.toString().trim() ?? '';
final buildNumber = (json['buildNumber'] as num?)?.toInt() ?? -1;
final downloadUrl = json['downloadUrl']?.toString().trim() ?? '';
if (id.isEmpty ||
versionName.isEmpty ||
buildNumber < 0 ||
downloadUrl.isEmpty) {
throw const FormatException('更新服务返回了不完整的版本信息');
}
return AppRelease(
id: id,
versionName: versionName,
buildNumber: buildNumber,
downloadUrl: downloadUrl,
releaseNotes: json['releaseNotes']?.toString().trim() ?? '',
sha256: json['sha256']?.toString().trim().nullIfEmpty,
fileSize: (json['fileSize'] as num?)?.toInt(),
publishedAt: DateTime.tryParse(json['publishedAt']?.toString() ?? ''),
);
}
}
class UpdateCheckResponse {
final bool hasUpdate;
final bool forceUpdate;
final AppRelease? release;
const UpdateCheckResponse({
required this.hasUpdate,
required this.forceUpdate,
required this.release,
});
factory UpdateCheckResponse.fromJson(Map<String, dynamic> json) {
final releaseJson = json['release'];
final release = releaseJson is Map
? AppRelease.fromJson(Map<String, dynamic>.from(releaseJson))
: null;
final hasUpdate = json['hasUpdate'] == true;
if (hasUpdate && release == null) {
throw const FormatException('更新服务未返回目标版本');
}
return UpdateCheckResponse(
hasUpdate: hasUpdate,
forceUpdate: json['forceUpdate'] == true,
release: release,
);
}
}
extension on String {
String? get nullIfEmpty => isEmpty ? null : this;
}
class UpdatePolicy {
UpdatePolicy._();
static AppRelease? availableRelease(
UpdateCheckResponse response, {
required int currentBuild,
}) {
final release = response.release;
if (!response.hasUpdate || release == null) return null;
return release.buildNumber > currentBuild ? release : null;
}
static bool shouldPresent({
required AppRelease release,
required bool forced,
required bool manual,
required String? ignoredReleaseId,
}) {
if (forced || manual) return true;
return ignoredReleaseId != release.id;
}
}
@@ -0,0 +1,376 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/update/update_config.dart';
import 'package:miaoji_zhang/shared/update/update_downloader.dart';
import 'package:miaoji_zhang/shared/update/update_installer.dart';
import 'package:miaoji_zhang/shared/update/update_models.dart';
import 'package:miaoji_zhang/shared/version.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
Future<void> showUpdatePrompt(
BuildContext context, {
required AppRelease release,
required bool forced,
required Future<void> Function() onIgnore,
}) {
return showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
backgroundColor: Colors.transparent,
builder: (_) =>
UpdatePromptSheet(release: release, forced: forced, onIgnore: onIgnore),
);
}
class UpdatePromptSheet extends StatefulWidget {
final AppRelease release;
final bool forced;
final Future<void> Function() onIgnore;
const UpdatePromptSheet({
super.key,
required this.release,
required this.forced,
required this.onIgnore,
});
@override
State<UpdatePromptSheet> createState() => _UpdatePromptSheetState();
}
class _UpdatePromptSheetState extends State<UpdatePromptSheet> {
final _downloader = UpdateDownloader();
UpdateDownloadProgress? _progress;
bool _working = false;
String? _status;
String? _error;
String? _downloadedPath;
@override
void dispose() {
_downloader.cancel();
super.dispose();
}
Future<void> _ignore() async {
if (_working) return;
await widget.onIgnore();
if (mounted) Navigator.pop(context);
}
Future<void> _update() async {
if (_working) return;
final uri = Uri.tryParse(widget.release.downloadUrl);
if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) {
setState(() => _error = '更新地址无效,必须使用 HTTPS');
return;
}
if (!UpdateConfig.canInstallInApp) {
setState(() {
_working = true;
_error = null;
_status = '正在打开更新页面…';
});
try {
final opened = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!opened) throw StateError('无法打开更新页面');
if (!widget.forced && mounted) Navigator.pop(context);
if (mounted && widget.forced) {
setState(() => _status = '更新页面已打开,完成更新后重新打开记之');
}
} catch (_) {
if (mounted) setState(() => _error = '无法打开更新页面,请稍后重试');
} finally {
if (mounted) setState(() => _working = false);
}
return;
}
setState(() {
_working = true;
_error = null;
_status = '正在下载更新包…';
_progress = null;
});
String? path;
try {
final cachedPath = _downloadedPath;
if (cachedPath != null && await File(cachedPath).exists()) {
path = cachedPath;
} else {
_downloadedPath = null;
path = await _downloader.downloadAndVerify(
widget.release,
onProgress: (progress) {
if (!mounted) return;
setState(() {
_progress = progress;
_status = '正在下载更新包…';
});
},
);
_downloadedPath = path;
}
if (!mounted) return;
setState(() {
_status = '校验完成,正在打开安装界面…';
_progress = null;
});
final status = await UpdateInstaller.install(
path: path,
expectedBuild: widget.release.buildNumber,
);
if (!mounted) return;
setState(() {
_status = switch (status) {
ApkInstallStatus.launched => '安装界面已打开,完成后重新启动记之',
ApkInstallStatus.permissionRequested => '请允许安装未知应用,返回后将继续安装',
ApkInstallStatus.unsupported => '当前设备无法调起安装,请重新下载',
};
if (status == ApkInstallStatus.unsupported) {
_error = _status;
}
});
if (status == ApkInstallStatus.launched) {
await _downloader.scheduleCleanup(path);
} else if (status == ApkInstallStatus.unsupported) {
await _downloader.deleteFile(path);
_downloadedPath = null;
}
} on UpdateDownloadException catch (error) {
if (mounted) setState(() => _error = error.message);
} catch (_) {
if (mounted) setState(() => _error = '更新失败,请重新尝试');
} finally {
if (mounted) setState(() => _working = false);
}
}
void _cancelDownload() {
_downloader.cancel();
setState(() => _status = '正在取消下载…');
}
@override
Widget build(BuildContext context) {
final release = widget.release;
return PopScope(
canPop: false,
child: SafeArea(
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.84,
),
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
JzSheetHeader(
title: widget.forced ? '必须更新后继续使用' : '发现新版本',
subtitle:
'v${release.versionName} (${release.buildNumber}) · 当前 ${AppVersion.display}',
),
if (widget.forced)
Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: context.jz.warningBackground,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.orange.withValues(alpha: 0.35),
),
),
child: Text(
'此版本已停止支持,更新完成前暂时不能进入 App。',
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
height: 1.45,
),
),
),
const SizedBox(height: 14),
Flexible(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: context.jz.background,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line),
),
child: SingleChildScrollView(
child: MarkdownBody(
data: release.releaseNotes.isEmpty
? '本次更新包含体验优化与问题修复。'
: release.releaseNotes,
selectable: true,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
fontSize: 13,
color: context.jz.text2,
height: 1.65,
),
h1: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: context.jz.text,
),
h2: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: context.jz.text,
),
listBullet: TextStyle(
fontSize: 13,
color: AppTheme.primaryDeep,
),
code: TextStyle(
fontSize: 12,
color: context.jz.text,
backgroundColor: context.jz.card,
),
blockquoteDecoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(8),
border: const Border(
left: BorderSide(color: AppTheme.ai, width: 3),
),
),
),
),
),
),
),
if (_progress != null || _status != null || _error != null) ...[
const SizedBox(height: 14),
if (_progress != null) _UpdateProgressBar(progress: _progress!),
if (_progress != null) const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
_error ?? _status ?? '',
style: TextStyle(
fontSize: 11.5,
color: _error == null ? context.jz.text2 : AppTheme.red,
),
),
),
],
const SizedBox(height: 18),
if (_working && _downloader.isDownloading && !widget.forced)
SizedBox(
width: double.infinity,
child: JzActionButton(
label: '取消下载',
secondary: true,
onPressed: _cancelDownload,
),
)
else
Row(
children: [
if (!widget.forced) ...[
Expanded(
child: JzActionButton(
label: '忽略此版本',
secondary: true,
onPressed: _working ? null : _ignore,
),
),
const SizedBox(width: 10),
],
Expanded(
child: JzActionButton(
label: UpdateConfig.canInstallInApp
? (_downloadedPath == null ? '下载并安装' : '继续安装')
: '立即更新',
loading: _working,
onPressed: _working ? null : _update,
),
),
],
),
],
),
),
),
);
}
}
class _UpdateProgressBar extends StatelessWidget {
final UpdateDownloadProgress progress;
const _UpdateProgressBar({required this.progress});
@override
Widget build(BuildContext context) {
final fraction = progress.fraction ?? 0;
return Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
height: 7,
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: context.jz.line),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: fraction,
child: const ColoredBox(color: AppTheme.primary),
),
],
),
),
),
const SizedBox(height: 6),
Row(
children: [
Text(
progress.total > 0
? '${(fraction * 100).toStringAsFixed(0)}%'
: _formatBytes(progress.received),
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
const Spacer(),
Text(
'${_formatBytes(progress.bytesPerSecond.round())}/s',
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
],
),
],
);
}
}
String _formatBytes(int bytes) {
if (bytes >= 1024 * 1024) {
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
}
if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '$bytes B';
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:package_info_plus/package_info_plus.dart';
class AppVersion {
AppVersion._();
static const String buildId = String.fromEnvironment(
'APP_VERSION',
defaultValue: 'dev',
);
static PackageInfo? _packageInfo;
static Future<void> initialize() async {
_packageInfo ??= await PackageInfo.fromPlatform();
}
static String get versionName => _packageInfo?.version ?? '0.0.0';
static int get buildNumber =>
int.tryParse(_packageInfo?.buildNumber ?? '') ?? 0;
static String get display => 'v$versionName ($buildNumber)';
}
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/sync_service.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
enum AiAccessState { guest, reauthenticate, cloudDisabled }
AiAccessState? currentAiAccessState() {
final session = SessionStore.instance;
if (session.isGuest) return AiAccessState.guest;
if (session.needsReauth) return AiAccessState.reauthenticate;
if (!session.cloudSyncEnabled) return AiAccessState.cloudDisabled;
return null;
}
class AiAccessGate extends StatefulWidget {
final AiAccessState state;
final Future<void> Function()? onAction;
const AiAccessGate({super.key, required this.state, this.onAction});
@override
State<AiAccessGate> createState() => _AiAccessGateState();
}
class _AiAccessGateState extends State<AiAccessGate> {
bool _busy = false;
String get _title => switch (widget.state) {
AiAccessState.guest => '登录后使用 AI 助手',
AiAccessState.reauthenticate => '登录状态已过期',
AiAccessState.cloudDisabled => 'AI 功能需要云连接',
};
String get _message => switch (widget.state) {
AiAccessState.guest => '游客账单会继续安全保存在本机。登录后即可使用 AI 聊天、语音解析和图片识别。',
AiAccessState.reauthenticate => '本地记账不受影响。重新登录后可以继续使用 AI 和云同步。',
AiAccessState.cloudDisabled => '当前账号仅使用本地数据。开启云同步后才能发送 AI 消息。',
};
String get _actionLabel => switch (widget.state) {
AiAccessState.guest => '登录后使用',
AiAccessState.reauthenticate => '重新登录',
AiAccessState.cloudDisabled => '开启云同步',
};
Future<void> _act() async {
if (_busy) return;
if (widget.onAction != null) {
await widget.onAction!();
return;
}
if (widget.state != AiAccessState.cloudDisabled) {
if (mounted) {
context.go(
Uri(path: '/login', queryParameters: {'notice': _title}).toString(),
);
}
return;
}
setState(() => _busy = true);
try {
await SessionStore.instance.setCloudSyncEnabled(true);
await SyncService.instance.run();
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 62,
height: 62,
decoration: BoxDecoration(
color: context.jz.aiBackground,
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: AppIcons.icon(
AppIcons.sparkle,
size: 29,
color: AppTheme.ai,
),
),
),
SizedBox(height: 18),
Text(
_title,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
SizedBox(height: 9),
Text(
_message,
textAlign: TextAlign.center,
style: TextStyle(
color: context.jz.text2,
fontSize: 12.5,
height: 1.6,
),
),
SizedBox(height: 22),
SizedBox(
width: double.infinity,
child: JzActionButton(
label: _actionLabel,
loading: _busy,
onPressed: _busy ? null : _act,
),
),
if (widget.state == AiAccessState.guest) ...[
SizedBox(height: 12),
Text(
'无需登录也可以继续手动记账、管理预算和查看统计',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
],
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,769 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
class JzSheetHeader extends StatelessWidget {
final String title;
final String? subtitle;
const JzSheetHeader({super.key, required this.title, this.subtitle});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 38,
height: 4,
margin: const EdgeInsets.only(top: 10, bottom: 18),
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(2),
),
),
Text(
title,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
if (subtitle != null) ...[
SizedBox(height: 6),
Text(
subtitle!,
textAlign: TextAlign.center,
style: TextStyle(
color: context.jz.text2,
fontSize: 12.5,
height: 1.5,
),
),
],
],
);
}
}
class JzActionButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final bool secondary;
final bool destructive;
final bool loading;
final Widget? icon;
const JzActionButton({
super.key,
required this.label,
required this.onPressed,
this.secondary = false,
this.destructive = false,
this.loading = false,
this.icon,
});
@override
Widget build(BuildContext context) {
final foreground = destructive ? AppTheme.red : AppTheme.primaryDeep;
final indicator = loading
? SizedBox(
width: 17,
height: 17,
child: CircularProgressIndicator(strokeWidth: 2, color: foreground),
)
: icon ?? const SizedBox.shrink();
if (secondary) {
return OutlinedButton.icon(
onPressed: loading ? null : onPressed,
icon: indicator,
label: Text(label),
style: OutlinedButton.styleFrom(
foregroundColor: foreground,
backgroundColor: destructive
? context.jz.expenseBackground
: context.jz.card,
side: BorderSide(
color: destructive
? AppTheme.red.withValues(alpha: 0.25)
: context.jz.line,
),
),
);
}
return FilledButton.icon(
onPressed: loading ? null : onPressed,
icon: loading
? SizedBox(
width: 17,
height: 17,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: indicator,
label: Text(label),
style: FilledButton.styleFrom(
backgroundColor: destructive ? AppTheme.red : AppTheme.primary,
),
);
}
}
class JzOption<T> {
final T value;
final String label;
final String? subtitle;
final Widget? leading;
const JzOption({
required this.value,
required this.label,
this.subtitle,
this.leading,
});
}
Future<T?> showJzOptionSheet<T>(
BuildContext context, {
required String title,
String? subtitle,
required List<JzOption<T>> options,
T? selected,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => SafeArea(
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.72,
),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: JzSheetHeader(title: title, subtitle: subtitle),
),
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
itemCount: options.length,
separatorBuilder: (_, __) => SizedBox(height: 7),
itemBuilder: (_, index) {
final option = options[index];
final active = option.value == selected;
return Semantics(
selected: active,
button: true,
child: InkWell(
onTap: () => Navigator.pop(sheetContext, option.value),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
decoration: BoxDecoration(
color: active
? context.jz.primaryBackground
: context.jz.background,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: active ? AppTheme.primary : context.jz.line,
),
),
child: Row(
children: [
if (option.leading != null) ...[
option.leading!,
SizedBox(width: 11),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.label,
style: TextStyle(
fontSize: 13.5,
fontWeight: active
? FontWeight.w700
: FontWeight.w600,
color: active
? AppTheme.primaryDeep
: context.jz.text,
),
),
if (option.subtitle != null) ...[
SizedBox(height: 3),
Text(
option.subtitle!,
style: TextStyle(
fontSize: 11,
color: context.jz.text3,
),
),
],
],
),
),
if (active)
Icon(
Icons.check_rounded,
color: AppTheme.primary,
size: 20,
),
],
),
),
),
);
},
),
),
],
),
),
),
);
}
Future<bool> showJzConfirmSheet(
BuildContext context, {
required String title,
required String message,
String confirmLabel = '确定',
String cancelLabel = '取消',
bool destructive = false,
Widget? content,
}) async {
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(sheetContext).bottom,
),
child: Container(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
JzSheetHeader(title: title, subtitle: message),
if (content != null) ...[SizedBox(height: 14), content],
SizedBox(height: 20),
Row(
children: [
Expanded(
child: JzActionButton(
label: cancelLabel,
secondary: true,
onPressed: () => Navigator.pop(sheetContext, false),
),
),
SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: confirmLabel,
destructive: destructive,
onPressed: () => Navigator.pop(sheetContext, true),
),
),
],
),
],
),
),
),
),
);
return result == true;
}
Future<String?> showJzTextInputSheet(
BuildContext context, {
required String title,
required String label,
String? subtitle,
String? initialValue,
bool obscureText = false,
int? maxLength,
String confirmLabel = '确定',
}) async {
final controller = TextEditingController(text: initialValue);
final result = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(sheetContext).bottom,
),
child: Container(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
JzSheetHeader(title: title, subtitle: subtitle),
SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
obscureText: obscureText,
maxLength: maxLength,
textInputAction: TextInputAction.done,
onSubmitted: (_) =>
Navigator.pop(sheetContext, controller.text.trim()),
decoration: InputDecoration(labelText: label),
),
SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: JzActionButton(
label: confirmLabel,
onPressed: () =>
Navigator.pop(sheetContext, controller.text.trim()),
),
),
],
),
),
),
),
);
controller.dispose();
return result;
}
Future<DateTime?> showJzDateTimeSheet(
BuildContext context, {
required DateTime initial,
DateTime? firstDate,
DateTime? lastDate,
String title = '选择日期和时间',
}) {
final minimum = firstDate ?? DateTime(2000);
final maximum = lastDate ?? ShanghaiTime.now.add(const Duration(days: 365));
return showModalBottomSheet<DateTime>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
var date = DateUtils.dateOnly(initial);
var hour = initial.hour;
var minute = initial.minute;
return StatefulBuilder(
builder: (context, setSheetState) => SafeArea(
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.86,
),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
JzSheetHeader(title: title),
Flexible(
child: CalendarDatePicker(
initialDate: date.isBefore(minimum)
? minimum
: date.isAfter(maximum)
? maximum
: date,
firstDate: minimum,
lastDate: maximum,
onDateChanged: (value) => setSheetState(() => date = value),
),
),
Container(
height: 92,
padding: const EdgeInsets.symmetric(horizontal: 18),
decoration: BoxDecoration(
color: context.jz.background,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line),
),
child: Row(
children: [
Text(
'时间',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
Spacer(),
_TimeWheel(
value: hour,
count: 24,
onChanged: (value) => setSheetState(() => hour = value),
),
Text(
':',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
_TimeWheel(
value: minute,
count: 60,
onChanged: (value) =>
setSheetState(() => minute = value),
),
],
),
),
SizedBox(height: 14),
Row(
children: [
Expanded(
child: JzActionButton(
label: '取消',
secondary: true,
onPressed: () => Navigator.pop(sheetContext),
),
),
SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: '完成',
onPressed: () => Navigator.pop(
sheetContext,
DateTime(
date.year,
date.month,
date.day,
hour,
minute,
),
),
),
),
],
),
],
),
),
),
);
},
);
}
class JzSegmentedControl<T> extends StatelessWidget {
final T value;
final List<JzOption<T>> options;
final ValueChanged<T>? onChanged;
const JzSegmentedControl({
super.key,
required this.value,
required this.options,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: options.map((option) {
final selected = option.value == value;
return Expanded(
child: Semantics(
selected: selected,
button: true,
child: InkWell(
onTap: onChanged == null
? null
: () => onChanged!(option.value),
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: selected ? context.jz.card : Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: selected ? context.jz.line : Colors.transparent,
),
),
child: Text(
option.label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected ? FontWeight.w800 : FontWeight.w500,
color: selected
? value == 'income'
? AppTheme.primaryDeep
: context.jz.text
: context.jz.text2,
),
),
),
),
),
);
}).toList(),
),
);
}
}
class JzSwitchTile extends StatelessWidget {
final bool value;
final String title;
final String? subtitle;
final ValueChanged<bool>? onChanged;
const JzSwitchTile({
super.key,
required this.value,
required this.title,
this.subtitle,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Semantics(
toggled: value,
button: true,
label: title,
child: InkWell(
onTap: onChanged == null ? null : () => onChanged!(!value),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
if (subtitle != null) ...[
SizedBox(height: 3),
Text(
subtitle!,
style: TextStyle(
fontSize: 11.5,
color: context.jz.text3,
),
),
],
],
),
),
SizedBox(width: 12),
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 46,
height: 27,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: value ? context.jz.primaryBackground : context.jz.line,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: value ? AppTheme.primary : context.jz.text3,
),
),
child: AnimatedAlign(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: value
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
width: 19,
height: 19,
decoration: BoxDecoration(
color: value ? AppTheme.primary : context.jz.card,
shape: BoxShape.circle,
),
),
),
),
],
),
),
),
);
}
}
class JzSlider extends StatelessWidget {
final double value;
final double min;
final double max;
final Color color;
final ValueChanged<double>? onChanged;
const JzSlider({
super.key,
required this.value,
this.min = 0,
this.max = 100,
this.color = AppTheme.primary,
required this.onChanged,
});
double get _fraction =>
max <= min ? 0 : ((value - min) / (max - min)).clamp(0.0, 1.0);
@override
Widget build(BuildContext context) {
void update(double x, double width) {
if (onChanged == null || width <= 0) return;
final fraction = (x / width).clamp(0.0, 1.0);
onChanged!(min + (max - min) * fraction);
}
return Semantics(
slider: true,
value: value.round().toString(),
increasedValue: (value + 5).clamp(min, max).round().toString(),
decreasedValue: (value - 5).clamp(min, max).round().toString(),
onIncrease: onChanged == null
? null
: () => onChanged!((value + 5).clamp(min, max)),
onDecrease: onChanged == null
? null
: () => onChanged!((value - 5).clamp(min, max)),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final thumbLeft = (width - 24) * _fraction;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: onChanged == null
? null
: (details) => update(details.localPosition.dx, width),
onHorizontalDragUpdate: onChanged == null
? null
: (details) => update(details.localPosition.dx, width),
child: SizedBox(
height: 36,
child: Stack(
alignment: Alignment.centerLeft,
children: [
Container(
height: 6,
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(3),
),
),
FractionallySizedBox(
widthFactor: _fraction,
child: Container(
height: 6,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
),
),
),
Positioned(
left: thumbLeft,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: context.jz.card,
shape: BoxShape.circle,
border: Border.all(color: color, width: 2),
boxShadow: const [
BoxShadow(
color: Color(0x1A191F26),
blurRadius: 5,
offset: Offset(0, 2),
),
],
),
),
),
],
),
),
);
},
),
);
}
}
class _TimeWheel extends StatelessWidget {
final int value;
final int count;
final ValueChanged<int> onChanged;
const _TimeWheel({
required this.value,
required this.count,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 52,
child: ListWheelScrollView.useDelegate(
controller: FixedExtentScrollController(initialItem: value),
itemExtent: 34,
physics: const FixedExtentScrollPhysics(),
onSelectedItemChanged: (index) {
HapticFeedback.selectionClick();
onChanged(index);
},
childDelegate: ListWheelChildBuilderDelegate(
childCount: count,
builder: (_, index) => Center(
child: Text(
index.toString().padLeft(2, '0'),
style: TextStyle(
fontSize: index == value ? 18 : 13,
fontWeight: index == value ? FontWeight.w800 : FontWeight.w500,
color: index == value ? AppTheme.primaryDeep : context.jz.text3,
),
),
),
),
),
);
}
}
+183
View File
@@ -0,0 +1,183 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
class CategoryIconMeta {
final String key;
final String label;
final String group;
const CategoryIconMeta(this.key, this.label, this.group);
}
/// 全套自绘 SVG 图标(来自 design/ui-mockup.html v0.5
/// 使用方式:AppIcons.cat → SvgPicture.asset(AppIcons.cat, colorFilter: ..., width: 24)
class AppIcons {
AppIcons._();
static const _p = 'assets/icons/';
// AI 形象
static const cat = '${_p}i-cat.svg';
static const dog = '${_p}i-dog.svg';
static const robot = '${_p}i-robot.svg';
// 底部导航
static const home = '${_p}i-home.svg';
static const chart = '${_p}i-chart.svg';
static const chat = '${_p}i-chat.svg';
static const user = '${_p}i-user.svg';
static const plus = '${_p}i-plus.svg';
// 分类图标
static const food = '${_p}i-food.svg';
static const cup = '${_p}i-cup.svg';
static const cart = '${_p}i-cart.svg';
static const metro = '${_p}i-metro.svg';
static const house = '${_p}i-house.svg';
static const game = '${_p}i-game.svg';
static const pill = '${_p}i-pill.svg';
static const book = '${_p}i-book.svg';
static const shirt = '${_p}i-shirt.svg';
static const gift = '${_p}i-gift.svg';
static const plane = '${_p}i-plane.svg';
static const money = '${_p}i-money.svg';
static const tag = '${_p}i-tag.svg';
// 扩展
static const wallet = '${_p}i-wallet.svg';
static const target = '${_p}i-target.svg';
static const sparkle = '${_p}i-sparkle.svg';
static const briefcase = '${_p}i-briefcase.svg';
static const cardIcon = '${_p}i-card.svg';
static const search = '${_p}i-search.svg';
static const bell = '${_p}i-bell.svg';
static const eye = '${_p}i-eye.svg';
static const gear = '${_p}i-gear.svg';
static const mic = '${_p}i-mic.svg';
static const smile = '${_p}i-smile.svg';
static const camera = '${_p}i-camera.svg';
static const fire = '${_p}i-fire.svg';
static const chevronDown = '${_p}i-chev-d.svg';
static const chevronRight = '${_p}i-chev-r.svg';
static const close = '${_p}i-close.svg';
static const check = '${_p}i-check.svg';
static const exportIcon = '${_p}i-export.svg';
static const cloud = '${_p}i-cloud.svg';
static const swap = '${_p}i-swap.svg';
static const receipt = '${_p}i-receipt.svg';
static const edit = '${_p}i-edit.svg';
static const trash = '${_p}i-trash.svg';
static const drag = '${_p}i-drag.svg';
static const offline = '${_p}i-offline.svg';
static const wave = '${_p}i-wave.svg';
static const scan = '${_p}i-scan.svg';
// 扩展分类图标
static const car = '${_p}i-car.svg';
static const baby = '${_p}i-baby.svg';
static const pet = '${_p}i-pet.svg';
static const phone = '${_p}i-phone.svg';
static const wifi = '${_p}i-wifi.svg';
static const sport = '${_p}i-sport.svg';
static const beauty = '${_p}i-beauty.svg';
static const insurance = '${_p}i-shield.svg';
static const tax = '${_p}i-tax.svg';
static const rent = '${_p}i-rent.svg';
static const refund = '${_p}i-refund.svg';
static const interest = '${_p}i-interest.svg';
// 映射 iconKey → asset path
static const keyMap = <String, String>{
'food': food,
'cup': cup,
'cart': cart,
'metro': metro,
'house': house,
'game': game,
'pill': pill,
'book': book,
'shirt': shirt,
'gift': gift,
'plane': plane,
'tag': tag,
'money': money,
'briefcase': briefcase,
'chart': chart,
'card': cardIcon,
'sparkle': sparkle,
'wallet': wallet,
'cat': cat,
'dog': dog,
'robot': robot,
'target': target,
'camera': camera,
'receipt': receipt,
'car': car,
'baby': baby,
'pet': pet,
'phone': phone,
'wifi': wifi,
'sport': sport,
'beauty': beauty,
'insurance': insurance,
'tax': tax,
'rent': rent,
'refund': refund,
'interest': interest,
};
static const categoryCatalog = <CategoryIconMeta>[
CategoryIconMeta('food', '餐饮', '日常生活'),
CategoryIconMeta('cup', '饮品', '日常生活'),
CategoryIconMeta('cart', '购物', '日常生活'),
CategoryIconMeta('house', '住房', '日常生活'),
CategoryIconMeta('rent', '房租', '日常生活'),
CategoryIconMeta('metro', '公交地铁', '交通出行'),
CategoryIconMeta('car', '汽车', '交通出行'),
CategoryIconMeta('plane', '旅行', '交通出行'),
CategoryIconMeta('phone', '手机数码', '日常生活'),
CategoryIconMeta('wifi', '网络通信', '日常生活'),
CategoryIconMeta('shirt', '服饰', '日常生活'),
CategoryIconMeta('beauty', '美容', '日常生活'),
CategoryIconMeta('pill', '医疗', '健康成长'),
CategoryIconMeta('sport', '运动', '健康成长'),
CategoryIconMeta('book', '学习', '健康成长'),
CategoryIconMeta('baby', '育儿', '健康成长'),
CategoryIconMeta('pet', '宠物', '健康成长'),
CategoryIconMeta('game', '娱乐', '休闲人情'),
CategoryIconMeta('gift', '礼物人情', '休闲人情'),
CategoryIconMeta('wallet', '钱包', '资金收入'),
CategoryIconMeta('money', '工资', '资金收入'),
CategoryIconMeta('briefcase', '工作兼职', '资金收入'),
CategoryIconMeta('chart', '投资理财', '资金收入'),
CategoryIconMeta('interest', '利息收益', '资金收入'),
CategoryIconMeta('refund', '退款', '资金收入'),
CategoryIconMeta('card', '银行卡', '资金收入'),
CategoryIconMeta('insurance', '保险', '资金收入'),
CategoryIconMeta('tax', '税费', '资金收入'),
CategoryIconMeta('receipt', '票据账单', '其他'),
CategoryIconMeta('camera', '摄影', '其他'),
CategoryIconMeta('target', '目标', '其他'),
CategoryIconMeta('sparkle', '奖励', '其他'),
CategoryIconMeta('tag', '其他', '其他'),
];
static String avatarAsset(String key) => keyMap[key] ?? cat;
/// 返回 SvgPicture widget
static Widget icon(String asset, {double size = 24, Color? color}) {
return SvgPicture.asset(
asset,
width: size,
height: size,
colorFilter: color != null
? ColorFilter.mode(color, BlendMode.srcIn)
: null,
);
}
/// 按 iconKey 返回 SvgPicture
static Widget byKey(String key, {double size = 24, Color? color}) {
return icon(keyMap[key] ?? tag, size: size, color: color);
}
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
class AsyncErrorView extends StatelessWidget {
final String message;
final Future<void> Function() onRetry;
const AsyncErrorView({
super.key,
required this.message,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off_outlined, size: 42, color: context.jz.text3),
SizedBox(height: 14),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
color: context.jz.text2,
fontSize: 13,
height: 1.5,
),
),
SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: Icon(Icons.refresh, size: 18),
label: Text('重试'),
),
],
),
),
);
}
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
class BrandLogo extends StatelessWidget {
final double size;
final bool framed;
const BrandLogo({super.key, this.size = 88, this.framed = true});
@override
Widget build(BuildContext context) {
final image = ClipRRect(
borderRadius: BorderRadius.circular(size * 0.24),
child: Image.asset(
'assets/branding/jizhi-app-icon-centered.png',
width: size,
height: size,
fit: BoxFit.cover,
filterQuality: FilterQuality.high,
semanticLabel: '记之',
),
);
if (!framed) return image;
return Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(size * 0.26),
border: Border.all(color: context.jz.line),
boxShadow: [
BoxShadow(
color: context.jz.text.withValues(alpha: 0.08),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: image,
);
}
}
@@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
class CategoryColorMeta {
final String key;
final String label;
final Color background;
final Color foreground;
const CategoryColorMeta({
required this.key,
required this.label,
required this.background,
required this.foreground,
});
}
const categoryColorCatalog = <CategoryColorMeta>[
CategoryColorMeta(
key: 'mint',
label: '薄荷',
background: Color(0xFFE3F7F0),
foreground: Color(0xFF008E69),
),
CategoryColorMeta(
key: 'teal',
label: '青绿',
background: Color(0xFFE1F3F1),
foreground: Color(0xFF087F72),
),
CategoryColorMeta(
key: 'aqua',
label: '水绿',
background: Color(0xFFE4F6F5),
foreground: Color(0xFF168C87),
),
CategoryColorMeta(
key: 'cyan',
label: '青蓝',
background: Color(0xFFE3F4F7),
foreground: Color(0xFF187F91),
),
CategoryColorMeta(
key: 'sky',
label: '天蓝',
background: Color(0xFFE7F2FA),
foreground: Color(0xFF327EAD),
),
CategoryColorMeta(
key: 'blue',
label: '蓝色',
background: Color(0xFFE8EFFB),
foreground: Color(0xFF416FAE),
),
CategoryColorMeta(
key: 'navy',
label: '海军蓝',
background: Color(0xFFE8ECF3),
foreground: Color(0xFF425A7B),
),
CategoryColorMeta(
key: 'indigo',
label: '靛蓝',
background: Color(0xFFEBEDFA),
foreground: Color(0xFF5864B1),
),
CategoryColorMeta(
key: 'violet',
label: '紫罗兰',
background: Color(0xFFF0EBF8),
foreground: Color(0xFF7656A8),
),
CategoryColorMeta(
key: 'plum',
label: '梅紫',
background: Color(0xFFF5EAF2),
foreground: Color(0xFF94547E),
),
CategoryColorMeta(
key: 'orchid',
label: '兰紫',
background: Color(0xFFF6EAF4),
foreground: Color(0xFFA45291),
),
CategoryColorMeta(
key: 'rose',
label: '玫瑰',
background: Color(0xFFF9E9EE),
foreground: Color(0xFFB94E6B),
),
CategoryColorMeta(
key: 'coral',
label: '珊瑚',
background: Color(0xFFFBEAE6),
foreground: Color(0xFFC45B49),
),
CategoryColorMeta(
key: 'red',
label: '朱红',
background: Color(0xFFFBE9E8),
foreground: Color(0xFFC4473F),
),
CategoryColorMeta(
key: 'orange',
label: '橙色',
background: Color(0xFFFCEDE2),
foreground: Color(0xFFBE681F),
),
CategoryColorMeta(
key: 'amber',
label: '琥珀',
background: Color(0xFFFAF0DB),
foreground: Color(0xFFA66B0E),
),
CategoryColorMeta(
key: 'peach',
label: '桃色',
background: Color(0xFFFBEDE6),
foreground: Color(0xFFB96B45),
),
CategoryColorMeta(
key: 'sand',
label: '沙金',
background: Color(0xFFF5F0E4),
foreground: Color(0xFF8C7442),
),
CategoryColorMeta(
key: 'lime',
label: '青柠',
background: Color(0xFFF0F5E2),
foreground: Color(0xFF71852D),
),
CategoryColorMeta(
key: 'olive',
label: '橄榄',
background: Color(0xFFEEF0E3),
foreground: Color(0xFF68723C),
),
CategoryColorMeta(
key: 'forest',
label: '森林',
background: Color(0xFFE6F1E9),
foreground: Color(0xFF39764A),
),
CategoryColorMeta(
key: 'slate',
label: '石板',
background: Color(0xFFEBEFF0),
foreground: Color(0xFF5F7074),
),
CategoryColorMeta(
key: 'cocoa',
label: '可可',
background: Color(0xFFF1ECE8),
foreground: Color(0xFF796154),
),
CategoryColorMeta(
key: 'graphite',
label: '石墨',
background: Color(0xFFEDEEEE),
foreground: Color(0xFF5D6261),
),
];
CategoryColorMeta categoryColorMeta(String? key) =>
categoryColorCatalog.firstWhere(
(item) => item.key == key,
orElse: () => categoryColorCatalog.first,
);
(Color bg, Color fg) categoryColors(String? colorKey) {
final meta = categoryColorMeta(colorKey);
return (meta.background, meta.foreground);
}
class CategoryIconBox extends StatelessWidget {
final String iconKey;
final String? colorKey;
final double size;
const CategoryIconBox({
super.key,
required this.iconKey,
this.colorKey,
this.size = 36,
});
@override
Widget build(BuildContext context) {
final (bg, fg) = categoryColors(colorKey);
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(size * 0.3),
),
child: Center(
child: AppIcons.byKey(iconKey, size: size * 0.5, color: fg),
),
);
}
}
Widget categoryIconSvg(
String key, {
double size = 24,
Color? color,
String? colorKey,
}) {
final (_, fg) = categoryColors(colorKey);
return AppIcons.byKey(key, size: size, color: color ?? fg);
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
/// P24 离线状态:顶部提示条 + AI 入口/tab 控制
class OfflineBar extends StatelessWidget {
const OfflineBar({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
color: AppTheme.orange,
child: Row(
children: [
AppIcons.icon(AppIcons.offline, size: 16, color: Colors.white),
const SizedBox(width: 8),
const Expanded(
child: Text(
'当前无网络 · AI 功能暂不可用 · 手动记账正常',
style: TextStyle(fontSize: 10.5, color: Colors.white),
),
),
],
),
);
}
}
/// 检测是否有网络连接(简化版:同时 ping 后端)
class ConnectivityChecker {
static bool _lastKnown = true;
static bool get isOnline => _lastKnown;
static Future<bool> check() async {
// 简化实现:future 如果需要真正的网络检测,用 connectivity_plus 包
// 当前返回上次已知状态
return _lastKnown;
}
static void setOffline() => _lastKnown = false;
static void setOnline() => _lastKnown = true;
}