Initial project import
This commit is contained in:
@@ -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 '出错了,请稍后再试';
|
||||
}
|
||||
@@ -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
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user