feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持

This commit is contained in:
qzl
2026-03-27 14:05:03 +08:00
parent b1f0eb8921
commit c592cc7854
178 changed files with 10748 additions and 5764 deletions
-174
View File
@@ -1,174 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../cache/cache_invalidator.dart';
import '../cache/hybrid_cache_store.dart';
import '../cache/memory_cache_store.dart';
import '../cache/persistent_cache_store.dart';
import '../api/api_client.dart';
import '../api/i_api_client.dart';
import '../storage/token_storage.dart';
import '../config/env.dart';
import '../notifications/local_notification_service.dart';
import '../../features/auth/data/auth_api.dart';
import '../../features/auth/data/auth_repository.dart';
import '../../features/auth/data/auth_repository_impl.dart';
import '../../features/auth/presentation/bloc/auth_bloc.dart';
import '../../features/auth/presentation/bloc/auth_event.dart';
import '../../features/calendar/data/calendar_api.dart';
import '../../features/calendar/data/services/calendar_repository.dart';
import '../../features/calendar/data/services/calendar_service.dart';
import '../../features/calendar/reminders/reminder_action_executor.dart';
import '../../features/calendar/ui/calendar_state_manager.dart';
import '../../features/friends/data/friends_api.dart';
import '../../features/messages/data/inbox_api.dart';
import '../../features/settings/data/settings_api.dart';
import '../../features/settings/data/services/automation_jobs_api.dart';
import '../../features/settings/data/services/settings_user_cache.dart';
import '../../features/settings/data/services/user_profile_cache_repository.dart';
import '../../features/settings/data/services/memory_service.dart';
import '../../features/users/data/users_api.dart';
import '../../features/todo/data/todo_api.dart';
import '../../features/todo/data/todo_repository.dart';
final sl = GetIt.instance;
Future<void> configureDependencies() async {
if (sl.isRegistered<IApiClient>()) {
await sl.reset();
}
final SecureTokenStorage tokenStorage;
final dio = Dio(BaseOptions(baseUrl: Env.apiUrl));
tokenStorage = SecureTokenStorage(
const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
),
);
final apiClient = ApiClient(
baseUrl: Env.apiUrl,
tokenStorage: tokenStorage,
dio: dio,
);
sl.registerSingleton<IApiClient>(apiClient);
final authApi = AuthApi(apiClient);
sl.registerSingleton<AuthApi>(authApi);
final sharedPreferences = await SharedPreferences.getInstance();
sl.registerSingleton<SharedPreferences>(sharedPreferences);
final memoryCacheStore = MemoryCacheStore();
final persistentCacheStore = PersistentCacheStore();
final hybridCacheStore = HybridCacheStore(
memory: memoryCacheStore,
persistent: persistentCacheStore,
);
sl.registerSingleton<MemoryCacheStore>(memoryCacheStore);
sl.registerSingleton<PersistentCacheStore>(persistentCacheStore);
sl.registerSingleton<HybridCacheStore>(hybridCacheStore);
sl.registerSingleton<CacheInvalidator>(
CacheInvalidator(store: hybridCacheStore),
);
final usersApi = UsersApi(apiClient);
sl.registerSingleton<UsersApi>(usersApi);
final userProfileCacheRepository = UserProfileCacheRepository(
store: hybridCacheStore,
remoteLoader: usersApi.getMe,
);
sl.registerSingleton<UserProfileCacheRepository>(userProfileCacheRepository);
final calendarApi = CalendarApi(apiClient);
sl.registerSingleton<CalendarApi>(calendarApi);
final calendarService = CalendarService(apiClient: apiClient);
sl.registerSingleton<CalendarService>(calendarService);
final calendarRepository = CalendarRepository(
store: hybridCacheStore,
loadDayFromRemote: calendarService.getEventsForDay,
loadMonthFromRemote: calendarService.getEventsForRange,
);
sl.registerSingleton<CalendarRepository>(calendarRepository);
sl.registerSingleton<LocalNotificationService>(LocalNotificationService());
final reminderActionExecutor = ReminderActionExecutor(
calendarService: calendarService,
notificationService: sl<LocalNotificationService>(),
);
sl.registerSingleton<ReminderActionExecutor>(reminderActionExecutor);
final friendsApi = FriendsApi(apiClient);
sl.registerSingleton<FriendsApi>(friendsApi);
final settingsApi = SettingsApi(apiClient);
sl.registerSingleton<SettingsApi>(settingsApi);
final automationJobsApi = AutomationJobsApi(apiClient);
sl.registerSingleton<AutomationJobsApi>(automationJobsApi);
final memoryService = MemoryService(apiClient);
sl.registerSingleton<MemoryService>(memoryService);
sl.registerSingleton<SettingsUserCache>(
SettingsUserCache(userProfileCacheRepository),
);
final inboxApi = InboxApi(apiClient);
sl.registerSingleton<InboxApi>(inboxApi);
final todoApi = TodoApi(apiClient);
sl.registerSingleton<TodoApi>(todoApi);
sl.registerSingleton<TodoRepository>(
TodoRepository(
api: todoApi,
store: hybridCacheStore,
invalidator: sl<CacheInvalidator>(),
),
);
final authRepository = AuthRepositoryImpl(
api: authApi,
tokenStorage: tokenStorage,
onLogout: () async {
apiClient.resetInterceptor();
if (sl.isRegistered<SettingsUserCache>()) {
sl<SettingsUserCache>().invalidate();
}
},
);
sl.registerSingleton<AuthRepository>(authRepository);
final authBloc = AuthBloc(authRepository);
sl.registerSingleton<AuthBloc>(authBloc);
apiClient.setRefreshCallback((token) async {
try {
await authRepository.refreshSession(token);
return true;
} catch (_) {
return false;
}
});
apiClient.setAuthFailureCallback(() async {
if (sl.isRegistered<SettingsUserCache>()) {
sl<SettingsUserCache>().invalidate();
}
authBloc.add(
const AuthSessionInvalidated(
source: AuthInvalidationSource.unauthorized401,
),
);
});
sl.registerSingleton<CalendarStateManager>(CalendarStateManager());
}
@@ -1,53 +0,0 @@
import 'package:formz/formz.dart';
class Username extends FormzInput<String, String> {
const Username.pure() : super.pure('');
const Username.dirty([super.value = '']) : super.dirty();
@override
String? validator(String value) {
if (value.isEmpty) return '请输入用户名';
if (value.length < 3) return '用户名至少 3 个字符';
if (value.length > 30) return '用户名最多 30 个字符';
return null;
}
}
class Phone extends FormzInput<String, String> {
const Phone.pure() : super.pure('');
const Phone.dirty([super.value = '']) : super.dirty();
static final _regex = RegExp(r'^\d{7,14}$');
@override
String? validator(String value) {
final normalized = value.replaceAll(RegExp(r'\s+'), '');
if (normalized.isEmpty) return '请输入手机号';
if (!_regex.hasMatch(normalized)) return '手机号格式不正确';
return null;
}
}
class Password extends FormzInput<String, String> {
const Password.pure() : super.pure('');
const Password.dirty([super.value = '']) : super.dirty();
@override
String? validator(String value) {
if (value.isEmpty) return '请输入密码';
if (value.length < 6) return '密码至少 6 个字符';
return null;
}
}
class VerificationCode extends FormzInput<String, String> {
const VerificationCode.pure() : super.pure('');
const VerificationCode.dirty([super.value = '']) : super.dirty();
@override
String? validator(String value) {
if (value.isEmpty) return '请输入验证码';
if (!RegExp(r'^\d{6}$').hasMatch(value)) return '验证码必须是 6 位数字';
return null;
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/widgets.dart';
import '../../l10n/app_localizations.dart';
class L10n {
static Locale _locale = const Locale('zh');
static void setLocale(Locale locale) {
_locale = locale;
}
static AppLocalizations get current => lookupAppLocalizations(_locale);
}
extension L10nContextX on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this);
}
@@ -1,12 +1,21 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../l10n/l10n.dart';
import 'error_code_mapper.dart';
abstract class ApiException implements Exception {
final String message;
final int? statusCode;
final String? errorCode;
final Map<String, dynamic>? errorParams;
const ApiException(this.message, {this.statusCode});
const ApiException(
this.message, {
this.statusCode,
this.errorCode,
this.errorParams,
});
@override
String toString() => message;
@@ -19,6 +28,8 @@ abstract class ApiException implements Exception {
final data = response?.data;
String detail;
String? errorCode;
Map<String, dynamic>? errorParams;
final decodedData = _normalizeErrorData(data);
if (decodedData is Map<String, dynamic>) {
@@ -27,26 +38,50 @@ abstract class ApiException implements Exception {
decodedData['message'] ??
decodedData['error'])
?.toString() ??
'请求失败';
L10n.current.errorRequestFailed;
final code = decodedData['code'];
if (code is String && code.trim().isNotEmpty) {
errorCode = code;
}
final params = decodedData['params'];
if (params is Map<String, dynamic>) {
errorParams = params;
} else if (params is Map) {
errorParams = params.map(
(key, value) => MapEntry(key.toString(), value),
);
}
} else {
detail = _networkErrorMessage(error);
}
final localized = _localizeError(detail, statusCode);
final localized = _localizeError(
detail,
statusCode,
errorCode: errorCode,
errorParams: errorParams,
);
if (statusCode == 401) {
return UnauthorizedException(localized);
return UnauthorizedException(message: localized, errorCode: errorCode);
}
if (statusCode == 422) {
return ValidationException(
localized,
errors: data,
statusCode: statusCode,
errorCode: errorCode,
errorParams: errorParams,
);
}
return ServerException(localized, statusCode: statusCode);
return ServerException(
localized,
statusCode: statusCode,
errorCode: errorCode,
errorParams: errorParams,
);
}
return const ServerException('网络错误');
return ServerException(L10n.current.errorNetwork);
}
static Map<String, dynamic>? _normalizeErrorData(dynamic data) {
@@ -72,52 +107,72 @@ abstract class ApiException implements Exception {
return null;
}
static String _localizeError(String detail, int? statusCode) {
static String _localizeError(
String detail,
int? statusCode, {
String? errorCode,
Map<String, dynamic>? errorParams,
}) {
final mapped = resolveErrorCodeMessage(errorCode, params: errorParams);
if (mapped != null && mapped.isNotEmpty) {
return mapped;
}
if (statusCode == 403) {
return '没有权限执行此操作';
return L10n.current.errorForbidden;
}
if (statusCode == 404) {
return '请求的资源不存在';
return L10n.current.errorNotFound;
}
if (statusCode == 429) {
final normalized = detail.trim();
if (normalized.isEmpty || normalized == '请求失败') {
return '请求过于频繁,请稍后再试';
}
return detail;
return L10n.current.errorTooManyRequests;
}
if (statusCode != null && statusCode >= 500) {
return '服务器错误,请稍后再试';
return L10n.current.errorServer;
}
return detail;
return L10n.current.errorGenericSafe;
}
static String _networkErrorMessage(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return '网络超时,请确认手机与服务端在同一网络后重试';
return L10n.current.errorNetworkTimeout;
}
if (error.type == DioExceptionType.connectionError ||
error.type == DioExceptionType.unknown) {
return '无法连接服务器。请在 iPhone 设置中为本应用开启“无线数据(WLAN与蜂窝网络)”,并确认本地网络权限已开启。';
return L10n.current.errorNetworkUnavailable;
}
return '请求失败';
return L10n.current.errorRequestFailed;
}
}
class ServerException extends ApiException {
const ServerException(super.message, {super.statusCode});
const ServerException(
super.message, {
super.statusCode,
super.errorCode,
super.errorParams,
});
}
class UnauthorizedException extends ApiException {
const UnauthorizedException([super.message = '请重新登录'])
: super(statusCode: 401);
UnauthorizedException({String? message, String? errorCode})
: super(
message ?? L10n.current.errorReLogin,
statusCode: 401,
errorCode: errorCode,
);
}
class ValidationException extends ApiException {
final Map<String, dynamic>? errors;
const ValidationException(super.message, {this.errors, super.statusCode});
const ValidationException(
super.message, {
this.errors,
super.statusCode,
super.errorCode,
super.errorParams,
});
}
@@ -0,0 +1,244 @@
import '../l10n/l10n.dart';
String? mapErrorCodeToL10nKey(
String? errorCode, {
Map<String, dynamic>? params,
}) {
if (errorCode == null || errorCode.isEmpty) {
return null;
}
switch (errorCode) {
case 'AGENT_RUN_INPUT_INVALID':
return 'errorGenericSafe';
case 'AGENT_RUN_MESSAGES_INVALID':
return 'errorGenericSafe';
case 'AGENT_INVALID_LAST_EVENT_ID':
return 'errorAgentInvalidLastEventId';
case 'AGENT_SSE_CONNECTION_LIMIT':
return 'errorAgentSseConnectionLimit';
case 'AGENT_ATTACHMENT_EMPTY':
return 'errorAgentAttachmentEmpty';
case 'AGENT_ATTACHMENT_TOO_LARGE':
return 'errorAgentAttachmentTooLarge';
case 'AGENT_AUDIO_UNSUPPORTED_FORMAT':
return 'errorAgentAudioUnsupportedFormat';
case 'AGENT_AUDIO_TOO_LARGE':
return 'errorAgentAudioTooLarge';
case 'AGENT_AUDIO_EMPTY':
return 'errorAgentAudioEmpty';
case 'AGENT_ASR_UNAVAILABLE':
return 'errorAgentAsrUnavailable';
case 'AGENT_FORBIDDEN':
return 'errorForbidden';
case 'AGENT_PAYLOAD_INVALID':
return 'errorGenericSafe';
case 'AGENT_ATTACHMENTS_TOO_MANY':
return 'errorGenericSafe';
case 'AGENT_SIGNED_IMAGE_URL_INVALID':
return 'errorGenericSafe';
case 'AGENT_ATTACHMENT_STORAGE_UNAVAILABLE':
return 'errorServer';
case 'AGENT_ATTACHMENT_UNSUPPORTED_TYPE':
return 'errorGenericSafe';
case 'AGENT_ATTACHMENT_UPLOAD_FAILED':
return 'errorGenericSafe';
case 'AGENT_ATTACHMENT_BUCKET_INVALID':
return 'errorGenericSafe';
case 'AGENT_ATTACHMENT_PATH_SCOPE_INVALID':
return 'errorGenericSafe';
case 'AGENT_SIGNED_URL_GENERATION_FAILED':
return 'errorGenericSafe';
case 'AGENT_SESSION_ID_INVALID':
return 'errorGenericSafe';
case 'AGENT_SESSION_NOT_FOUND':
return 'errorNotFound';
case 'AGENT_USER_ID_INVALID':
return 'errorGenericSafe';
case 'INVALID_BINARY_URL_HOST':
return 'errorAgentInvalidBinaryUrl';
case 'INVALID_BINARY_URL_BUCKET':
return 'errorAgentInvalidBinaryUrl';
case 'INVALID_BINARY_URL_PATH_SCOPE':
return 'errorAgentInvalidBinaryUrl';
case 'AUTH_SERVICE_UNAVAILABLE':
return 'errorServer';
case 'AUTH_TOO_MANY_REQUESTS':
return 'errorTooManyRequests';
case 'AUTH_VERIFICATION_CODE_INVALID':
return 'errorGenericSafe';
case 'AUTH_REFRESH_TOKEN_INVALID':
return 'errorReLogin';
case 'AUTH_REFRESH_TOKEN_MISSING':
return 'errorReLogin';
case 'AUTH_USER_NOT_FOUND':
return 'errorNotFound';
case 'AUTH_UNAUTHORIZED':
return 'errorReLogin';
case 'JWT_VERIFIER_NOT_CONFIGURED':
return 'errorServer';
case 'AUTOMATION_JOB_LIMIT_EXCEEDED':
return 'errorGenericSafe';
case 'AUTOMATION_SYSTEM_JOB_MODIFICATION_FORBIDDEN':
return 'errorForbidden';
case 'AUTOMATION_JOB_NOT_FOUND':
return 'errorNotFound';
case 'AUTOMATION_JOB_STORE_UNAVAILABLE':
return 'errorServer';
case 'NOT_FOUND':
return 'errorNotFound';
case 'LOOKUP_FAILED':
return 'errorServer';
case 'INTERNAL_ERROR':
return 'errorServer';
case 'MISSING_RUNTIME_ARGS':
return 'errorGenericSafe';
case 'TOOL_PENDING_APPROVAL':
return 'errorGenericSafe';
case 'TOOL_REJECTED':
return 'errorForbidden';
case 'USER_STORE_UNAVAILABLE':
return 'errorServer';
case 'USER_NOT_FOUND':
return 'errorNotFound';
case 'USER_UPDATE_FIELDS_EMPTY':
return 'errorGenericSafe';
case 'USER_AVATAR_UNSUPPORTED_TYPE':
return 'errorGenericSafe';
case 'USER_AVATAR_TOO_LARGE':
return 'errorGenericSafe';
case 'USER_AVATAR_EMPTY':
return 'errorGenericSafe';
case 'USER_AVATAR_UPLOAD_FAILED':
return 'errorGenericSafe';
case 'USER_AUTH_LOOKUP_UNAVAILABLE':
return 'errorServer';
case 'TODO_SERVICE_UNAVAILABLE':
return 'errorServer';
case 'TODO_NOT_FOUND':
return 'errorNotFound';
case 'TODO_ACCESS_FORBIDDEN':
return 'errorForbidden';
case 'TODO_REORDER_DUPLICATE_ID':
return 'errorGenericSafe';
case 'TODO_STATUS_INVALID':
return 'errorGenericSafe';
case 'TODO_PRIORITY_INVALID':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_INVALID_TIME_RANGE':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_STORE_UNAVAILABLE':
return 'errorServer';
case 'SCHEDULE_ITEM_NOT_FOUND':
return 'errorNotFound';
case 'SCHEDULE_ITEM_START_AT_TIMEZONE_REQUIRED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_PAGE_INVALID':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_PAGE_SIZE_INVALID':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_SHARE_FORBIDDEN':
return 'errorForbidden';
case 'SCHEDULE_ITEM_SHARE_PERMISSION_EXCEEDED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_SUBSCRIPTION_ALREADY_ACTIVE':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_INVITE_ALREADY_SUBSCRIBED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_INVITE_ALREADY_PENDING':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_AUTH_LOOKUP_UNAVAILABLE':
return 'errorServer';
case 'SCHEDULE_ITEM_PENDING_INVITE_NOT_FOUND':
return 'errorNotFound';
case 'SCHEDULE_ITEM_ACCEPT_SUBSCRIPTION_FAILED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_REJECT_SUBSCRIPTION_FAILED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_DATETIME_TIMEZONE_REQUIRED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_DATETIME_REQUIRED':
return 'errorGenericSafe';
case 'INBOX_MESSAGE_NOT_FOUND':
return 'errorNotFound';
case 'INBOX_MESSAGE_STORE_UNAVAILABLE':
return 'errorServer';
case 'MEMORIES_USER_NOT_FOUND':
return 'errorNotFound';
case 'MEMORIES_WORK_NOT_FOUND':
return 'errorNotFound';
case 'MEMORIES_SERVICE_UNAVAILABLE':
return 'errorServer';
case 'FRIEND_REQUEST_SELF_NOT_ALLOWED':
return 'errorGenericSafe';
case 'FRIEND_ALREADY_ACCEPTED':
return 'errorGenericSafe';
case 'FRIEND_REQUEST_BLOCKED':
return 'errorGenericSafe';
case 'FRIEND_REQUEST_ALREADY_SENT':
return 'errorGenericSafe';
case 'FRIENDSHIP_SERVICE_UNAVAILABLE':
return 'errorServer';
case 'FRIEND_REQUEST_NOT_FOUND':
return 'errorNotFound';
case 'FRIEND_REQUEST_FORBIDDEN':
return 'errorForbidden';
case 'FRIEND_REQUEST_NOT_PENDING':
return 'errorGenericSafe';
case 'FRIEND_INBOX_MESSAGE_NOT_FOUND':
return 'errorNotFound';
case 'FRIENDSHIP_DATA_INVALID':
return 'errorGenericSafe';
case 'FRIENDSHIP_NOT_FOUND':
return 'errorNotFound';
case 'FRIENDSHIP_REMOVE_REQUIRES_ACCEPTED':
return 'errorGenericSafe';
default:
return null;
}
}
String? resolveErrorCodeMessage(
String? errorCode, {
Map<String, dynamic>? params,
}) {
final key = mapErrorCodeToL10nKey(errorCode, params: params);
if (key == null) {
return null;
}
switch (key) {
case 'errorAgentSseConnectionLimit':
return L10n.current.errorAgentSseConnectionLimit;
case 'errorAgentAttachmentEmpty':
return L10n.current.errorAgentAttachmentEmpty;
case 'errorAgentAttachmentTooLarge':
return L10n.current.errorAgentAttachmentTooLarge;
case 'errorAgentAudioEmpty':
return L10n.current.errorAgentAudioEmpty;
case 'errorAgentAudioTooLarge':
return L10n.current.errorAgentAudioTooLarge;
case 'errorAgentAudioUnsupportedFormat':
return L10n.current.errorAgentAudioUnsupportedFormat;
case 'errorAgentAsrUnavailable':
return L10n.current.errorAgentAsrUnavailable;
case 'errorAgentInvalidLastEventId':
return L10n.current.errorAgentInvalidLastEventId;
case 'errorAgentInvalidBinaryUrl':
return L10n.current.errorAgentInvalidBinaryUrl;
case 'errorForbidden':
return L10n.current.errorForbidden;
case 'errorNotFound':
return L10n.current.errorNotFound;
case 'errorTooManyRequests':
return L10n.current.errorTooManyRequests;
case 'errorServer':
return L10n.current.errorServer;
case 'errorGenericSafe':
return L10n.current.errorGenericSafe;
case 'errorReLogin':
return L10n.current.errorReLogin;
default:
return null;
}
}
@@ -1,31 +0,0 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/calendar/reminders/models/reminder_payload.dart';
class IOSNotificationPayloadBridge {
static const String _key = 'pending_notification_payload';
final SharedPreferences _prefs;
IOSNotificationPayloadBridge(this._prefs);
Future<ReminderPayload?> getPendingPayload() async {
final raw = _prefs.getString(_key);
if (raw == null || raw.isEmpty) {
return null;
}
try {
final json = Map<String, dynamic>.from(jsonDecode(raw) as Map);
return ReminderPayload.fromJson(json);
} catch (_) {
return null;
}
}
Future<void> setPendingPayload(ReminderPayload payload) async {
await _prefs.setString(_key, jsonEncode(payload.toJson()));
}
Future<void> clearPendingPayload() async {
await _prefs.remove(_key);
}
}
@@ -1,306 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
import 'reminder_notification_callbacks.dart';
import '../../features/calendar/data/models/schedule_item_model.dart';
import '../../features/calendar/reminders/models/reminder_payload.dart';
class LocalNotificationService {
final FlutterLocalNotificationsPlugin _plugin;
bool _initialized = false;
LocalNotificationService({FlutterLocalNotificationsPlugin? plugin})
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
Future<void> initialize() async {
if (_initialized) {
return;
}
tz_data.initializeTimeZones();
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const ios = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
final settings = InitializationSettings(android: android, iOS: ios);
await _plugin.initialize(
settings,
onDidReceiveNotificationResponse:
ReminderNotificationCallbacks.onForegroundResponse,
onDidReceiveBackgroundNotificationResponse:
reminderNotificationTapBackground,
);
final androidImpl = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
await androidImpl?.requestNotificationsPermission();
await androidImpl?.requestExactAlarmsPermission();
await androidImpl?.requestFullScreenIntentPermission();
final iosImpl = _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
await iosImpl?.requestPermissions(alert: true, badge: true, sound: true);
_initialized = true;
}
Future<void> upsertEventReminder(ScheduleItemModel event) async {
await initialize();
if (event.status != ScheduleStatus.active ||
event.metadata?.reminderMinutes == null) {
await cancelEventReminder(event.id);
return;
}
final now = DateTime.now();
final reminderMinutes = event.metadata?.reminderMinutes ?? 0;
final fireAt = event.startAt.subtract(Duration(minutes: reminderMinutes));
if (fireAt.isBefore(now)) {
await cancelEventReminder(event.id);
return;
}
await cancelEventReminder(event.id);
await _scheduleRemindersFrom(event: event, firstFireAt: fireAt);
}
Future<void> scheduleReminderAt(
ScheduleItemModel event,
DateTime fireAt,
) async {
await initialize();
await cancelEventReminder(event.id);
await _scheduleRemindersFrom(event: event, firstFireAt: fireAt);
}
Future<void> cancelEventReminder(String eventId) async {
await initialize();
final pending = await _plugin.pendingNotificationRequests();
for (final request in pending) {
final payload = _decodePayload(request.payload);
if (payload == null) {
continue;
}
if (payload.eventId == eventId ||
payload.aggregateIds.contains(eventId)) {
await _plugin.cancel(request.id);
}
}
await _plugin.cancel(_notificationIdForEvent(eventId));
}
Future<void> rebuildUpcomingReminders(
Iterable<ScheduleItemModel> events,
) async {
await initialize();
for (final event in events) {
await upsertEventReminder(event);
}
}
int _notificationIdForEvent(String eventId) {
return eventId.hashCode & 0x7fffffff;
}
int _notificationIdForEventCycle(
String eventId,
DateTime fireAt,
ReminderPayloadMode mode,
) {
final cycleMinute =
fireAt.millisecondsSinceEpoch ~/
const Duration(minutes: 1).inMilliseconds;
return '$eventId|$cycleMinute|${mode.value}'.hashCode & 0x7fffffff;
}
Future<AndroidScheduleMode> _resolveAndroidScheduleMode() async {
final androidImpl = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidImpl == null) {
return AndroidScheduleMode.exactAllowWhileIdle;
}
final canScheduleExact =
await androidImpl.canScheduleExactNotifications() ?? false;
return canScheduleExact
? AndroidScheduleMode.exactAllowWhileIdle
: AndroidScheduleMode.inexactAllowWhileIdle;
}
NotificationDetails _buildNotificationDetails(DateTime fireAt) {
return NotificationDetails(
android: AndroidNotificationDetails(
'calendar_alarm_channel_v2',
'日程闹钟提醒',
channelDescription: '日程到点闹钟式提醒通知',
importance: Importance.max,
priority: Priority.max,
category: AndroidNotificationCategory.alarm,
audioAttributesUsage: AudioAttributesUsage.alarm,
fullScreenIntent: true,
playSound: true,
enableVibration: true,
vibrationPattern: Int64List.fromList([0, 1000, 500, 1200]),
timeoutAfter: 30000,
autoCancel: true,
groupKey: _getGroupKey(fireAt),
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
presentBadge: true,
threadIdentifier: _getThreadIdentifier(fireAt),
),
);
}
String _getThreadIdentifier(DateTime fireAt) {
final bucket =
fireAt.millisecondsSinceEpoch ~/
const Duration(minutes: 1).inMilliseconds;
return 'calendar_reminder_$bucket';
}
String _getGroupKey(DateTime fireAt) {
final bucket =
fireAt.millisecondsSinceEpoch ~/
const Duration(minutes: 1).inMilliseconds;
return 'com.socialapp.calendar.$bucket';
}
Future<void> _scheduleSingleReminder({
required ScheduleItemModel event,
required DateTime fireAt,
}) async {
final notificationId = _notificationIdForEventCycle(
event.id,
fireAt,
ReminderPayloadMode.single,
);
final payload = ReminderPayload(
eventId: event.id,
title: event.title,
startAt: event.startAt,
endAt: event.endAt,
timezone: event.timezone,
location: event.metadata?.location,
notes: event.metadata?.notes,
color: event.metadata?.color,
mode: ReminderPayloadMode.single,
fireTimeBucket:
fireAt.millisecondsSinceEpoch ~/
const Duration(minutes: 1).inMilliseconds,
version: 1,
);
final details = _buildNotificationDetails(fireAt);
final scheduledAt = tz.TZDateTime.from(fireAt, tz.local);
final mode = await _resolveAndroidScheduleMode();
try {
await _plugin.zonedSchedule(
notificationId,
event.title,
_buildReminderBody(event, event.metadata?.reminderMinutes ?? 0),
scheduledAt,
details,
payload: jsonEncode(payload.toJson()),
androidScheduleMode: mode,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
} catch (_) {
await _plugin.zonedSchedule(
notificationId,
event.title,
_buildReminderBody(event, event.metadata?.reminderMinutes ?? 0),
scheduledAt,
details,
payload: jsonEncode(payload.toJson()),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
}
}
Future<void> _scheduleRemindersFrom({
required ScheduleItemModel event,
required DateTime firstFireAt,
}) async {
final endAt = event.endAt;
var cursor = firstFireAt;
if (endAt == null) {
await _scheduleSingleReminder(event: event, fireAt: cursor);
return;
}
while (cursor.isBefore(endAt)) {
await _scheduleSingleReminder(event: event, fireAt: cursor);
cursor = cursor.add(const Duration(minutes: 10));
}
}
ReminderPayload? _decodePayload(String? raw) {
if (raw == null || raw.isEmpty) {
return null;
}
try {
final json = Map<String, dynamic>.from(jsonDecode(raw) as Map);
return ReminderPayload.fromJson(json);
} catch (_) {
return null;
}
}
String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) {
final when = reminderMinutes == 0
? '日程现在开始'
: '日程即将开始(提前$reminderMinutes分钟';
final location = event.metadata?.location;
final notes = event.metadata?.notes;
final buffer = StringBuffer(when);
if (location != null && location.isNotEmpty) {
buffer.write('\n地点:$location');
}
if (notes != null && notes.isNotEmpty) {
buffer.write(
'\n备注:${notes.length > 30 ? '${notes.substring(0, 30)}...' : notes}',
);
}
return buffer.toString();
}
Future<void> handleNotificationResponse(NotificationResponse response) async {
final payloadRaw = response.payload;
if (payloadRaw == null || payloadRaw.isEmpty) {
return;
}
ReminderPayload payload;
try {
payload = ReminderPayload.fromJson(
Map<String, dynamic>.from(jsonDecode(payloadRaw) as Map),
);
} catch (_) {
debugPrint('failed to handle reminder notification response');
return;
}
ReminderNotificationCallbacks.onNotificationPayloadReceived?.call(payload);
}
}
@@ -1,152 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/calendar/reminders/models/reminder_payload.dart';
typedef ReminderNotificationResponseHandler =
Future<void> Function(NotificationResponse response);
class ReminderNotificationCallbacks {
static const String _pendingKey =
'calendar_reminder_pending_notification_responses_v1';
static ReminderNotificationResponseHandler? _responseHandler;
static Future<void> _pendingStorageLock = Future<void>.value();
static void Function(ReminderPayload)? onNotificationPayloadReceived;
@visibleForTesting
static Future<void> resetForTest() async {
_responseHandler = null;
_pendingStorageLock = Future<void>.value();
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingKey);
}
static Future<void> bindResponseHandler(
ReminderNotificationResponseHandler handler,
) async {
_responseHandler = handler;
await _drainPendingResponses();
}
static Future<void> onForegroundResponse(
NotificationResponse response,
) async {
final handler = _responseHandler;
if (handler == null) {
await _enqueuePendingResponse(response);
return;
}
try {
await handler(response);
} catch (_) {
await _enqueuePendingResponse(response);
}
}
static Future<void> onBackgroundResponse(
NotificationResponse response,
) async {
final handler = _responseHandler;
if (handler == null) {
await _enqueuePendingResponse(response);
return;
}
try {
await handler(response);
} catch (_) {
await _enqueuePendingResponse(response);
}
}
static Future<T> _withPendingStorageLock<T>(Future<T> Function() operation) {
final completer = Completer<void>();
final waitForTurn = _pendingStorageLock;
_pendingStorageLock = waitForTurn.then((_) => completer.future);
return waitForTurn.then((_) => operation()).whenComplete(() {
if (!completer.isCompleted) {
completer.complete();
}
});
}
static Future<void> _enqueuePendingResponse(
NotificationResponse response,
) async {
await _withPendingStorageLock(() async {
final prefs = await SharedPreferences.getInstance();
final current = prefs.getStringList(_pendingKey) ?? const <String>[];
final encoded = jsonEncode({
'id': response.id,
'actionId': response.actionId,
'payload': response.payload,
'type': response.notificationResponseType.index,
'input': response.input,
});
await prefs.setStringList(_pendingKey, <String>[...current, encoded]);
});
}
static Future<void> _drainPendingResponses() async {
final handler = _responseHandler;
if (handler == null) {
return;
}
await _withPendingStorageLock(() async {
final prefs = await SharedPreferences.getInstance();
final pending = prefs.getStringList(_pendingKey) ?? const <String>[];
if (pending.isEmpty) {
return;
}
final remaining = <String>[];
for (final raw in pending) {
Map<String, dynamic> parsed;
try {
parsed = Map<String, dynamic>.from(jsonDecode(raw) as Map);
} catch (_) {
continue;
}
final id = parsed['id'] as int?;
final actionId = parsed['actionId'] as String?;
final payload = parsed['payload'] as String?;
final typeIndex = (parsed['type'] as int?) ?? 0;
final input = parsed['input'] as String?;
final type = NotificationResponseType.values[typeIndex.clamp(0, 1)];
try {
await handler(
NotificationResponse(
id: id,
actionId: actionId,
payload: payload,
input: input,
notificationResponseType: type,
),
);
} catch (_) {
remaining.add(raw);
}
}
if (remaining.isEmpty) {
await prefs.remove(_pendingKey);
return;
}
await prefs.setStringList(_pendingKey, remaining);
});
}
}
@pragma('vm:entry-point')
Future<void> reminderNotificationTapBackground(
NotificationResponse response,
) async {
await ReminderNotificationCallbacks.onBackgroundResponse(response);
}
@@ -1,4 +0,0 @@
import 'package:flutter/widgets.dart';
final RouteObserver<ModalRoute<void>> appRouteObserver =
RouteObserver<ModalRoute<void>>();
-219
View File
@@ -1,219 +0,0 @@
import 'package:go_router/go_router.dart';
import 'app_route_observer.dart';
import '../../features/auth/presentation/bloc/auth_bloc.dart';
import '../../features/auth/presentation/bloc/auth_state.dart';
import 'app_routes.dart';
import 'go_router_refresh_stream.dart';
import '../../features/auth/ui/screens/auth_boot_screen.dart';
import '../../features/auth/ui/screens/login_screen.dart';
import '../../features/home/ui/screens/home_screen.dart';
import '../../features/messages/ui/screens/message_invite_list_screen.dart';
import '../../features/messages/ui/screens/message_invite_detail_screen.dart';
import '../../features/contacts/ui/screens/contacts_screen.dart';
import '../../features/contacts/ui/screens/add_contact_screen.dart';
import '../../features/calendar/ui/screens/calendar_dayweek_screen.dart';
import '../../features/calendar/ui/screens/calendar_month_screen.dart';
import '../../features/calendar/ui/screens/calendar_event_detail_screen.dart';
import '../../features/calendar/ui/screens/calendar_event_create_screen.dart';
import '../../features/calendar/ui/screens/calendar_event_edit_screen.dart';
import '../../features/calendar/ui/screens/calendar_event_share_screen.dart';
import '../../features/calendar/ui/calendar_time_utils.dart';
import '../../features/todo/ui/screens/todo_quadrants_screen.dart';
import '../../features/todo/ui/screens/todo_detail_screen.dart';
import '../../features/todo/ui/screens/todo_edit_screen.dart';
import '../../features/settings/ui/screens/settings_screen.dart';
import '../../features/settings/ui/screens/features_screen.dart';
import '../../features/settings/ui/screens/job_detail_screen.dart';
import '../../features/settings/ui/screens/memory_screen.dart';
import '../../features/settings/ui/screens/user_memory_view_screen.dart';
import '../../features/settings/ui/screens/work_memory_view_screen.dart';
import '../../features/settings/ui/screens/user_memory_detail_screen.dart';
import '../../features/settings/ui/screens/work_memory_detail_screen.dart';
import '../../features/settings/ui/screens/edit_profile_screen.dart';
final _homeSecondLevelRoutes = [
AppRoutes.shellHomeBranch,
AppRoutes.shellCalendarBranch,
AppRoutes.calendarMonth,
AppRoutes.shellTodoBranch,
AppRoutes.settingsMain,
];
final _protectedRoutes = [
..._homeSecondLevelRoutes,
AppRoutes.contactsList,
AppRoutes.contactsAdd,
'/calendar/events',
AppRoutes.settingsFeatures,
AppRoutes.settingsMemory,
AppRoutes.settingsMemoryUser,
AppRoutes.settingsMemoryWork,
AppRoutes.settingsMemoryUserEdit,
AppRoutes.settingsMemoryWorkEdit,
AppRoutes.settingsEditProfile,
AppRoutes.messageInviteList,
];
GoRouter createAppRouter(AuthBloc authBloc) {
return GoRouter(
initialLocation: AppRoutes.authBoot,
observers: [appRouteObserver],
refreshListenable: GoRouterRefreshStream(authBloc.stream),
redirect: (context, state) {
final authState = authBloc.state;
final isAuthenticated = authState is AuthAuthenticated;
final isAuthChecking =
authState is AuthInitial || authState is AuthLoading;
final isBootRoute = state.matchedLocation == AppRoutes.authBoot;
final isAuthRoute =
state.matchedLocation == AppRoutes.authLogin ||
state.matchedLocation.startsWith('/login');
final isProtected = _protectedRoutes.any(
(route) => state.matchedLocation.startsWith(route),
);
if (isAuthChecking && !isBootRoute) {
return AppRoutes.authBoot;
}
if (!isAuthChecking && isBootRoute) {
return isAuthenticated ? AppRoutes.homeMain : AppRoutes.authLogin;
}
if (!isAuthenticated && isProtected) {
return AppRoutes.authLogin;
}
if (isAuthenticated && isAuthRoute) {
return AppRoutes.homeMain;
}
return null;
},
routes: [
GoRoute(
path: AppRoutes.authBoot,
builder: (context, state) => const AuthBootScreen(),
),
GoRoute(
path: AppRoutes.calendarEventCreate,
builder: (context, state) => CalendarEventCreateScreen(
initialDate: parseYmd(state.uri.queryParameters['date']),
),
),
GoRoute(
path: '/calendar/events/:id',
builder: (context, state) =>
CalendarEventDetailScreen(eventId: state.pathParameters['id']!),
),
GoRoute(
path: '/calendar/events/:id/edit',
builder: (context, state) =>
CalendarEventEditScreen(eventId: state.pathParameters['id']!),
),
GoRoute(
path: '/calendar/events/:id/share',
builder: (context, state) =>
CalendarEventShareScreen(eventId: state.pathParameters['id']!),
),
GoRoute(
path: AppRoutes.authLogin,
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: AppRoutes.homeMain,
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: AppRoutes.messageInviteList,
builder: (context, state) => const MessageInviteListScreen(),
),
GoRoute(
path: '/messages/invites/:id',
builder: (context, state) =>
MessageInviteDetailScreen(inviteId: state.pathParameters['id']!),
),
GoRoute(
path: AppRoutes.contactsList,
builder: (context, state) => const ContactsScreen(),
),
GoRoute(
path: AppRoutes.contactsAdd,
builder: (context, state) => const AddContactScreen(),
),
GoRoute(
path: AppRoutes.calendarDayWeek,
builder: (context, state) {
final fromHome = state.uri.queryParameters['from'] == 'home';
final initialDate = parseYmd(state.uri.queryParameters['date']);
return CalendarDayWeekScreen(
initialDate: initialDate,
resetToToday: fromHome,
);
},
),
GoRoute(
path: AppRoutes.calendarMonth,
builder: (context, state) {
final fromHome = state.uri.queryParameters['from'] == 'home';
return CalendarMonthScreen(resetToToday: fromHome);
},
),
GoRoute(
path: AppRoutes.todoList,
builder: (context, state) => const TodoQuadrantsScreen(),
),
GoRoute(
path: AppRoutes.todoCreate,
builder: (context, state) => const TodoEditScreen.create(),
),
GoRoute(
path: '/todo/:id',
builder: (context, state) =>
TodoDetailScreen(todoId: state.pathParameters['id']!),
),
GoRoute(
path: '/todo/:id/edit',
builder: (context, state) =>
TodoEditScreen(todoId: state.pathParameters['id']!),
),
GoRoute(
path: AppRoutes.settingsMain,
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: AppRoutes.settingsFeatures,
builder: (context, state) => const FeaturesScreen(),
),
GoRoute(
path: AppRoutes.settingsJobNew,
builder: (context, state) => const JobDetailScreen(),
),
GoRoute(
path: '/settings/job/:id',
builder: (context, state) =>
JobDetailScreen(jobId: state.pathParameters['id']),
),
GoRoute(
path: AppRoutes.settingsMemory,
builder: (context, state) => const MemoryScreen(),
),
GoRoute(
path: AppRoutes.settingsMemoryUser,
builder: (context, state) => const UserMemoryViewScreen(),
),
GoRoute(
path: AppRoutes.settingsMemoryWork,
builder: (context, state) => const WorkMemoryViewScreen(),
),
GoRoute(
path: AppRoutes.settingsMemoryUserEdit,
builder: (context, state) => const UserMemoryDetailScreen(),
),
GoRoute(
path: AppRoutes.settingsMemoryWorkEdit,
builder: (context, state) => const WorkMemoryDetailScreen(),
),
GoRoute(
path: AppRoutes.settingsEditProfile,
builder: (context, state) => const EditProfileScreen(),
),
],
);
}
-40
View File
@@ -1,40 +0,0 @@
class AppRoutes {
AppRoutes._();
static const authBoot = '/boot';
static const authLogin = '/';
static const homeMain = '/home';
static const shellHomeBranch = homeMain;
static const shellCalendarBranch = calendarDayWeek;
static const shellTodoBranch = todoList;
static const messageInviteList = '/messages/invites';
static String messageInviteDetail(String id) => '/messages/invites/$id';
static const contactsList = '/contacts';
static const contactsAdd = '/contacts/add';
static const calendarDayWeek = '/calendar/dayweek';
static const calendarMonth = '/calendar/month';
static String calendarEventDetail(String id) => '/calendar/events/$id';
static const calendarEventCreate = '/calendar/events/new';
static String calendarEventEdit(String id) => '/calendar/events/$id/edit';
static String calendarEventShare(String id) => '/calendar/events/$id/share';
static const todoList = '/todo';
static String todoDetail(String id) => '/todo/$id';
static const todoCreate = '/todo/new';
static String todoEdit(String id) => '/todo/$id/edit';
static const settingsMain = '/settings';
static const settingsFeatures = '/settings/features';
static const settingsJobNew = '/settings/job/new';
static String settingsJobDetail(String id) => '/settings/job/$id';
static const settingsMemory = '/settings/memory';
static const settingsMemoryUser = '/settings/memory/user';
static const settingsMemoryWork = '/settings/memory/work';
static const settingsMemoryUserEdit = '/settings/memory/user/edit';
static const settingsMemoryWorkEdit = '/settings/memory/work/edit';
static const settingsEditProfile = '/edit-profile';
}
@@ -1,17 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
class GoRouterRefreshStream extends ChangeNotifier {
GoRouterRefreshStream(Stream<dynamic> stream) {
notifyListeners();
_subscription = stream.listen((_) => notifyListeners());
}
late final StreamSubscription<dynamic> _subscription;
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}
-14
View File
@@ -1,14 +0,0 @@
// UI Schema Protocol Implementation.
///
/// This file is the single source of truth for UI Schema.
/// All implementations must follow docs/protocols/ui-schema.md.
///
/// Version: 1.0
library;
part 'ui_schema/enums.dart';
part 'ui_schema/common_types.dart';
part 'ui_schema/actions.dart';
part 'ui_schema/nodes.dart';
part 'ui_schema/document.dart';
part 'ui_schema/builders.dart';
@@ -1,205 +0,0 @@
part of '../ui_schema.dart';
abstract class ActionSpec {
String get type;
Map<String, dynamic> toJson();
}
class NavigateAction implements ActionSpec {
final String path;
final Map<String, dynamic>? params;
const NavigateAction({required this.path, this.params});
@override
String get type => 'navigation';
@override
Map<String, dynamic> toJson() {
return {'type': type, 'path': path, if (params != null) 'params': params};
}
}
class LinkAction implements ActionSpec {
final String url;
final String? target;
const LinkAction({required this.url, this.target});
@override
String get type => 'url';
@override
Map<String, dynamic> toJson() {
return {'type': type, 'url': url, if (target != null) 'target': target};
}
}
class EventAction implements ActionSpec {
final String event;
final Map<String, dynamic>? payload;
const EventAction({required this.event, this.payload});
@override
String get type => 'event';
@override
Map<String, dynamic> toJson() {
return {
'type': type,
'event': event,
if (payload != null) 'payload': payload,
};
}
}
class ToolAction implements ActionSpec {
final String toolId;
final Map<String, dynamic>? params;
const ToolAction({required this.toolId, this.params});
@override
String get type => 'tool';
@override
Map<String, dynamic> toJson() {
return {
'type': type,
'toolId': toolId,
if (params != null) 'params': params,
};
}
}
class CopyAction implements ActionSpec {
final String content;
final String? successMessage;
const CopyAction({required this.content, this.successMessage});
@override
String get type => 'copy';
@override
Map<String, dynamic> toJson() {
return {
'type': type,
'content': content,
if (successMessage != null) 'successMessage': successMessage,
};
}
}
class PayloadAction implements ActionSpec {
final Map<String, dynamic> payload;
final String? submitTo;
const PayloadAction({required this.payload, this.submitTo});
@override
String get type => 'payload';
@override
Map<String, dynamic> toJson() {
return {
'type': type,
'payload': payload,
if (submitTo != null) 'submitTo': submitTo,
};
}
}
ActionSpec actionSpecFromJson(Map<String, dynamic> json) {
final type = json['type'] as String? ?? '';
switch (type) {
case 'navigation':
return NavigateAction(
path: json['path'] as String? ?? '',
params: json['params'] as Map<String, dynamic>?,
);
case 'url':
return LinkAction(
url: json['url'] as String? ?? '',
target: json['target'] as String?,
);
case 'event':
return EventAction(
event: json['event'] as String? ?? '',
payload: json['payload'] as Map<String, dynamic>?,
);
case 'tool':
return ToolAction(
toolId: json['toolId'] as String? ?? '',
params: json['params'] as Map<String, dynamic>?,
);
case 'copy':
return CopyAction(
content: json['content'] as String? ?? '',
successMessage: json['successMessage'] as String?,
);
case 'payload':
return PayloadAction(
payload: json['payload'] as Map<String, dynamic>? ?? {},
submitTo: json['submitTo'] as String?,
);
default:
return EventAction(event: 'unknown');
}
}
class UiAction {
final String id;
final String label;
final UiIcon? icon;
final ActionStyle? style;
final bool disabled;
final ActionSpec action;
final ActionConfirm? confirm;
const UiAction({
required this.id,
required this.label,
this.icon,
this.style,
this.disabled = false,
required this.action,
this.confirm,
});
factory UiAction.fromJson(Map<String, dynamic> json) {
return UiAction(
id: json['id'] as String? ?? '',
label: json['label'] as String? ?? '',
icon: json['icon'] != null
? UiIcon.fromJson(json['icon'] as Map<String, dynamic>)
: null,
style: json['style'] != null
? ActionStyle.values.firstWhere(
(e) => e.value == json['style'],
orElse: () => ActionStyle.primary,
)
: null,
disabled: json['disabled'] as bool? ?? false,
action: actionSpecFromJson(
json['action'] as Map<String, dynamic>? ?? {'type': 'event'},
),
confirm: json['confirm'] != null
? ActionConfirm.fromJson(json['confirm'] as Map<String, dynamic>)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'label': label,
if (icon != null) 'icon': icon!.toJson(),
if (style != null) 'style': style!.value,
'disabled': disabled,
'action': action.toJson(),
if (confirm != null) 'confirm': confirm!.toJson(),
};
}
}
@@ -1,47 +0,0 @@
part of '../ui_schema.dart';
UiSchemaDocument buildSuccessDocument(
List<UiNode> nodes, {
String version = '1.0',
SchemaType schemaType = SchemaType.toolResult,
String? docId,
String? timestamp,
String locale = 'zh-CN',
RendererConfig? renderer,
DocumentMeta? meta,
}) {
return UiSchemaDocument(
version: version,
schemaType: schemaType,
docId: docId,
timestamp: timestamp,
locale: locale,
status: UiStatus.success,
renderer: renderer,
meta: meta,
nodes: nodes,
);
}
UiSchemaDocument buildErrorDocument(
List<UiNode> nodes, {
String version = '1.0',
SchemaType schemaType = SchemaType.toolResult,
String? docId,
String? timestamp,
String locale = 'zh-CN',
RendererConfig? renderer,
DocumentMeta? meta,
}) {
return UiSchemaDocument(
version: version,
schemaType: schemaType,
docId: docId,
timestamp: timestamp,
locale: locale,
status: UiStatus.error,
renderer: renderer,
meta: meta,
nodes: nodes,
);
}
@@ -1,273 +0,0 @@
part of '../ui_schema.dart';
class UiIcon {
final IconSource source;
final String value;
final String? color;
final int? size;
const UiIcon({
required this.source,
required this.value,
this.color,
this.size,
});
factory UiIcon.fromJson(Map<String, dynamic> json) {
return UiIcon(
source: IconSource.values.firstWhere(
(e) => e.value == json['source'],
orElse: () => IconSource.icon,
),
value: json['value'] as String? ?? '',
color: json['color'] as String?,
size: json['size'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'source': source.value,
'value': value,
if (color != null) 'color': color,
if (size != null) 'size': size,
};
}
}
class UiBadge {
final String label;
final BadgeVariant variant;
const UiBadge({required this.label, this.variant = BadgeVariant.def});
factory UiBadge.fromJson(Map<String, dynamic> json) {
return UiBadge(
label: json['label'] as String? ?? '',
variant: BadgeVariant.values.firstWhere(
(e) => e.value == json['variant'],
orElse: () => BadgeVariant.def,
),
);
}
Map<String, dynamic> toJson() {
return {'label': label, 'variant': variant.value};
}
}
class Pagination {
final int page;
final int pageSize;
final int total;
final bool hasMore;
const Pagination({
required this.page,
required this.pageSize,
required this.total,
required this.hasMore,
});
factory Pagination.fromJson(Map<String, dynamic> json) {
return Pagination(
page: json['page'] as int? ?? 1,
pageSize: json['pageSize'] as int? ?? 20,
total: json['total'] as int? ?? 0,
hasMore: json['hasMore'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'page': page,
'pageSize': pageSize,
'total': total,
'hasMore': hasMore,
};
}
}
class ActionConfirm {
final String? title;
final String? message;
final String? confirmLabel;
final String? cancelLabel;
const ActionConfirm({
this.title,
this.message,
this.confirmLabel,
this.cancelLabel,
});
factory ActionConfirm.fromJson(Map<String, dynamic> json) {
return ActionConfirm(
title: json['title'] as String?,
message: json['message'] as String?,
confirmLabel: json['confirmLabel'] as String?,
cancelLabel: json['cancelLabel'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
if (title != null) 'title': title,
if (message != null) 'message': message,
if (confirmLabel != null) 'confirmLabel': confirmLabel,
if (cancelLabel != null) 'cancelLabel': cancelLabel,
};
}
}
class KeyValuePair {
final String key;
final String? label;
final dynamic value;
final bool? copyable;
const KeyValuePair({
required this.key,
this.label,
required this.value,
this.copyable,
});
factory KeyValuePair.fromJson(Map<String, dynamic> json) {
return KeyValuePair(
key: json['key'] as String? ?? '',
label: json['label'] as String?,
value: json['value'],
copyable: json['copyable'] as bool?,
);
}
Map<String, dynamic> toJson() {
return {
'key': key,
if (label != null) 'label': label,
'value': value,
if (copyable != null) 'copyable': copyable,
};
}
}
class TableColumn {
final String key;
final String label;
final String? width;
final String? align;
const TableColumn({
required this.key,
required this.label,
this.width,
this.align,
});
factory TableColumn.fromJson(Map<String, dynamic> json) {
return TableColumn(
key: json['key'] as String? ?? '',
label: json['label'] as String? ?? '',
width: json['width'] as String?,
align: json['align'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'key': key,
'label': label,
if (width != null) 'width': width,
if (align != null) 'align': align,
};
}
}
class TableRow {
final String id;
final Map<String, dynamic> cells;
final Map<String, dynamic>? metadata;
final List<UiAction>? actions;
const TableRow({
required this.id,
required this.cells,
this.metadata,
this.actions,
});
factory TableRow.fromJson(Map<String, dynamic> json) {
return TableRow(
id: json['id'] as String? ?? '',
cells: json['cells'] as Map<String, dynamic>? ?? {},
metadata: json['metadata'] as Map<String, dynamic>?,
actions: (json['actions'] as List<dynamic>?)
?.map((e) => UiAction.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'cells': cells,
if (metadata != null) 'metadata': metadata,
if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(),
};
}
}
class ListItem {
final String id;
final String title;
final String? subtitle;
final String? description;
final UiIcon? icon;
final UiBadge? badge;
final Map<String, dynamic>? metadata;
final List<UiAction>? actions;
const ListItem({
required this.id,
required this.title,
this.subtitle,
this.description,
this.icon,
this.badge,
this.metadata,
this.actions,
});
factory ListItem.fromJson(Map<String, dynamic> json) {
return ListItem(
id: json['id'] as String? ?? '',
title: json['title'] as String? ?? '',
subtitle: json['subtitle'] as String?,
description: json['description'] as String?,
icon: json['icon'] != null
? UiIcon.fromJson(json['icon'] as Map<String, dynamic>)
: null,
badge: json['badge'] != null
? UiBadge.fromJson(json['badge'] as Map<String, dynamic>)
: null,
metadata: json['metadata'] as Map<String, dynamic>?,
actions: (json['actions'] as List<dynamic>?)
?.map((e) => UiAction.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
if (subtitle != null) 'subtitle': subtitle,
if (description != null) 'description': description,
if (icon != null) 'icon': icon!.toJson(),
if (badge != null) 'badge': badge!.toJson(),
if (metadata != null) 'metadata': metadata,
if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(),
};
}
}
@@ -1,128 +0,0 @@
part of '../ui_schema.dart';
class RendererConfig {
final String? renderer;
final RendererTheme? theme;
const RendererConfig({this.renderer, this.theme});
factory RendererConfig.fromJson(Map<String, dynamic> json) {
return RendererConfig(
renderer: json['renderer'] as String?,
theme: json['theme'] != null
? RendererTheme.values.firstWhere(
(e) => e.value == json['theme'],
orElse: () => RendererTheme.def,
)
: null,
);
}
Map<String, dynamic> toJson() {
return {
if (renderer != null) 'renderer': renderer,
if (theme != null) 'theme': theme!.value,
};
}
}
class DocumentMeta {
final String? requestId;
final String? toolId;
final String? traceId;
final String? userId;
final Map<String, dynamic>? extra;
const DocumentMeta({
this.requestId,
this.toolId,
this.traceId,
this.userId,
this.extra,
});
factory DocumentMeta.fromJson(Map<String, dynamic> json) {
return DocumentMeta(
requestId: json['requestId'] as String?,
toolId: json['toolId'] as String?,
traceId: json['traceId'] as String?,
userId: json['userId'] as String?,
extra: json,
);
}
Map<String, dynamic> toJson() {
return {
if (requestId != null) 'requestId': requestId,
if (toolId != null) 'toolId': toolId,
if (traceId != null) 'traceId': traceId,
if (userId != null) 'userId': userId,
};
}
}
class UiSchemaDocument {
final String version;
final SchemaType schemaType;
final String? docId;
final String? timestamp;
final String? locale;
final UiStatus status;
final RendererConfig? renderer;
final DocumentMeta? meta;
final List<UiNode> nodes;
const UiSchemaDocument({
required this.version,
required this.schemaType,
this.docId,
this.timestamp,
this.locale,
required this.status,
this.renderer,
this.meta,
required this.nodes,
});
factory UiSchemaDocument.fromJson(Map<String, dynamic> json) {
return UiSchemaDocument(
version: json['version'] as String? ?? '1.0',
schemaType: SchemaType.values.firstWhere(
(e) => e.value == json['schemaType'],
orElse: () => SchemaType.toolResult,
),
docId: json['docId'] as String?,
timestamp: json['timestamp'] as String?,
locale: json['locale'] as String?,
status: UiStatus.values.firstWhere(
(e) => e.value == json['status'],
orElse: () => UiStatus.info,
),
renderer: json['renderer'] != null
? RendererConfig.fromJson(json['renderer'] as Map<String, dynamic>)
: null,
meta: json['meta'] != null
? DocumentMeta.fromJson(json['meta'] as Map<String, dynamic>)
: null,
nodes:
(json['nodes'] as List<dynamic>?)
?.map((e) => UiNode.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'version': version,
'schemaType': schemaType.value,
if (docId != null) 'docId': docId,
if (timestamp != null) 'timestamp': timestamp,
if (locale != null) 'locale': locale,
'status': status.value,
if (renderer != null) 'renderer': renderer!.toJson(),
if (meta != null) 'meta': meta!.toJson(),
'nodes': nodes.map((e) => (e as dynamic).toJson()).toList(),
};
}
}
-104
View File
@@ -1,104 +0,0 @@
part of '../ui_schema.dart';
enum SchemaType {
toolResult('tool_result'),
agentResponse('agent_response'),
notification('notification');
final String value;
const SchemaType(this.value);
}
enum UiStatus {
info('info'),
success('success'),
warning('warning'),
error('error'),
pending('pending');
final String value;
const UiStatus(this.value);
}
enum IconSource {
icon('icon'),
emoji('emoji'),
url('url');
final String value;
const IconSource(this.value);
}
enum OperationType {
create('create'),
update('update'),
delete('delete'),
execute('execute');
final String value;
const OperationType(this.value);
}
enum OperationResult {
success('success'),
failure('failure'),
partial('partial');
final String value;
const OperationResult(this.value);
}
enum ContainerDirection {
vertical('vertical'),
horizontal('horizontal');
final String value;
const ContainerDirection(this.value);
}
enum TextFormat {
plain('plain'),
markdown('markdown');
final String value;
const TextFormat(this.value);
}
enum KvLayout {
vertical('vertical'),
horizontal('horizontal'),
grid('grid');
final String value;
const KvLayout(this.value);
}
enum BadgeVariant {
def('default'),
success('success'),
warning('warning'),
error('error'),
info('info');
final String value;
const BadgeVariant(this.value);
}
enum ActionStyle {
primary('primary'),
secondary('secondary'),
ghost('ghost'),
danger('danger');
final String value;
const ActionStyle(this.value);
}
enum RendererTheme {
def('default'),
dark('dark'),
light('light');
final String value;
const RendererTheme(this.value);
}
-595
View File
@@ -1,595 +0,0 @@
part of '../ui_schema.dart';
abstract class UiNode {
String get type;
String? get id;
factory UiNode.fromJson(Map<String, dynamic> json) {
final type = json['type'] as String? ?? '';
switch (type) {
case 'text':
return UiTextNode.fromJson(json);
case 'card':
return UiCardNode.fromJson(json);
case 'list':
return UiListNode.fromJson(json);
case 'table':
return UiTableNode.fromJson(json);
case 'kv':
return UiKvNode.fromJson(json);
case 'operation':
return UiOperationNode.fromJson(json);
case 'error':
return UiErrorNode.fromJson(json);
case 'container':
return UiContainerNode.fromJson(json);
default:
return UiTextNode(content: 'Unknown node type: $type');
}
}
}
class UiTextNode implements UiNode {
@override
final String? id;
@override
String get type => 'text';
final String content;
final TextFormat format;
final UiIcon? icon;
final List<UiAction>? actions;
const UiTextNode({
this.id,
required this.content,
this.format = TextFormat.plain,
this.icon,
this.actions,
});
factory UiTextNode.fromJson(Map<String, dynamic> json) {
return UiTextNode(
id: json['id'] as String?,
content: json['content'] as String? ?? '',
format: TextFormat.values.firstWhere(
(e) => e.value == json['format'],
orElse: () => TextFormat.plain,
),
icon: json['icon'] != null
? UiIcon.fromJson(json['icon'] as Map<String, dynamic>)
: null,
actions: (json['actions'] as List<dynamic>?)
?.map((e) => UiAction.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
'content': content,
'format': format.value,
if (icon != null) 'icon': icon!.toJson(),
if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(),
};
}
}
class UiCardNode implements UiNode {
@override
final String? id;
@override
String get type => 'card';
final String? title;
final String? description;
final UiIcon? icon;
final UiStatus? status;
final String? timestamp;
final List<UiNode>? children;
final UiTextNode? footer;
final Map<String, dynamic>? extensions;
final List<UiAction>? actions;
const UiCardNode({
this.id,
this.title,
this.description,
this.icon,
this.status,
this.timestamp,
this.children,
this.footer,
this.extensions,
this.actions,
});
factory UiCardNode.fromJson(Map<String, dynamic> json) {
return UiCardNode(
id: json['id'] as String?,
title: json['title'] as String?,
description: json['description'] as String?,
icon: json['icon'] != null
? UiIcon.fromJson(json['icon'] as Map<String, dynamic>)
: null,
status: json['status'] != null
? UiStatus.values.firstWhere(
(e) => e.value == json['status'],
orElse: () => UiStatus.info,
)
: null,
timestamp: json['timestamp'] as String?,
children: (json['children'] as List<dynamic>?)
?.map((e) => UiNode.fromJson(e as Map<String, dynamic>))
.toList(),
footer: json['footer'] != null
? UiTextNode.fromJson(json['footer'] as Map<String, dynamic>)
: null,
extensions: json['extensions'] as Map<String, dynamic>?,
actions: (json['actions'] as List<dynamic>?)
?.map((e) => UiAction.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (icon != null) 'icon': icon!.toJson(),
if (status != null) 'status': status!.value,
if (timestamp != null) 'timestamp': timestamp,
if (children != null)
'children': children!.map((e) => (e as dynamic).toJson()).toList(),
if (footer != null) 'footer': footer!.toJson(),
if (extensions != null) 'extensions': extensions,
if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(),
};
}
}
class UiListNode implements UiNode {
@override
final String? id;
@override
String get type => 'list';
final String? title;
final String? description;
final UiIcon? icon;
final UiStatus? status;
final List<ListItem> items;
final Pagination? pagination;
final String? emptyText;
final Map<String, dynamic>? extensions;
final List<UiAction>? actions;
const UiListNode({
this.id,
this.title,
this.description,
this.icon,
this.status,
required this.items,
this.pagination,
this.emptyText,
this.extensions,
this.actions,
});
factory UiListNode.fromJson(Map<String, dynamic> json) {
return UiListNode(
id: json['id'] as String?,
title: json['title'] as String?,
description: json['description'] as String?,
icon: json['icon'] != null
? UiIcon.fromJson(json['icon'] as Map<String, dynamic>)
: null,
status: json['status'] != null
? UiStatus.values.firstWhere(
(e) => e.value == json['status'],
orElse: () => UiStatus.info,
)
: null,
items:
(json['items'] as List<dynamic>?)
?.map((e) => ListItem.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
pagination: json['pagination'] != null
? Pagination.fromJson(json['pagination'] as Map<String, dynamic>)
: null,
emptyText: json['emptyText'] as String?,
extensions: json['extensions'] as Map<String, dynamic>?,
actions: (json['actions'] as List<dynamic>?)
?.map((e) => UiAction.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (icon != null) 'icon': icon!.toJson(),
if (status != null) 'status': status!.value,
'items': items.map((e) => e.toJson()).toList(),
if (pagination != null) 'pagination': pagination!.toJson(),
if (emptyText != null) 'emptyText': emptyText,
if (extensions != null) 'extensions': extensions,
if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(),
};
}
}
class UiTableNode implements UiNode {
@override
final String? id;
@override
String get type => 'table';
final String? title;
final String? description;
final UiIcon? icon;
final UiStatus? status;
final List<TableColumn> columns;
final List<TableRow> rows;
final Pagination? pagination;
final Map<String, dynamic>? extensions;
final List<UiAction>? actions;
const UiTableNode({
this.id,
this.title,
this.description,
this.icon,
this.status,
required this.columns,
required this.rows,
this.pagination,
this.extensions,
this.actions,
});
factory UiTableNode.fromJson(Map<String, dynamic> json) {
return UiTableNode(
id: json['id'] as String?,
title: json['title'] as String?,
description: json['description'] as String?,
icon: json['icon'] != null
? UiIcon.fromJson(json['icon'] as Map<String, dynamic>)
: null,
status: json['status'] != null
? UiStatus.values.firstWhere(
(e) => e.value == json['status'],
orElse: () => UiStatus.info,
)
: null,
columns:
(json['columns'] as List<dynamic>?)
?.map((e) => TableColumn.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
rows:
(json['rows'] as List<dynamic>?)
?.map((e) => TableRow.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
pagination: json['pagination'] != null
? Pagination.fromJson(json['pagination'] as Map<String, dynamic>)
: null,
extensions: json['extensions'] as Map<String, dynamic>?,
actions: (json['actions'] as List<dynamic>?)
?.map((e) => UiAction.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (icon != null) 'icon': icon!.toJson(),
if (status != null) 'status': status!.value,
'columns': columns.map((e) => e.toJson()).toList(),
'rows': rows.map((e) => e.toJson()).toList(),
if (pagination != null) 'pagination': pagination!.toJson(),
if (extensions != null) 'extensions': extensions,
if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(),
};
}
}
class UiKvNode implements UiNode {
@override
final String? id;
@override
String get type => 'kv';
final String? title;
final String? description;
final UiIcon? icon;
final UiStatus? status;
final List<KeyValuePair> pairs;
final KvLayout layout;
final Map<String, dynamic>? extensions;
final List<UiAction>? actions;
const UiKvNode({
this.id,
this.title,
this.description,
this.icon,
this.status,
required this.pairs,
this.layout = KvLayout.vertical,
this.extensions,
this.actions,
});
factory UiKvNode.fromJson(Map<String, dynamic> json) {
return UiKvNode(
id: json['id'] as String?,
title: json['title'] as String?,
description: json['description'] as String?,
icon: json['icon'] != null
? UiIcon.fromJson(json['icon'] as Map<String, dynamic>)
: null,
status: json['status'] != null
? UiStatus.values.firstWhere(
(e) => e.value == json['status'],
orElse: () => UiStatus.info,
)
: null,
pairs:
(json['pairs'] as List<dynamic>?)
?.map((e) => KeyValuePair.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
layout: KvLayout.values.firstWhere(
(e) => e.value == json['layout'],
orElse: () => KvLayout.vertical,
),
extensions: json['extensions'] as Map<String, dynamic>?,
actions: (json['actions'] as List<dynamic>?)
?.map((e) => UiAction.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (icon != null) 'icon': icon!.toJson(),
if (status != null) 'status': status!.value,
'pairs': pairs.map((e) => e.toJson()).toList(),
'layout': layout.value,
if (extensions != null) 'extensions': extensions,
if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(),
};
}
}
class UiOperationNode implements UiNode {
@override
final String? id;
@override
String get type => 'operation';
final String? title;
final String? description;
final UiIcon? icon;
final UiStatus? status;
final OperationType operation;
final OperationResult result;
final String? message;
final int? affectedCount;
final UiNode? details;
final UiAction? rollback;
final Map<String, dynamic>? extensions;
final List<UiAction>? actions;
const UiOperationNode({
this.id,
this.title,
this.description,
this.icon,
this.status,
required this.operation,
required this.result,
this.message,
this.affectedCount,
this.details,
this.rollback,
this.extensions,
this.actions,
});
factory UiOperationNode.fromJson(Map<String, dynamic> json) {
return UiOperationNode(
id: json['id'] as String?,
title: json['title'] as String?,
description: json['description'] as String?,
icon: json['icon'] != null
? UiIcon.fromJson(json['icon'] as Map<String, dynamic>)
: null,
status: json['status'] != null
? UiStatus.values.firstWhere(
(e) => e.value == json['status'],
orElse: () => UiStatus.info,
)
: null,
operation: OperationType.values.firstWhere(
(e) => e.value == json['operation'],
orElse: () => OperationType.execute,
),
result: OperationResult.values.firstWhere(
(e) => e.value == json['result'],
orElse: () => OperationResult.failure,
),
message: json['message'] as String?,
affectedCount: json['affectedCount'] as int?,
details: json['details'] != null
? UiNode.fromJson(json['details'] as Map<String, dynamic>)
: null,
rollback: json['rollback'] != null
? UiAction.fromJson(json['rollback'] as Map<String, dynamic>)
: null,
extensions: json['extensions'] as Map<String, dynamic>?,
actions: (json['actions'] as List<dynamic>?)
?.map((e) => UiAction.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (icon != null) 'icon': icon!.toJson(),
if (status != null) 'status': status!.value,
'operation': operation.value,
'result': result.value,
if (message != null) 'message': message,
if (affectedCount != null) 'affectedCount': affectedCount,
if (details != null) 'details': (details as dynamic).toJson(),
if (rollback != null) 'rollback': rollback!.toJson(),
if (extensions != null) 'extensions': extensions,
if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(),
};
}
}
class UiErrorNode implements UiNode {
@override
final String? id;
@override
String get type => 'error';
final String? title;
final UiIcon? icon;
final String errorCode;
final String message;
final String? details;
final String? stack;
final bool retryable;
final List<String>? suggestions;
final UiAction? retry;
final UiAction? support;
final List<UiAction>? actions;
const UiErrorNode({
this.id,
this.title,
this.icon,
required this.errorCode,
required this.message,
this.details,
this.stack,
this.retryable = false,
this.suggestions,
this.retry,
this.support,
this.actions,
});
factory UiErrorNode.fromJson(Map<String, dynamic> json) {
return UiErrorNode(
id: json['id'] as String?,
title: json['title'] as String?,
icon: json['icon'] != null
? UiIcon.fromJson(json['icon'] as Map<String, dynamic>)
: null,
errorCode: json['errorCode'] as String? ?? 'UNKNOWN',
message: json['message'] as String? ?? 'An error occurred',
details: json['details'] as String?,
stack: json['stack'] as String?,
retryable: json['retryable'] as bool? ?? false,
suggestions: (json['suggestions'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
retry: json['retry'] != null
? UiAction.fromJson(json['retry'] as Map<String, dynamic>)
: null,
support: json['support'] != null
? UiAction.fromJson(json['support'] as Map<String, dynamic>)
: null,
actions: (json['actions'] as List<dynamic>?)
?.map((e) => UiAction.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
if (title != null) 'title': title,
if (icon != null) 'icon': icon!.toJson(),
'errorCode': errorCode,
'message': message,
if (details != null) 'details': details,
if (stack != null) 'stack': stack,
'retryable': retryable,
if (suggestions != null) 'suggestions': suggestions,
if (retry != null) 'retry': retry!.toJson(),
if (support != null) 'support': support!.toJson(),
if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(),
};
}
}
class UiContainerNode implements UiNode {
@override
final String? id;
@override
String get type => 'container';
final ContainerDirection direction;
final int? gap;
final List<UiNode> children;
final List<UiAction>? actions;
const UiContainerNode({
this.id,
this.direction = ContainerDirection.vertical,
this.gap,
required this.children,
this.actions,
});
factory UiContainerNode.fromJson(Map<String, dynamic> json) {
return UiContainerNode(
id: json['id'] as String?,
direction: ContainerDirection.values.firstWhere(
(e) => e.value == json['direction'],
orElse: () => ContainerDirection.vertical,
),
gap: json['gap'] as int?,
children:
(json['children'] as List<dynamic>?)
?.map((e) => UiNode.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
actions: (json['actions'] as List<dynamic>?)
?.map((e) => UiAction.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
'direction': direction.value,
if (gap != null) 'gap': gap,
'children': children.map((e) => (e as dynamic).toJson()).toList(),
if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(),
};
}
}
@@ -1,38 +0,0 @@
import '../../features/auth/presentation/bloc/auth_state.dart';
import '../../features/calendar/data/services/calendar_service.dart';
import '../notifications/local_notification_service.dart';
class AuthSessionBootstrapper {
AuthSessionBootstrapper({
required CalendarService calendarService,
required LocalNotificationService notificationService,
}) : _calendarService = calendarService,
_notificationService = notificationService;
final CalendarService _calendarService;
final LocalNotificationService _notificationService;
String? _syncedUserId;
Future<void> syncForAuthState(AuthState state) async {
if (state is! AuthAuthenticated) {
_syncedUserId = null;
return;
}
if (_syncedUserId == state.user.id) {
return;
}
try {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 90));
final end = now.add(const Duration(days: 90));
final events = await _calendarService.getEventsForRange(start, end);
await _notificationService.rebuildUpcomingReminders(events);
_syncedUserId = state.user.id;
} catch (_) {
// ignore reminder bootstrap failures
}
}
}
@@ -0,0 +1,68 @@
String formatPhoneForDisplay(String? rawPhone) {
final normalized = _normalizePhone(rawPhone);
if (normalized == null) {
return rawPhone?.trim() ?? '';
}
if (normalized.startsWith('+86') && normalized.length == 14) {
final local = normalized.substring(3);
return '${local.substring(0, 3)}****${local.substring(7)}';
}
if (!normalized.startsWith('+')) {
return normalized;
}
final digits = normalized.substring(1);
final countryCode = _detectCountryCode(digits);
if (countryCode == null) {
return normalized;
}
final localNumber = digits.substring(countryCode.length);
if (localNumber.length <= 4) {
return '+$countryCode $localNumber';
}
final tail = localNumber.substring(localNumber.length - 4);
return '+$countryCode ****$tail';
}
String? _normalizePhone(String? rawPhone) {
if (rawPhone == null) {
return null;
}
var phone = rawPhone.trim();
for (final separator in const [' ', '-', '(', ')']) {
phone = phone.replaceAll(separator, '');
}
if (phone.isEmpty) {
return null;
}
if (phone.startsWith('00') && phone.length > 2) {
phone = '+${phone.substring(2)}';
}
if (!phone.startsWith('+') && RegExp(r'^\d+$').hasMatch(phone)) {
phone = '+$phone';
}
return phone;
}
String? _detectCountryCode(String digits) {
const knownCodes = ['86', '1', '44', '81', '65', '33'];
for (final code in knownCodes) {
if (digits.startsWith(code) && digits.length > code.length + 3) {
return code;
}
}
for (int length = 3; length >= 1; length--) {
if (length >= digits.length) {
continue;
}
final candidate = digits.substring(0, length);
if (candidate.startsWith('0')) {
continue;
}
if (digits.length - length >= 4) {
return candidate;
}
}
return null;
}
@@ -0,0 +1,43 @@
import '../l10n/l10n.dart';
const Map<String, String> _toolNameAliases = {
'calendar_read': 'calendar.read',
'calendar_write': 'calendar.write',
'calendar_share': 'calendar.share',
'user_lookup': 'user.lookup',
'memory_write': 'memory.write',
'memory_forget': 'memory.forget',
};
const List<String> automationToolOptions = [
'calendar.read',
'calendar.write',
'calendar.share',
'user.lookup',
'memory.write',
'memory.forget',
];
String localizeToolName(String rawName) {
final normalized = rawName.trim().toLowerCase();
if (normalized.isEmpty) {
return rawName;
}
final canonical = _toolNameAliases[normalized] ?? normalized;
switch (canonical) {
case 'calendar.read':
return L10n.current.toolCalendarRead;
case 'calendar.write':
return L10n.current.toolCalendarWrite;
case 'calendar.share':
return L10n.current.toolCalendarShare;
case 'user.lookup':
return L10n.current.toolUserLookup;
case 'memory.write':
return L10n.current.toolMemoryWrite;
case 'memory.forget':
return L10n.current.toolMemoryForget;
default:
return rawName;
}
}
+45
View File
@@ -0,0 +1,45 @@
import '../l10n/l10n.dart';
class Validators {
Validators._();
static String? phone(String? value) {
if (value == null || value.isEmpty) {
return L10n.current.validatorPhoneRequired;
}
final phoneRegex = RegExp(r'^\+861[3-9]\d{9}$');
if (!phoneRegex.hasMatch(value)) {
return L10n.current.validatorPhoneInvalid86;
}
return null;
}
static String? password(String? value) {
if (value == null || value.isEmpty) {
return L10n.current.validatorPasswordRequired;
}
if (value.length < 8) {
return L10n.current.validatorPasswordMin8;
}
return null;
}
static String? required(String? value, [String? fieldName]) {
if (value == null || value.isEmpty) {
return L10n.current.validatorRequired(
fieldName ?? L10n.current.commonUnknown,
);
}
return null;
}
static String? nickname(String? value) {
if (value == null || value.isEmpty) {
return L10n.current.validatorNicknameRequired;
}
if (value.length < 2) {
return L10n.current.validatorNicknameMin2;
}
return null;
}
}