From b34697660d1e24d5e269cb879e8675631963e952 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 18 Mar 2026 13:35:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Auth=20=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E7=8A=B6=E6=80=81=E6=9C=BA=E4=B8=8E=20401=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=A4=84=E7=90=86=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AuthSessionInvalidated 事件处理 token 失效场景 - ApiInterceptor 新增 authFailureCallback 单飞机制 - AuthBloc 区分 manual logout 与 auto expiry 语义 - 新增 startup recovery fallback 防止启动卡死 feat: 重构 Calendar DayWeek 视图事件布局引擎 - 新增 DayEventLayoutEngine 解耦事件计算与渲染 - 新增 DayTimelineMetrics 统一时间轴常量 - 新增 DayViewScale 支持捏合缩放 feat: 新增 Settings 页面共享 UI 组件 - 新增 BackTitlePageHeader 统一页面 header - 新增 DetailHeaderActionMenu 统一操作菜单 - 新增 DestructiveActionSheet 统一删除确认 - 新增 AppToggleSwitch 统一开关组件 feat: Chat UI Schema 支持导航操作 - 支持 navigation 类型 action 触发内部路由跳转 - 新增路径验证与参数处理 chore: 更新相关测试覆盖 auth 失效路径 --- apps/AGENTS.md | 34 ++ apps/lib/core/api/api_client.dart | 16 +- apps/lib/core/api/api_interceptor.dart | 32 ++ apps/lib/core/config/env.dart | 8 - apps/lib/core/di/injection.dart | 28 +- .../features/auth/data/auth_repository.dart | 1 + .../auth/data/auth_repository_impl.dart | 11 +- .../auth/presentation/bloc/auth_bloc.dart | 49 ++- .../auth/presentation/bloc/auth_event.dart | 11 + .../auth/presentation/bloc/auth_state.dart | 13 +- .../ui/dayweek/day_event_layout_engine.dart | 184 ++++++++ .../ui/dayweek/day_timeline_metrics.dart | 29 ++ .../calendar/ui/dayweek/day_view_scale.dart | 38 ++ .../ui/screens/calendar_dayweek_screen.dart | 415 ++++++++++-------- .../screens/calendar_event_detail_screen.dart | 216 +++++---- .../ui/screens/calendar_month_screen.dart | 2 +- .../ui/widgets/create_event_sheet.dart | 35 +- .../chat/ui/widgets/ui_schema_renderer.dart | 87 +++- .../ui/screens/add_contact_screen.dart | 9 +- .../contacts/ui/screens/contacts_screen.dart | 5 +- .../settings/ui/screens/account_screen.dart | 6 +- .../ui/screens/change_password_screen.dart | 5 +- .../ui/screens/edit_profile_screen.dart | 5 +- .../settings/ui/screens/features_screen.dart | 81 +--- .../settings/ui/screens/memory_screen.dart | 90 ++-- .../settings/ui/screens/settings_screen.dart | 52 +-- .../ui/widgets/account_surface_scaffold.dart | 140 ------ .../ui/widgets/settings_page_scaffold.dart | 63 +++ .../todo/ui/screens/todo_detail_screen.dart | 98 +++-- .../ui/screens/todo_quadrants_screen.dart | 116 +++-- .../lib/shared/widgets/app_toggle_switch.dart | 58 +++ .../widgets/back_title_page_header.dart | 65 +++ .../widgets/destructive_action_sheet.dart | 89 ++++ .../widgets/detail_header_action_menu.dart | 219 +++++++++ apps/test/core/api/api_interceptor_test.dart | 35 +- .../auth/data/auth_repository_test.dart | 12 + .../presentation/bloc/auth_bloc_test.dart | 61 ++- .../calendar/ui/create_event_sheet_test.dart | 29 ++ .../dayweek/day_event_layout_engine_test.dart | 104 +++++ .../ui/dayweek/day_view_scale_test.dart | 31 ++ .../chat/ui_schema_renderer_test.dart | 89 ++++ .../versions/20260226_0001_initial_schema.py | 2 +- ..._0006_invite_codes_and_profile_referral.py | 6 +- ...35419f8121c_simplify_agent_architecture.py | 56 ++- .../50ae013ce530_add_user_agent_catalog.py | 4 +- .../core/agentscope/prompts/route_prompt.py | 76 ++++ .../core/agentscope/prompts/system_prompt.py | 9 +- .../config/static/route/frontend_routes.yaml | 118 +++++ backend/src/schemas/agent/ui_hints.py | 51 ++- .../unit/core/agentscope/test_route_prompt.py | 25 ++ .../core/agentscope/test_system_prompt.py | 2 + .../schemas/agent/test_ui_hints_navigation.py | 71 +++ .../2026-03-18-auth-global-rewrite-design.md | 102 +++++ .../2026-03-18-auth-global-rewrite-plan.md | 158 +++++++ docs/protocols/ui/data-flow.md | 26 ++ docs/protocols/ui/ui-schema.md | 9 + 56 files changed, 2602 insertions(+), 784 deletions(-) create mode 100644 apps/lib/features/calendar/ui/dayweek/day_event_layout_engine.dart create mode 100644 apps/lib/features/calendar/ui/dayweek/day_timeline_metrics.dart create mode 100644 apps/lib/features/calendar/ui/dayweek/day_view_scale.dart delete mode 100644 apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart create mode 100644 apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart create mode 100644 apps/lib/shared/widgets/app_toggle_switch.dart create mode 100644 apps/lib/shared/widgets/back_title_page_header.dart create mode 100644 apps/lib/shared/widgets/destructive_action_sheet.dart create mode 100644 apps/lib/shared/widgets/detail_header_action_menu.dart create mode 100644 apps/test/features/calendar/ui/create_event_sheet_test.dart create mode 100644 apps/test/features/calendar/ui/dayweek/day_event_layout_engine_test.dart create mode 100644 apps/test/features/calendar/ui/dayweek/day_view_scale_test.dart create mode 100644 backend/src/core/agentscope/prompts/route_prompt.py create mode 100644 backend/src/core/config/static/route/frontend_routes.yaml create mode 100644 backend/tests/unit/core/agentscope/test_route_prompt.py create mode 100644 backend/tests/unit/schemas/agent/test_ui_hints_navigation.py create mode 100644 docs/plans/2026-03-18-auth-global-rewrite-design.md create mode 100644 docs/plans/2026-03-18-auth-global-rewrite-plan.md diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 258640a..9517e99 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -28,6 +28,19 @@ This document defines **hard constraints** for Flutter mobile development. Treat - **MUST NOT** introduce parallel UI systems (e.g., custom button styles, custom loading indicators) that duplicate existing shared components. - When creating new UI components, ensure they follow the design tokens and visual design language. +## 2.1) Navigation/Header Reuse Rules (MUST) + +- For page groups with clear parent-child relationships (e.g., Settings and its subpages), **MUST** use one shared header pattern: back button + page title. +- **MUST** extract shared page scaffolds/header wrappers instead of duplicating `SafeArea + header + scroll` structures across sibling pages. +- Detail-page right-side actions (edit/delete/share etc.) **MUST** use a shared action-menu component, not per-page ad-hoc button groups. +- Header action menus **MUST NOT** overlap the trigger button area; menu surfaces should open below/right-aligned to the trigger and preserve title readability. + +## 2.2) Interaction Surface Reuse Rules (MUST) + +- Repeated state-switch controls (toggle/switch UI) **MUST** be extracted into shared widgets. +- Destructive confirmations (delete/remove) **MUST** use shared project-style confirmation surfaces (e.g., unified action sheet), not platform-default dialog styles. +- **MUST NOT** use raw platform-default popup/dialog/dropdown visuals when they break project visual language; use token-driven shared components instead. + ## 3) Layout Mapping & Alignment (MUST) - **MUST** explicitly set `crossAxisAlignment` for every `Row` / `Column` (do not rely on defaults). @@ -118,3 +131,24 @@ Before finalizing any UI, mentally verify: - Does the screen feel calm and premium? - Is the assistant identity visually present? - Would this look plausible in a polished shipping app? + +## 9) Auth Global Module Rules (MUST) + +Auth is a global module. All auth/session behavior MUST follow a single state machine. + +- **MUST** treat `AuthBloc` as the single source of truth for authentication state. +- **MUST NOT** implement ad-hoc auth state in feature modules (no parallel flags, no local auth caches). +- **MUST** route all 401 refresh-failure handling through the global callback chain: + `ApiInterceptor -> ApiClient auth failure callback -> AuthBloc(AuthSessionInvalidated)`. +- **MUST NOT** clear tokens directly inside feature/page code. +- **MUST NOT** navigate to login directly from feature code on token errors; rely on router redirect driven by global auth state. +- **MUST** distinguish logout semantics: + - manual logout: revoke server session + clear local session + - auto expiry/logout on refresh failure: clear local session only +- **MUST** ensure startup session recovery has exception fallback and never leaves app stuck in boot/loading state. +- **MUST** add/maintain tests for: + - startup recovery fallback + - concurrent 401 refresh failure singleflight + - session invalidation -> unauthenticated redirect path + +If a new auth-related requirement cannot fit this model, update this section first, then implement code. diff --git a/apps/lib/core/api/api_client.dart b/apps/lib/core/api/api_client.dart index 0b48820..29ddca4 100644 --- a/apps/lib/core/api/api_client.dart +++ b/apps/lib/core/api/api_client.dart @@ -15,7 +15,16 @@ class ApiClient implements IApiClient { required TokenStorage tokenStorage, Dio? dio, }) { - final effectiveDio = dio ?? Dio(BaseOptions(baseUrl: baseUrl)); + final effectiveDio = + dio ?? + Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 20), + sendTimeout: const Duration(seconds: 20), + ), + ); final interceptor = ApiInterceptor( tokenStorage: tokenStorage, dio: effectiveDio, @@ -50,6 +59,11 @@ class ApiClient implements IApiClient { }; } + void setAuthFailureCallback(Future Function() onAuthFailure) { + _interceptor.onAuthFailure = onAuthFailure; + } + + @override Future> get(String path, {Options? options}) async { try { return await _dio.get(path, options: options); diff --git a/apps/lib/core/api/api_interceptor.dart b/apps/lib/core/api/api_interceptor.dart index 707ef91..78b093b 100644 --- a/apps/lib/core/api/api_interceptor.dart +++ b/apps/lib/core/api/api_interceptor.dart @@ -6,7 +6,9 @@ class ApiInterceptor extends Interceptor { final Dio dio; final Duration refreshFailureCooldown; Future Function()? onTokenRefresh; + Future Function()? onAuthFailure; Future? _refreshFuture; + Future? _authFailureFuture; DateTime? _refreshBlockedUntil; static const _retriedRequestKey = '_auth_retry_once'; @@ -34,6 +36,10 @@ class ApiInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) async { final requestOptions = err.requestOptions; + final isUnauthorized = err.response?.statusCode == 401; + final shouldHandleUnauthorized = + isUnauthorized && _isAuthenticatedRequest(requestOptions); + if (err.response?.statusCode == 401 && onTokenRefresh != null && !_shouldSkipRefresh(requestOptions)) { @@ -57,11 +63,36 @@ class ApiInterceptor extends Interceptor { // Retry failed, proceed with original error. } } + } else if (shouldHandleUnauthorized) { + await _notifyAuthFailureSingleflight(); } + } else if (shouldHandleUnauthorized && _shouldSkipRefresh(requestOptions)) { + await _notifyAuthFailureSingleflight(); } handler.next(err); } + bool _isAuthenticatedRequest(RequestOptions options) { + return options.headers['Authorization'] != null; + } + + Future _notifyAuthFailureSingleflight() { + final existing = _authFailureFuture; + if (existing != null) { + return existing; + } + final callback = onAuthFailure; + if (callback == null) { + return Future.value(); + } + + final future = callback().whenComplete(() { + _authFailureFuture = null; + }); + _authFailureFuture = future; + return future; + } + bool _shouldSkipRefresh(RequestOptions options) { final blockedUntil = _refreshBlockedUntil; if (blockedUntil != null && DateTime.now().isBefore(blockedUntil)) { @@ -101,6 +132,7 @@ class ApiInterceptor extends Interceptor { void reset() { _refreshFuture = null; + _authFailureFuture = null; _refreshBlockedUntil = null; } } diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart index d9e4ae9..ee2a734 100644 --- a/apps/lib/core/config/env.dart +++ b/apps/lib/core/config/env.dart @@ -11,12 +11,4 @@ class Env { } return 'http://localhost:5775'; } - - static bool get isMockApi { - final fromDefine = const String.fromEnvironment('MOCK_API'); - if (fromDefine.isNotEmpty) { - return fromDefine == 'true'; - } - return false; - } } diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 21113ce..4d4199f 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -10,6 +10,7 @@ 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_service.dart'; import '../../features/calendar/ui/calendar_state_manager.dart'; @@ -26,12 +27,18 @@ Future configureDependencies() async { await sl.reset(); } - final IApiClient apiClient; final SecureTokenStorage tokenStorage; final dio = Dio(BaseOptions(baseUrl: Env.apiUrl)); - tokenStorage = SecureTokenStorage(const FlutterSecureStorage()); - apiClient = ApiClient( + 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, @@ -69,12 +76,15 @@ Future configureDependencies() async { api: authApi, tokenStorage: tokenStorage, onLogout: () async { - (apiClient as ApiClient).resetInterceptor(); + apiClient.resetInterceptor(); }, ); sl.registerSingleton(authRepository); - (apiClient as ApiClient).setRefreshCallback((token) async { + final authBloc = AuthBloc(authRepository); + sl.registerSingleton(authBloc); + + apiClient.setRefreshCallback((token) async { try { await authRepository.refreshSession(token); return true; @@ -83,6 +93,12 @@ Future configureDependencies() async { } }); - sl.registerSingleton(AuthBloc(authRepository)); + apiClient.setAuthFailureCallback(() async { + authBloc.add( + const AuthSessionInvalidated( + source: AuthInvalidationSource.unauthorized401, + ), + ); + }); sl.registerSingleton(CalendarStateManager()); } diff --git a/apps/lib/features/auth/data/auth_repository.dart b/apps/lib/features/auth/data/auth_repository.dart index f4cc2e9..4d0ccbc 100644 --- a/apps/lib/features/auth/data/auth_repository.dart +++ b/apps/lib/features/auth/data/auth_repository.dart @@ -11,6 +11,7 @@ abstract class AuthRepository { Future createSession(LoginRequest request); Future refreshSession(String refreshToken); Future deleteSession(); + Future clearSessionLocalOnly(); Future getAccessToken(); Future getRefreshToken(); Future isAuthenticated(); diff --git a/apps/lib/features/auth/data/auth_repository_impl.dart b/apps/lib/features/auth/data/auth_repository_impl.dart index 35721fd..f1c19e1 100644 --- a/apps/lib/features/auth/data/auth_repository_impl.dart +++ b/apps/lib/features/auth/data/auth_repository_impl.dart @@ -64,9 +64,6 @@ class AuthRepositoryImpl implements AuthRepository { @override Future deleteSession() async { - if (_onLogout != null) { - await _onLogout!(); - } final refreshToken = await _tokenStorage.getRefreshToken(); if (refreshToken != null) { try { @@ -75,6 +72,14 @@ class AuthRepositoryImpl implements AuthRepository { // ignore API errors during logout } } + await clearSessionLocalOnly(); + } + + @override + Future clearSessionLocalOnly() async { + if (_onLogout != null) { + await _onLogout(); + } await _tokenStorage.clear(); } diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart index ec89dd9..eb5391b 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -10,13 +10,14 @@ class AuthBloc extends Bloc { on(_onStarted); on(_onLoggedIn); on(_onLoggedOut); + on(_onSessionInvalidated); } Future _onStarted(AuthStarted event, Emitter emit) async { emit(AuthLoading()); - final refreshToken = await _repository.getRefreshToken(); - if (refreshToken != null) { - try { + try { + final refreshToken = await _repository.getRefreshToken(); + if (refreshToken != null) { final response = await _repository.refreshSession(refreshToken); emit( AuthAuthenticated( @@ -24,11 +25,23 @@ class AuthBloc extends Bloc { ), ); return; + } + emit( + const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut), + ); + } catch (_) { + try { + await _repository.clearSessionLocalOnly(); } catch (_) { - await _repository.deleteSession(); + // Keep state convergence even when storage cleanup fails. + } finally { + emit( + const AuthUnauthenticated( + reason: AuthUnauthenticatedReason.startupRecoveryFailed, + ), + ); } } - emit(AuthUnauthenticated()); } void _onLoggedIn(AuthLoggedIn event, Emitter emit) { @@ -39,7 +52,29 @@ class AuthBloc extends Bloc { AuthLoggedOut event, Emitter emit, ) async { - await _repository.deleteSession(); - emit(AuthUnauthenticated()); + try { + await _repository.deleteSession(); + } catch (_) { + // Keep state convergence even when logout cleanup fails. + } finally { + emit( + const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut), + ); + } + } + + Future _onSessionInvalidated( + AuthSessionInvalidated event, + Emitter emit, + ) async { + try { + await _repository.clearSessionLocalOnly(); + } catch (_) { + // Keep state convergence even when local cleanup fails. + } finally { + emit( + const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired), + ); + } } } diff --git a/apps/lib/features/auth/presentation/bloc/auth_event.dart b/apps/lib/features/auth/presentation/bloc/auth_event.dart index 3b773df..408628f 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_event.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_event.dart @@ -1,6 +1,8 @@ import 'package:equatable/equatable.dart'; import '../../data/models/auth_response.dart'; +enum AuthInvalidationSource { unauthorized401 } + abstract class AuthEvent extends Equatable { const AuthEvent(); @@ -20,3 +22,12 @@ class AuthLoggedIn extends AuthEvent { } class AuthLoggedOut extends AuthEvent {} + +class AuthSessionInvalidated extends AuthEvent { + final AuthInvalidationSource source; + + const AuthSessionInvalidated({required this.source}); + + @override + List get props => [source]; +} diff --git a/apps/lib/features/auth/presentation/bloc/auth_state.dart b/apps/lib/features/auth/presentation/bloc/auth_state.dart index 040d2e3..680dc4e 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_state.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_state.dart @@ -14,6 +14,8 @@ class AuthInitial extends AuthState {} class AuthLoading extends AuthState {} +enum AuthUnauthenticatedReason { signedOut, expired, startupRecoveryFailed } + class AuthAuthenticated extends AuthState { final AuthUser user; @@ -23,4 +25,13 @@ class AuthAuthenticated extends AuthState { List get props => [user]; } -class AuthUnauthenticated extends AuthState {} +class AuthUnauthenticated extends AuthState { + final AuthUnauthenticatedReason reason; + + const AuthUnauthenticated({ + this.reason = AuthUnauthenticatedReason.signedOut, + }); + + @override + List get props => [reason]; +} diff --git a/apps/lib/features/calendar/ui/dayweek/day_event_layout_engine.dart b/apps/lib/features/calendar/ui/dayweek/day_event_layout_engine.dart new file mode 100644 index 0000000..8b06bf2 --- /dev/null +++ b/apps/lib/features/calendar/ui/dayweek/day_event_layout_engine.dart @@ -0,0 +1,184 @@ +import '../../data/models/schedule_item_model.dart'; +import 'day_timeline_metrics.dart'; +import 'day_view_scale.dart'; + +class DayEventLayout { + final ScheduleItemModel event; + final int startMinutes; + final int endMinutes; + final int column; + final int columnCount; + final double top; + final double geometryHeight; + final double visualHeight; + final double left; + final double width; + + const DayEventLayout({ + required this.event, + required this.startMinutes, + required this.endMinutes, + required this.column, + required this.columnCount, + required this.top, + required this.geometryHeight, + required this.visualHeight, + required this.left, + required this.width, + }); +} + +class DayEventLayoutEngine { + const DayEventLayoutEngine(); + + List layout({ + required List events, + required DayViewScale scale, + required double eventAreaLeft, + required double eventAreaWidth, + double columnGap = DayTimelineMetrics.eventColumnGap, + }) { + if (events.isEmpty || eventAreaWidth <= 0) { + return const []; + } + + final sorted = + events + .map(_EventSpan.fromEvent) + .where((span) => span.endMinutes > span.startMinutes) + .toList() + ..sort((a, b) { + final byStart = a.startMinutes.compareTo(b.startMinutes); + if (byStart != 0) { + return byStart; + } + final byEnd = a.endMinutes.compareTo(b.endMinutes); + if (byEnd != 0) { + return byEnd; + } + return a.event.id.compareTo(b.event.id); + }); + + if (sorted.isEmpty) { + return const []; + } + + final active = <_PlacedSpan>[]; + final clusters = >[]; + List<_PlacedSpan> currentCluster = []; + var clusterEnd = sorted.first.endMinutes; + + for (final span in sorted) { + active.removeWhere((item) => item.endMinutes <= span.startMinutes); + + if (currentCluster.isNotEmpty && span.startMinutes >= clusterEnd) { + clusters.add(currentCluster); + currentCluster = []; + clusterEnd = span.endMinutes; + } else if (span.endMinutes > clusterEnd) { + clusterEnd = span.endMinutes; + } + + final usedColumns = active.map((item) => item.column).toSet(); + var column = 0; + while (usedColumns.contains(column)) { + column++; + } + + final placed = _PlacedSpan( + event: span.event, + startMinutes: span.startMinutes, + endMinutes: span.endMinutes, + column: column, + ); + active.add(placed); + currentCluster.add(placed); + } + + if (currentCluster.isNotEmpty) { + clusters.add(currentCluster); + } + + final layouts = []; + for (final cluster in clusters) { + final clusterColumnCount = + cluster.map((item) => item.column).reduce((a, b) => a > b ? a : b) + + 1; + final totalGap = (clusterColumnCount - 1) * columnGap; + final columnWidth = clusterColumnCount > 0 + ? ((eventAreaWidth - totalGap) / clusterColumnCount).toDouble() + : eventAreaWidth; + + for (final item in cluster) { + final top = scale.pixelsForMinutes(item.startMinutes); + final geometryHeight = scale.pixelsForMinutes( + item.endMinutes - item.startMinutes, + ); + final visualHeight = geometryHeight < 1 ? 1.0 : geometryHeight; + final left = eventAreaLeft + item.column * (columnWidth + columnGap); + + layouts.add( + DayEventLayout( + event: item.event, + startMinutes: item.startMinutes, + endMinutes: item.endMinutes, + column: item.column, + columnCount: clusterColumnCount, + top: top, + geometryHeight: geometryHeight, + visualHeight: visualHeight, + left: left, + width: columnWidth, + ), + ); + } + } + + return layouts; + } +} + +class _EventSpan { + final ScheduleItemModel event; + final int startMinutes; + final int endMinutes; + + const _EventSpan({ + required this.event, + required this.startMinutes, + required this.endMinutes, + }); + + factory _EventSpan.fromEvent(ScheduleItemModel event) { + final start = _minutesOfDay(event.startAt); + final end = event.endAt != null ? _minutesOfDay(event.endAt!) : start + 60; + final clampedStart = DayTimelineMetrics.clampMinuteOfDay(start); + var clampedEnd = DayTimelineMetrics.clampMinuteOfDay(end); + if (clampedEnd <= clampedStart) { + clampedEnd = DayTimelineMetrics.clampMinuteOfDay(clampedStart + 1); + } + return _EventSpan( + event: event, + startMinutes: clampedStart, + endMinutes: clampedEnd, + ); + } +} + +class _PlacedSpan { + final ScheduleItemModel event; + final int startMinutes; + final int endMinutes; + final int column; + + const _PlacedSpan({ + required this.event, + required this.startMinutes, + required this.endMinutes, + required this.column, + }); +} + +int _minutesOfDay(DateTime dateTime) { + return dateTime.hour * 60 + dateTime.minute; +} diff --git a/apps/lib/features/calendar/ui/dayweek/day_timeline_metrics.dart b/apps/lib/features/calendar/ui/dayweek/day_timeline_metrics.dart new file mode 100644 index 0000000..4f1aeea --- /dev/null +++ b/apps/lib/features/calendar/ui/dayweek/day_timeline_metrics.dart @@ -0,0 +1,29 @@ +import 'day_view_scale.dart'; + +class DayTimelineMetrics { + static const int hoursInDay = 24; + static const int minutesInHour = 60; + static const int minutesInDay = hoursInDay * minutesInHour; + + static const double timeLabelWidth = 44; + static const double timeLabelGap = 8; + static const double eventRightInset = 4; + static const double eventColumnGap = 4; + + static double timelineHeight(DayViewScale scale) { + return scale.pixelsForMinutes(minutesInDay); + } + + static double eventAreaLeft() { + return timeLabelWidth + timeLabelGap; + } + + static double eventAreaWidth(double boardWidth) { + final width = boardWidth - eventAreaLeft() - eventRightInset; + return width > 0 ? width : 0; + } + + static int clampMinuteOfDay(int minute) { + return minute.clamp(0, minutesInDay).toInt(); + } +} diff --git a/apps/lib/features/calendar/ui/dayweek/day_view_scale.dart b/apps/lib/features/calendar/ui/dayweek/day_view_scale.dart new file mode 100644 index 0000000..b8007a3 --- /dev/null +++ b/apps/lib/features/calendar/ui/dayweek/day_view_scale.dart @@ -0,0 +1,38 @@ +class DayViewScale { + static const double defaultHourHeight = 34.0; + static const double minHourHeight = 17.0; + static const double maxHourHeight = 68.0; + + final double hourHeight; + + const DayViewScale({required this.hourHeight}); + + factory DayViewScale.defaultScale() { + return const DayViewScale(hourHeight: defaultHourHeight); + } + + DayViewScale copyWith({double? hourHeight}) { + return DayViewScale( + hourHeight: _clampHourHeight(hourHeight ?? this.hourHeight), + ); + } + + DayViewScale zoomByFactor(double factor) { + if (factor <= 0) { + return this; + } + return copyWith(hourHeight: hourHeight * factor); + } + + double pixelsForMinutes(int minutes) { + return (minutes / 60) * hourHeight; + } + + double minutesForPixels(double pixels) { + return (pixels / hourHeight) * 60; + } + + static double _clampHourHeight(double value) { + return value.clamp(minHourHeight, maxHourHeight); + } +} diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index 3466e23..229d8bb 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -1,15 +1,19 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.dart'; + import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_pressable.dart'; +import '../../data/models/schedule_item_model.dart'; +import '../../data/services/calendar_service.dart'; import '../calendar_state_manager.dart'; import '../calendar_time_utils.dart'; +import '../dayweek/day_event_layout_engine.dart'; +import '../dayweek/day_timeline_metrics.dart'; +import '../dayweek/day_view_scale.dart'; import '../widgets/bottom_dock.dart'; import '../widgets/create_event_sheet.dart'; -import '../../data/services/calendar_service.dart'; -import '../../data/models/schedule_item_model.dart'; class CalendarDayWeekScreen extends StatefulWidget { final DateTime? initialDate; @@ -29,20 +33,20 @@ class _CalendarDayWeekScreenState extends State with WidgetsBindingObserver { static const double _dayItemWidth = 44; static const double _dayItemGap = 12; - static const double _eventLeftOffset = 52; - static const double _defaultHourHeight = 34.0; - static const double _minHourHeight = 17.0; - static const double _maxHourHeight = 68.0; + static const double _minEventTapHeight = 32; + static const List _dayNames = ['日', '一', '二', '三', '四', '五', '六']; - double _hourHeight = _defaultHourHeight; + final DayEventLayoutEngine _layoutEngine = const DayEventLayoutEngine(); final Map _activePointers = {}; + final ScrollController _dayStripController = ScrollController(); + + DayViewScale _scale = DayViewScale.defaultScale(); + DayViewScale _pinchStartScale = DayViewScale.defaultScale(); double? _pinchStartDistance; - double _pinchStartHourHeight = _defaultHourHeight; late final CalendarStateManager _calendarManager; late DateTime _selectedDate; late List _monthDates; - final ScrollController _dayStripController = ScrollController(); List _events = const []; @override @@ -55,7 +59,7 @@ class _CalendarDayWeekScreenState extends State _calendarManager.resetToToday(); } - _selectedDate = _calendarManager.selectedDate; + _selectedDate = widget.initialDate ?? _calendarManager.selectedDate; _updateMonthDates(); _loadEvents(); @@ -159,7 +163,7 @@ class _CalendarDayWeekScreenState extends State final today = DateTime.now(); setState(() { _selectedDate = today; - _hourHeight = _defaultHourHeight; + _scale = DayViewScale.defaultScale(); }); _calendarManager.setSelectedDate(today); _updateMonthDates(); @@ -172,7 +176,7 @@ class _CalendarDayWeekScreenState extends State if (_activePointers.length == 2) { final pointers = _activePointers.values.toList(growable: false); _pinchStartDistance = (pointers[0] - pointers[1]).distance; - _pinchStartHourHeight = _hourHeight; + _pinchStartScale = _scale; } } @@ -192,28 +196,27 @@ class _CalendarDayWeekScreenState extends State return; } - final nextHeight = - (_pinchStartHourHeight * (currentDistance / startDistance)).clamp( - _minHourHeight, - _maxHourHeight, - ); - if ((nextHeight - _hourHeight).abs() < 0.1) { + final nextScale = _pinchStartScale.zoomByFactor( + currentDistance / startDistance, + ); + if ((nextScale.hourHeight - _scale.hourHeight).abs() < 0.1) { return; } setState(() { - _hourHeight = nextHeight; + _scale = nextScale; }); } void _handlePointerUp(PointerUpEvent event) { - _activePointers.remove(event.pointer); - if (_activePointers.length < 2) { - _pinchStartDistance = null; - } + _handlePointerRemove(event.pointer); } void _handlePointerCancel(PointerCancelEvent event) { - _activePointers.remove(event.pointer); + _handlePointerRemove(event.pointer); + } + + void _handlePointerRemove(int pointer) { + _activePointers.remove(pointer); if (_activePointers.length < 2) { _pinchStartDistance = null; } @@ -221,6 +224,7 @@ class _CalendarDayWeekScreenState extends State Widget _buildHeader() { final monthLabel = '${_selectedDate.year}年${_selectedDate.month}月'; + final isNotToday = !isSameDay(_selectedDate, DateTime.now()); return SizedBox( height: 68, @@ -281,7 +285,7 @@ class _CalendarDayWeekScreenState extends State ), ), const Spacer(), - if (!isSameDay(_selectedDate, DateTime.now())) + if (isNotToday) AppPressable( borderRadius: BorderRadius.circular(AppRadius.xl), onTap: _goToToday, @@ -305,8 +309,7 @@ class _CalendarDayWeekScreenState extends State ), ), ), - if (!isSameDay(_selectedDate, DateTime.now())) - const SizedBox(width: 8), + if (isNotToday) const SizedBox(width: 8), AppPressable( borderRadius: BorderRadius.circular(AppRadius.full), onTap: () => CreateEventSheet.show( @@ -413,14 +416,12 @@ class _CalendarDayWeekScreenState extends State } Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) { - final dayNames = ['日', '一', '二', '三', '四', '五', '六']; - return Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text( - dayNames[date.weekday % 7], + _dayNames[date.weekday % 7], style: TextStyle( fontSize: 11, color: isWeekend ? AppColors.slate400 : AppColors.slate600, @@ -456,110 +457,203 @@ class _CalendarDayWeekScreenState extends State Widget _buildTimelineBoard() { final now = DateTime.now(); final showCurrent = shouldShowCurrentMarker(_selectedDate, now); - final events = _events; - final eventColumns = _calculateEventColumns(events); + return LayoutBuilder( + builder: (context, constraints) { + final boardWidth = constraints.maxWidth; + final boardHeight = DayTimelineMetrics.timelineHeight(_scale); + final eventAreaLeft = DayTimelineMetrics.eventAreaLeft(); + final eventAreaWidth = DayTimelineMetrics.eventAreaWidth(boardWidth); - return SizedBox( - child: Stack( - clipBehavior: Clip.none, - children: [ - Column( + final layouts = _layoutEngine.layout( + events: _events, + scale: _scale, + eventAreaLeft: eventAreaLeft, + eventAreaWidth: eventAreaWidth, + ); + + return SizedBox( + height: boardHeight, + child: Stack( children: [ - for (var hour = 0; hour <= 23; hour++) ...[ - _buildTimelineRow(formatHour(hour)), - if (showCurrent && now.hour == hour) - _buildTimelineRow(formatHm(now), isCurrentTime: true), - ], - _buildTimelineRow(formatHour(24), isDisabled: true), + RepaintBoundary( + child: _buildTimelineGrid( + boardHeight: boardHeight, + eventAreaLeft: eventAreaLeft, + ), + ), + if (showCurrent) + _buildCurrentTimeMarker(now: now, boardHeight: boardHeight), + RepaintBoundary( + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final layout in layouts) + _buildEventCard(layout: layout, boardHeight: boardHeight), + ], + ), + ), ], ), - ..._buildPositionedEvents(events, eventColumns), + ); + }, + ); + } + + Widget _buildTimelineGrid({ + required double boardHeight, + required double eventAreaLeft, + }) { + return SizedBox( + height: boardHeight, + child: Stack( + children: [ + for (var hour = 0; hour <= DayTimelineMetrics.hoursInDay; hour++) + _buildHourTick( + hour: hour, + boardHeight: boardHeight, + eventAreaLeft: eventAreaLeft, + ), ], ), ); } - List _calculateEventColumns(List events) { - if (events.isEmpty) return []; + Widget _buildHourTick({ + required int hour, + required double boardHeight, + required double eventAreaLeft, + }) { + final minute = hour * DayTimelineMetrics.minutesInHour; + final y = _scale.pixelsForMinutes(minute); + final isDisabled = hour == DayTimelineMetrics.hoursInDay; + final labelTop = (y - 7).clamp(0.0, boardHeight - 14); - final columns = List.filled(events.length, -1); - final columnHeights = {}; - - for (var i = 0; i < events.length; i++) { - final event = events[i]; - final eventStart = event.startAt.hour * 60 + event.startAt.minute; - final eventEnd = event.endAt != null - ? event.endAt!.hour * 60 + event.endAt!.minute - : eventStart + 60; - - var column = 0; - while (true) { - final columnEnd = columnHeights[column] ?? 0; - if (columnEnd <= eventStart) { - columns[i] = column; - columnHeights[column] = eventEnd; - break; - } - column++; - } - } - - return columns; + return Stack( + children: [ + Positioned( + top: y, + left: eventAreaLeft, + right: 0, + child: Container( + height: 1, + color: isDisabled ? AppColors.blue50 : AppColors.border, + ), + ), + Positioned( + top: labelTop, + left: 0, + width: DayTimelineMetrics.timeLabelWidth, + child: Text( + formatHour(hour), + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: isDisabled ? AppColors.slate300 : AppColors.slate400, + ), + ), + ), + ], + ); } - List _buildPositionedEvents( - List events, - List columns, - ) { - if (events.isEmpty) return []; + Widget _buildCurrentTimeMarker({ + required DateTime now, + required double boardHeight, + }) { + final minute = now.hour * DayTimelineMetrics.minutesInHour + now.minute; + final top = _scale.pixelsForMinutes(minute).clamp(0.0, boardHeight); - final maxColumn = columns.reduce((a, b) => a > b ? a : b) + 1; - final eventWidgets = []; + return Positioned( + top: top - 9, + left: 0, + right: 0, + child: IgnorePointer( + child: SizedBox( + height: 18, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: DayTimelineMetrics.timeLabelWidth, + height: 18, + decoration: BoxDecoration( + color: AppColors.red500, + borderRadius: BorderRadius.circular(9), + ), + child: Center( + child: Text( + formatHm(now), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: DayTimelineMetrics.timeLabelGap), + Expanded( + child: Container( + height: 2, + decoration: BoxDecoration( + color: AppColors.red500, + borderRadius: BorderRadius.circular(99), + ), + ), + ), + ], + ), + ), + ), + ); + } - for (var i = 0; i < events.length; i++) { - final event = events[i]; - final column = columns[i]; - final eventColor = _parseColor(event.metadata?.color); + Widget _buildEventCard({ + required DayEventLayout layout, + required double boardHeight, + }) { + final eventColor = _parseColor(layout.event.metadata?.color); + final isCompact = layout.visualHeight < 20; + final tapHeight = layout.visualHeight < _minEventTapHeight + ? _minEventTapHeight + : layout.visualHeight; + final top = (layout.top - ((tapHeight - layout.visualHeight) / 2)).clamp( + 0.0, + boardHeight - tapHeight, + ); + final visualTop = layout.top - top; - final startMinutes = event.startAt.hour * 60 + event.startAt.minute; - final endMinutes = event.endAt != null - ? event.endAt!.hour * 60 + event.endAt!.minute - : startMinutes + 60; - final durationMinutes = endMinutes - startMinutes; - - final top = (startMinutes / 60) * _hourHeight; - final height = (durationMinutes / 60) * _hourHeight; - - final eventWidth = maxColumn > 1 - ? (MediaQuery.of(context).size.width - _eventLeftOffset - 16) / - maxColumn - : MediaQuery.of(context).size.width - _eventLeftOffset - 16; - final left = _eventLeftOffset + column * eventWidth; - - eventWidgets.add( - Positioned( - top: top, - left: left, - right: maxColumn > 1 ? null : 16, - width: maxColumn > 1 ? eventWidth - 4 : null, - height: height.clamp(24.0, double.infinity), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - context.push('/calendar/events/${event.id}'); - }, + return Positioned( + top: top, + left: layout.left, + width: layout.width, + height: tapHeight, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => context.push('/calendar/events/${layout.event.id}'), + child: Stack( + children: [ + Positioned( + top: visualTop, + left: 0, + right: 0, + height: layout.visualHeight, child: Container( - margin: const EdgeInsets.only(right: 4), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + margin: const EdgeInsets.only( + right: DayTimelineMetrics.eventColumnGap, + ), + padding: isCompact + ? const EdgeInsets.symmetric(horizontal: 4, vertical: 2) + : const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: eventColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), border: Border.all(color: eventColor, width: 1), ), child: Row( - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 6, @@ -569,33 +663,34 @@ class _CalendarDayWeekScreenState extends State shape: BoxShape.circle, ), ), - const SizedBox(width: 4), - Expanded( - child: Text( - event.title, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: eventColor, + if (!isCompact) const SizedBox(width: 4), + if (!isCompact) + Expanded( + child: Text( + layout.event.title, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: eventColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ), ], ), ), ), - ), + ], ), - ); - } - - return eventWidgets; + ), + ); } Color _parseColor(String? hex) { - if (hex == null || hex.isEmpty) return AppColors.blue600; + if (hex == null || hex.isEmpty) { + return AppColors.blue600; + } try { return Color(int.parse(hex.replaceFirst('#', '0xFF'))); } catch (_) { @@ -603,74 +698,12 @@ class _CalendarDayWeekScreenState extends State } } - Widget _buildTimelineRow( - String time, { - bool isCurrentTime = false, - bool isDisabled = false, - }) { - return SizedBox( - height: _hourHeight, - child: Row( - children: [ - SizedBox( - width: 44, - child: isCurrentTime - ? Container( - width: 44, - height: 18, - decoration: BoxDecoration( - color: AppColors.red500, - borderRadius: BorderRadius.circular(9), - ), - child: Center( - child: Text( - time, - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ), - ) - : Text( - time, - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: isDisabled - ? AppColors.slate300 - : AppColors.slate400, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: isCurrentTime - ? Container( - height: 2, - decoration: BoxDecoration( - color: AppColors.red500, - borderRadius: BorderRadius.circular(99), - ), - ) - : Container( - height: 1, - color: isDisabled ? AppColors.blue50 : AppColors.border, - ), - ), - ], - ), - ); - } - Widget _buildBottomDock() { return BottomDock( activeTab: DockTab.calendar, onTodoTap: () { _calendarManager.setViewType(CalendarViewType.day); - context.push('/todo'); + context.go('/todo'); }, onCalendarTap: () { _calendarManager.setViewType(CalendarViewType.day); diff --git a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart index 334756a..7b3d548 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart @@ -5,12 +5,16 @@ import '../../../../core/di/injection.dart'; import '../../../../core/notifications/local_notification_service.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; -import '../../../../shared/widgets/page_header.dart' as widgets; +import '../../../../shared/widgets/back_title_page_header.dart'; +import '../../../../shared/widgets/detail_header_action_menu.dart'; +import '../../../../shared/widgets/destructive_action_sheet.dart'; import '../../data/services/calendar_service.dart'; import '../../data/models/schedule_item_model.dart'; import '../widgets/create_event_sheet.dart'; import '../widgets/calendar_share_dialog.dart'; +enum _CalendarHeaderAction { edit, delete, share } + class CalendarEventDetailScreen extends StatefulWidget { final String eventId; @@ -95,8 +99,9 @@ class _CalendarEventDetailScreenState extends State { backgroundColor: const Color(0xFFF8FAFC), body: SafeArea( child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildHeader(context), + _buildHeader(event), Expanded(child: _buildDetailOverlay(event)), _buildInputContainer(), ], @@ -105,20 +110,82 @@ class _CalendarEventDetailScreenState extends State { ); } - Widget _buildHeader(BuildContext context) { - return SizedBox( - height: 64, - child: Padding( - padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 8), - child: Row( - children: [ - widgets.BackButton(onPressed: () => Navigator.of(context).pop()), - ], - ), - ), + Widget _buildHeader(ScheduleItemModel event) { + return BackTitlePageHeader( + title: '日程详情', + onBack: () => context.pop(), + trailing: _buildHeaderActions(event), ); } + Widget _buildHeaderActions(ScheduleItemModel event) { + final items = >[]; + if (event.canEdit) { + items.add( + const DetailHeaderActionItem<_CalendarHeaderAction>( + value: _CalendarHeaderAction.edit, + label: '编辑', + icon: LucideIcons.pencil, + ), + ); + } + if (event.canDelete) { + items.add( + const DetailHeaderActionItem<_CalendarHeaderAction>( + value: _CalendarHeaderAction.delete, + label: '删除', + icon: LucideIcons.trash2, + isDestructive: true, + ), + ); + } + if (event.canInvite) { + items.add( + const DetailHeaderActionItem<_CalendarHeaderAction>( + value: _CalendarHeaderAction.share, + label: '分享', + icon: LucideIcons.share2, + ), + ); + } + + return DetailHeaderActionMenu<_CalendarHeaderAction>( + items: items, + onSelected: (action) => _handleHeaderAction(action, event), + ); + } + + void _handleHeaderAction( + _CalendarHeaderAction action, + ScheduleItemModel event, + ) { + switch (action) { + case _CalendarHeaderAction.edit: + CreateEventSheet.edit( + context, + event, + onSaved: () { + setState(() { + _loadEvent(); + }); + }, + ); + return; + case _CalendarHeaderAction.delete: + _showDeleteConfirmation(); + return; + case _CalendarHeaderAction.share: + CalendarShareDialog.show( + context, + event.id, + event.title, + canInvite: event.canInvite, + canEdit: event.canEdit, + ); + return; + } + } + Widget _buildDetailOverlay(ScheduleItemModel event) { final startAt = event.startAt; final endAt = event.endAt; @@ -198,10 +265,11 @@ class _CalendarEventDetailScreenState extends State { Widget _buildTitleRow(ScheduleItemModel event) { return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 4, @@ -226,114 +294,28 @@ class _CalendarEventDetailScreenState extends State { ], ), ), - Row( - children: [ - if (event.canEdit) - _buildHeaderActionButton( - onTap: () => CreateEventSheet.edit( - context, - event, - onSaved: () { - setState(() { - _loadEvent(); - }); - }, - ), - icon: LucideIcons.pencil, - iconColor: AppColors.slate600, - backgroundColor: AppColors.surfaceTertiary, - borderColor: AppColors.borderTertiary, - ), - if (event.canEdit) const SizedBox(width: 8), - if (event.canDelete) - _buildHeaderActionButton( - onTap: _showDeleteConfirmation, - icon: LucideIcons.trash2, - iconColor: AppColors.red500, - backgroundColor: AppColors.warningBackground, - borderColor: AppColors.messageRejectBorder, - ), - if (event.canInvite) ...[ - const SizedBox(width: 8), - _buildHeaderActionButton( - onTap: () => CalendarShareDialog.show( - context, - event.id, - event.title, - canInvite: event.canInvite, - canEdit: event.canEdit, - ), - icon: LucideIcons.share2, - iconColor: AppColors.slate600, - backgroundColor: AppColors.blue50, - borderColor: AppColors.blue100, - ), - ], - ], - ), ], ); } - Widget _buildHeaderActionButton({ - required VoidCallback onTap, - required IconData icon, - required Color iconColor, - required Color backgroundColor, - required Color borderColor, - }) { - return SizedBox( - width: AppSpacing.xxl * 2, - height: AppSpacing.xxl * 2, - child: TextButton( - onPressed: onTap, - style: TextButton.styleFrom( - padding: const EdgeInsets.all(AppSpacing.none), - backgroundColor: backgroundColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.md), - side: BorderSide(color: borderColor), - ), - ), - child: Icon( - icon, - size: AppSpacing.lg + AppSpacing.xs, - color: iconColor, - ), - ), - ); - } - - void _showDeleteConfirmation() { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('删除日程'), - content: const Text('确定要删除这个日程吗?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消'), - ), - TextButton( - onPressed: () async { - await sl().deleteEvent(widget.eventId); - try { - await sl().cancelEventReminder( - widget.eventId, - ); - } catch (_) {} - if (!context.mounted) { - return; - } - Navigator.pop(context); - context.pop(); - }, - child: Text('删除', style: TextStyle(color: AppColors.red500)), - ), - ], - ), + Future _showDeleteConfirmation() async { + final confirmed = await showDestructiveActionSheet( + context, + title: '删除日程', + message: '确定要删除这个日程吗?', + confirmText: '确认删除', ); + if (!confirmed) { + return; + } + await sl().deleteEvent(widget.eventId); + try { + await sl().cancelEventReminder(widget.eventId); + } catch (_) {} + if (!mounted) { + return; + } + context.pop(); } Widget _buildDetailField(String label, String value) { diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index 5de4cbc..3568143 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -522,7 +522,7 @@ class _CalendarMonthScreenState extends State activeTab: DockTab.calendar, onTodoTap: () { _calendarManager.setViewType(CalendarViewType.month); - context.push('/todo'); + context.go('/todo'); }, onCalendarTap: () {}, onHomeTap: () => context.go('/home'), diff --git a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart index 686d0e2..fbb5760 100644 --- a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart @@ -57,6 +57,17 @@ class CreateEventSheet extends StatefulWidget { class _CreateEventSheetState extends State with SingleTickerProviderStateMixin { + static const List _defaultReminderOptions = [ + null, + 0, + 5, + 10, + 15, + 30, + 60, + 120, + ]; + late TabController _tabController; final _titleController = TextEditingController(); final _descriptionController = TextEditingController(); @@ -89,7 +100,9 @@ class _CreateEventSheetState extends State _endDate = event.endAt; _endTime = event.endAt; _selectedColor = event.metadata?.color ?? '#3B82F6'; - _reminderMinutes = event.metadata?.reminderMinutes ?? 15; + _reminderMinutes = _sanitizeReminderMinutes( + event.metadata?.reminderMinutes, + ); } else { final now = widget.initialDate ?? _roundToNearestMinute(DateTime.now(), 5); @@ -512,7 +525,7 @@ class _CreateEventSheetState extends State } Widget _buildReminderPicker() { - const options = [null, 0, 5, 10, 15, 30, 60, 120]; + final options = _buildReminderOptions(); String labelOf(int? value) { if (value == null) { return '无提醒'; @@ -573,6 +586,24 @@ class _CreateEventSheetState extends State ); } + int? _sanitizeReminderMinutes(int? minutes) { + if (minutes == null || minutes < 0) { + return null; + } + return minutes; + } + + List _buildReminderOptions() { + final current = _sanitizeReminderMinutes(_reminderMinutes); + final nonNull = _defaultReminderOptions.whereType().toSet(); + if (current != null) { + nonNull.add(current); + } + + final sorted = nonNull.toList()..sort(); + return [null, ...sorted]; + } + Future _saveEvent() async { if (_titleController.text.trim().isEmpty || _saving) return; setState(() { diff --git a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart index d955682..0821cc9 100644 --- a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart +++ b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:social_app/core/theme/design_tokens.dart'; import 'package:social_app/shared/widgets/toast/toast.dart'; @@ -168,12 +169,7 @@ class UiSchemaRenderer { onPressed: disabled ? null : () { - final actionType = _asString(action?['type']); - if (actionType == 'copy') { - Toast.show(context, '已复制', type: ToastType.success); - } else { - Toast.show(context, '该操作暂未接入', type: ToastType.info); - } + _handleAction(context, action); }, style: ElevatedButton.styleFrom( elevation: 0, @@ -203,6 +199,85 @@ class UiSchemaRenderer { ); } + static void _handleAction( + BuildContext context, + Map? action, + ) { + final actionType = _asString(action?['type']); + switch (actionType) { + case 'copy': + Toast.show(context, '已复制', type: ToastType.success); + return; + case 'navigation': + _handleNavigationAction(context, action); + return; + default: + Toast.show(context, '该操作暂未接入', type: ToastType.info); + return; + } + } + + static void _handleNavigationAction( + BuildContext context, + Map? action, + ) { + if (action == null) { + Toast.show(context, '导航参数无效', type: ToastType.warning); + return; + } + + final path = _asString(action['path']).trim(); + if (!_isValidInternalPath(path)) { + Toast.show(context, '导航路径无效', type: ToastType.warning); + return; + } + + final params = _asMap(action['params']); + final queryParams = _extractNavigationQueryParams(params); + try { + final baseUri = Uri.parse(path); + final mergedQueryParams = {...baseUri.queryParameters, ...queryParams}; + final targetUri = baseUri.replace( + queryParameters: mergedQueryParams.isEmpty ? null : mergedQueryParams, + ); + context.go(targetUri.toString()); + } on FormatException { + Toast.show(context, '导航路径无效', type: ToastType.warning); + } + } + + static bool _isValidInternalPath(String path) { + if (path.isEmpty || !path.startsWith('/')) { + return false; + } + if (path.startsWith('//') || path.contains('://')) { + return false; + } + if (path.contains('?') || path.contains('#') || path.contains(':')) { + return false; + } + return true; + } + + static Map _extractNavigationQueryParams( + Map? params, + ) { + if (params == null || params.isEmpty) { + return const {}; + } + final query = {}; + params.forEach((key, value) { + if (value is String && value.isNotEmpty) { + query[key] = value; + return; + } + if (value is num || value is bool) { + query[key] = value.toString(); + } + }); + return query; + } + static Widget _renderKv(Map node) { final items = _asList( node['items'], diff --git a/apps/lib/features/contacts/ui/screens/add_contact_screen.dart b/apps/lib/features/contacts/ui/screens/add_contact_screen.dart index 5798f70..0bc81f8 100644 --- a/apps/lib/features/contacts/ui/screens/add_contact_screen.dart +++ b/apps/lib/features/contacts/ui/screens/add_contact_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/page_header.dart' as widgets; +import '../../../../shared/widgets/back_title_page_header.dart'; import '../../../../shared/widgets/app_input.dart'; import '../../../../shared/widgets/link_button.dart'; import '../../../../shared/widgets/toast/toast.dart'; @@ -37,15 +37,18 @@ class _AddContactScreenState extends State { backgroundColor: AppColors.surfaceSecondary, body: SafeArea( child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - widgets.PageHeader( - leading: widgets.BackButton(), + BackTitlePageHeader( + title: isEditing ? '编辑联系人' : '添加联系人', + onBack: () => context.pop(), trailing: _buildConfirmButton(), ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildAvatarSection(), const SizedBox(height: 14), diff --git a/apps/lib/features/contacts/ui/screens/contacts_screen.dart b/apps/lib/features/contacts/ui/screens/contacts_screen.dart index cd5a5ca..f9816ce 100644 --- a/apps/lib/features/contacts/ui/screens/contacts_screen.dart +++ b/apps/lib/features/contacts/ui/screens/contacts_screen.dart @@ -5,7 +5,7 @@ import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/toast/index.dart'; import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/page_header.dart' as widgets; +import '../../../../shared/widgets/back_title_page_header.dart'; import '../../../friends/data/friends_api.dart'; import '../../../users/data/models/user_response.dart'; import '../../../users/data/users_api.dart'; @@ -267,8 +267,9 @@ class _ContactsScreenState extends State { backgroundColor: AppColors.surfaceSecondary, body: SafeArea( child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - widgets.PageHeader(leading: widgets.BackButton()), + BackTitlePageHeader(title: '联系人', onBack: () => context.pop()), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), diff --git a/apps/lib/features/settings/ui/screens/account_screen.dart b/apps/lib/features/settings/ui/screens/account_screen.dart index a0d5967..7e7739d 100644 --- a/apps/lib/features/settings/ui/screens/account_screen.dart +++ b/apps/lib/features/settings/ui/screens/account_screen.dart @@ -10,7 +10,7 @@ import '../../../auth/presentation/bloc/auth_event.dart'; import '../../../auth/presentation/bloc/auth_state.dart'; import '../../../../shared/widgets/app_button.dart'; import '../widgets/account_section_card.dart'; -import '../widgets/account_surface_scaffold.dart'; +import '../widgets/settings_page_scaffold.dart'; class AccountScreen extends StatelessWidget { const AccountScreen({super.key}); @@ -22,10 +22,8 @@ class AccountScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return AccountSurfaceScaffold( + return SettingsPageScaffold( title: '账户', - subtitle: null, - compactHeaderTitle: true, onBack: () => context.pop(), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/apps/lib/features/settings/ui/screens/change_password_screen.dart b/apps/lib/features/settings/ui/screens/change_password_screen.dart index 16590b7..762f3bf 100644 --- a/apps/lib/features/settings/ui/screens/change_password_screen.dart +++ b/apps/lib/features/settings/ui/screens/change_password_screen.dart @@ -13,7 +13,7 @@ import '../../../auth/presentation/bloc/auth_state.dart'; import '../../../../features/auth/presentation/cubits/reset_password_cubit.dart'; import '../../../../features/auth/data/auth_repository.dart'; import '../widgets/account_section_card.dart'; -import '../widgets/account_surface_scaffold.dart'; +import '../widgets/settings_page_scaffold.dart'; class ChangePasswordScreen extends StatelessWidget { const ChangePasswordScreen({super.key}); @@ -95,9 +95,8 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> { Toast.show(context, state.errorMessage!, type: ToastType.error); } }, - child: AccountSurfaceScaffold( + child: SettingsPageScaffold( title: '修改密码', - subtitle: '通过邮箱验证码修改密码', onBack: () => context.pop(), body: _buildForm(), footer: BlocBuilder( diff --git a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart index a8fabf5..d37cc9b 100644 --- a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart +++ b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart @@ -9,7 +9,7 @@ import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../users/data/models/user_response.dart'; import '../../../users/data/users_api.dart'; import '../widgets/account_section_card.dart'; -import '../widgets/account_surface_scaffold.dart'; +import '../widgets/settings_page_scaffold.dart'; class EditProfileScreen extends StatefulWidget { const EditProfileScreen({super.key}); @@ -119,9 +119,8 @@ class _EditProfileScreenState extends State { @override Widget build(BuildContext context) { - return AccountSurfaceScaffold( + return SettingsPageScaffold( title: '编辑资料', - subtitle: '编辑账户资料', onBack: () => context.pop(), body: _isLoading ? const Center( diff --git a/apps/lib/features/settings/ui/screens/features_screen.dart b/apps/lib/features/settings/ui/screens/features_screen.dart index 967b9b4..c265d2d 100644 --- a/apps/lib/features/settings/ui/screens/features_screen.dart +++ b/apps/lib/features/settings/ui/screens/features_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/page_header.dart' as widgets; +import '../../../../shared/widgets/app_toggle_switch.dart'; +import '../widgets/settings_page_scaffold.dart'; class FeaturesScreen extends StatefulWidget { const FeaturesScreen({super.key}); @@ -17,31 +19,20 @@ class _FeaturesScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surfaceSecondary, - body: SafeArea( - child: Column( - children: [ - const widgets.PageHeader(leading: widgets.BackButton()), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionTitle('每日'), - const SizedBox(height: 8), - _buildDailyList(), - const SizedBox(height: 16), - _buildSectionTitle('每周'), - const SizedBox(height: 8), - _buildWeeklyList(), - ], - ), - ), - ), - ], - ), + return SettingsPageScaffold( + title: '周期计划', + onBack: () => context.pop(), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle('每日'), + const SizedBox(height: 8), + _buildDailyList(), + const SizedBox(height: 16), + _buildSectionTitle('每周'), + const SizedBox(height: 8), + _buildWeeklyList(), + ], ), ); } @@ -59,6 +50,7 @@ class _FeaturesScreenState extends State { Widget _buildDailyList() { return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildFeatureCard( icon: Icons.alarm, @@ -87,6 +79,7 @@ class _FeaturesScreenState extends State { Widget _buildWeeklyList() { return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildFeatureCard( icon: Icons.calendar_view_week, @@ -128,9 +121,10 @@ class _FeaturesScreenState extends State { decoration: BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFE4ECF6)), + border: Border.all(color: AppColors.borderSecondary), ), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 40, @@ -167,40 +161,9 @@ class _FeaturesScreenState extends State { ], ), ), - _buildToggle(value, onChanged), + AppToggleSwitch(value: value, onChanged: onChanged), ], ), ); } - - Widget _buildToggle(bool value, ValueChanged onChanged) { - return GestureDetector( - onTap: () => onChanged(!value), - child: Container( - width: 44, - height: 24, - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: value ? const Color(0xFFBFDBFE) : const Color(0xFFF1F5FC), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: value ? const Color(0xFF93C5FD) : const Color(0xFFD5DFEE), - ), - ), - child: AnimatedAlign( - duration: const Duration(milliseconds: 150), - alignment: value ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFFCCDDF8)), - ), - ), - ), - ), - ); - } } diff --git a/apps/lib/features/settings/ui/screens/memory_screen.dart b/apps/lib/features/settings/ui/screens/memory_screen.dart index de1846e..dd1375f 100644 --- a/apps/lib/features/settings/ui/screens/memory_screen.dart +++ b/apps/lib/features/settings/ui/screens/memory_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/page_header.dart' as widgets; +import '../../../../shared/widgets/app_toggle_switch.dart'; import '../../data/services/memory_service.dart'; +import '../widgets/settings_page_scaffold.dart'; class MemoryScreen extends StatefulWidget { const MemoryScreen({super.key}); @@ -23,33 +25,22 @@ class _MemoryScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surfaceSecondary, - body: SafeArea( - child: Column( - children: [ - widgets.PageHeader(leading: widgets.BackButton(), height: 56), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildToggleCard(), - if (_memoryItems.isNotEmpty) ...[ - const SizedBox(height: 14), - _buildListTitle(), - const SizedBox(height: 8), - _buildMemoryList(), - ], - const SizedBox(height: 20), - _buildManageButton(), - ], - ), - ), - ), + return SettingsPageScaffold( + title: '我的记忆', + onBack: () => context.pop(), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildToggleCard(), + if (_memoryItems.isNotEmpty) ...[ + const SizedBox(height: 14), + _buildListTitle(), + const SizedBox(height: 8), + _buildMemoryList(), ], - ), + const SizedBox(height: 20), + _buildManageButton(), + ], ), ); } @@ -63,8 +54,10 @@ class _MemoryScreenState extends State { border: Border.all(color: AppColors.borderSecondary), ), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( @@ -114,9 +107,10 @@ class _MemoryScreenState extends State { decoration: BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFE1E8F3)), + border: Border.all(color: AppColors.borderSecondary), ), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (int i = 0; i < _memoryItems.length; i++) ...[ _buildMemoryItem(_memoryItems[i]), @@ -136,9 +130,10 @@ class _MemoryScreenState extends State { decoration: BoxDecoration( color: AppColors.surfaceTertiary, borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE8EDF7)), + border: Border.all(color: AppColors.borderSecondary), ), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 32, @@ -175,7 +170,11 @@ class _MemoryScreenState extends State { ], ), ), - const Icon(Icons.chevron_right, size: 16, color: Color(0xFF9AAAC1)), + const Icon( + Icons.chevron_right, + size: 16, + color: AppColors.slate400, + ), ], ), ), @@ -190,7 +189,7 @@ class _MemoryScreenState extends State { decoration: BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFDCE6F4)), + border: Border.all(color: AppColors.borderSecondary), ), child: const Center( child: Text( @@ -207,33 +206,6 @@ class _MemoryScreenState extends State { } Widget _buildToggle(bool value, ValueChanged onChanged) { - return GestureDetector( - onTap: () => onChanged(!value), - child: Container( - width: 44, - height: 24, - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: value ? const Color(0xFFBFDBFE) : const Color(0xFFF1F5FC), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: value ? const Color(0xFF93C5FD) : const Color(0xFFD5DFEE), - ), - ), - child: AnimatedAlign( - duration: const Duration(milliseconds: 150), - alignment: value ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFFCCDDF8)), - ), - ), - ), - ), - ); + return AppToggleSwitch(value: value, onChanged: onChanged); } } diff --git a/apps/lib/features/settings/ui/screens/settings_screen.dart b/apps/lib/features/settings/ui/screens/settings_screen.dart index 37d5516..cde877b 100644 --- a/apps/lib/features/settings/ui/screens/settings_screen.dart +++ b/apps/lib/features/settings/ui/screens/settings_screen.dart @@ -4,13 +4,13 @@ import 'package:social_app/core/constants/app_constants.dart'; import 'package:social_app/core/di/injection.dart'; import 'package:social_app/core/theme/design_tokens.dart'; import 'package:social_app/shared/widgets/app_loading_indicator.dart'; -import 'package:social_app/shared/widgets/page_header.dart' as widgets; import 'package:social_app/shared/widgets/toast/toast.dart'; import 'package:social_app/shared/widgets/toast/toast_type.dart'; import 'package:social_app/features/friends/data/friends_api.dart'; import 'package:social_app/features/settings/data/settings_api.dart'; import 'package:social_app/features/users/data/models/user_response.dart'; import 'package:social_app/features/users/data/users_api.dart'; +import '../widgets/settings_page_scaffold.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -65,36 +65,20 @@ class _SettingsScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surfaceSecondary, - body: SafeArea( - child: Column( - children: [ - const widgets.PageHeader(leading: widgets.BackButton()), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - AppSpacing.xl, - AppSpacing.sm, - AppSpacing.xl, - AppSpacing.xl, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildProfileHero(), - const SizedBox(height: 16), - _buildQuickActions(context), - const SizedBox(height: 16), - _buildSubscriptionCard(), - const SizedBox(height: 16), - _buildMenuCard(context), - ], - ), - ), - ), - ], - ), + return SettingsPageScaffold( + title: '设置', + onBack: () => context.pop(), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildProfileHero(), + const SizedBox(height: 16), + _buildQuickActions(context), + const SizedBox(height: 16), + _buildSubscriptionCard(), + const SizedBox(height: 16), + _buildMenuCard(context), + ], ), ); } @@ -253,7 +237,7 @@ class _SettingsScreenState extends State { child: _buildActionCard( icon: Icons.auto_awesome, iconColor: const Color(0xFF8B5CF6), - title: '常用功能', + title: '周期计划', subtitle: '已启用:会议提醒', onTap: () => context.push('/settings/features'), ), @@ -459,7 +443,7 @@ class _SettingsScreenState extends State { icon: Icons.system_update, title: '检查更新', trailing: 'v${AppConstants.version}', - onTap: () => _checkForUpdates(context), + onTap: _checkForUpdates, ), ], ), @@ -528,7 +512,7 @@ class _SettingsScreenState extends State { ); } - Future _checkForUpdates(BuildContext context) async { + Future _checkForUpdates() async { try { final settingsApi = sl(); final result = await settingsApi.checkUpdates( diff --git a/apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart b/apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart deleted file mode 100644 index fd6d68a..0000000 --- a/apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/page_header.dart' as widgets; - -class AccountSurfaceScaffold extends StatelessWidget { - const AccountSurfaceScaffold({ - super.key, - required this.title, - this.subtitle, - required this.body, - this.footer, - this.onBack, - this.compactHeaderTitle = false, - }); - - final String title; - final String? subtitle; - final Widget body; - final Widget? footer; - final VoidCallback? onBack; - final bool compactHeaderTitle; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surfaceSecondary, - body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (compactHeaderTitle) - SizedBox( - height: 64, - child: Stack( - alignment: Alignment.center, - children: [ - widgets.PageHeader( - leading: widgets.BackButton(onPressed: onBack), - trailing: const SizedBox( - width: AppSpacing.xl * 2, - height: AppSpacing.xl * 2, - ), - ), - IgnorePointer( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xxl * 2, - ), - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppColors.slate900, - ), - ), - ), - ), - ], - ), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - widgets.PageHeader( - leading: widgets.BackButton(onPressed: onBack), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.xl, - AppSpacing.none, - AppSpacing.xl, - AppSpacing.sm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppColors.slate900, - ), - ), - if (subtitle != null && subtitle!.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.xs), - Text( - subtitle!, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - ), - ], - ], - ), - ), - ], - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - AppSpacing.xl, - AppSpacing.sm, - AppSpacing.xl, - AppSpacing.xl, - ), - child: body, - ), - ), - if (footer != null) - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.xl, - AppSpacing.none, - AppSpacing.xl, - AppSpacing.xl, - ), - child: Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.surfaceInfoLight, - borderRadius: BorderRadius.circular(AppRadius.xl), - border: Border.all(color: AppColors.borderTertiary), - ), - child: footer, - ), - ), - ], - ), - ), - ); - } -} diff --git a/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart b/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart new file mode 100644 index 0000000..3fd5afa --- /dev/null +++ b/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/design_tokens.dart'; +import '../../../../shared/widgets/back_title_page_header.dart'; + +class SettingsPageScaffold extends StatelessWidget { + const SettingsPageScaffold({ + super.key, + required this.title, + required this.body, + this.footer, + this.onBack, + }); + + final String title; + final Widget body; + final Widget? footer; + final VoidCallback? onBack; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.surfaceSecondary, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BackTitlePageHeader(title: title, onBack: onBack), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.sm, + AppSpacing.xl, + AppSpacing.xl, + ), + child: body, + ), + ), + if (footer != null) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.none, + AppSpacing.xl, + AppSpacing.xl, + ), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceInfoLight, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.borderTertiary), + ), + child: footer, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart index 55b8815..2b3d102 100644 --- a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart @@ -4,13 +4,17 @@ import 'package:lucide_icons/lucide_icons.dart'; import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; -import '../../../../shared/widgets/page_header.dart' as widgets; +import '../../../../shared/widgets/back_title_page_header.dart'; +import '../../../../shared/widgets/detail_header_action_menu.dart'; +import '../../../../shared/widgets/destructive_action_sheet.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../calendar/data/calendar_api.dart'; import '../../data/todo_api.dart'; +enum _TodoHeaderAction { edit, delete } + class TodoDetailScreen extends StatefulWidget { final String todoId; @@ -87,8 +91,9 @@ class _TodoDetailScreenState extends State { backgroundColor: AppColors.todoBg, body: SafeArea( child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildHeader(context), + _buildHeader(), Expanded(child: _buildContent()), ], ), @@ -96,39 +101,47 @@ class _TodoDetailScreenState extends State { ); } - Widget _buildHeader(BuildContext context) { - return SizedBox( - height: 64, - child: Padding( - padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 8), - child: Row( - children: [ - widgets.BackButton(onPressed: () => Navigator.of(context).pop()), - const Spacer(), - if (_todo != null) ...[ - IconButton( - onPressed: _editTodo, - icon: const Icon( - LucideIcons.pencil, - size: 20, - color: AppColors.slate600, - ), - ), - IconButton( - onPressed: _deleteTodo, - icon: const Icon( - LucideIcons.trash2, - size: 20, - color: Colors.red, - ), - ), - ], - ], - ), - ), + Widget _buildHeader() { + return BackTitlePageHeader( + title: '待办详情', + onBack: () => context.pop(), + trailing: _buildHeaderMenu(), ); } + Widget? _buildHeaderMenu() { + if (_todo == null) { + return null; + } + return DetailHeaderActionMenu<_TodoHeaderAction>( + items: const [ + DetailHeaderActionItem<_TodoHeaderAction>( + value: _TodoHeaderAction.edit, + label: '编辑', + icon: LucideIcons.pencil, + ), + DetailHeaderActionItem<_TodoHeaderAction>( + value: _TodoHeaderAction.delete, + label: '删除', + icon: LucideIcons.trash2, + isDestructive: true, + ), + ], + onSelected: _handleHeaderAction, + ); + } + + void _handleHeaderAction(_TodoHeaderAction action) { + switch (action) { + case _TodoHeaderAction.edit: + _editTodo(); + return; + case _TodoHeaderAction.delete: + _deleteTodo(); + return; + } + } + Widget _buildContent() { if (_isLoading) { return const Center(child: AppLoadingIndicator(size: 22)); @@ -382,22 +395,11 @@ class _TodoDetailScreenState extends State { } void _deleteTodo() async { - final confirm = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('确认删除'), - content: const Text('确定要删除这个待办吗?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('取消'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('删除', style: TextStyle(color: Colors.red)), - ), - ], - ), + final confirm = await showDestructiveActionSheet( + context, + title: '删除待办', + message: '确定要删除这个待办吗?', + confirmText: '确认删除', ); if (confirm == true) { diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index fd604cc..d410ece 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -8,6 +8,7 @@ import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/app_pull_refresh_feedback.dart'; import '../../../../shared/widgets/app_pressable.dart'; import '../../../../shared/widgets/app_sheet_input_field.dart'; +import '../../../../shared/widgets/back_title_page_header.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../calendar/data/calendar_api.dart'; @@ -167,74 +168,57 @@ class _TodoQuadrantsScreenState extends State { } Widget _buildHeader() { - return SizedBox( - height: 72, - child: Padding( - padding: const EdgeInsets.only(left: 16, right: 16, top: 14, bottom: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - '待办事项', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppColors.slate900, + return BackTitlePageHeader( + title: '待办事项', + showBackButton: false, + trailing: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AppPressable( + borderRadius: BorderRadius.circular(AppRadius.full), + onTap: _loadTodos, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.messageBtnWrap, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.messageBtnBorder), + ), + child: const Icon( + LucideIcons.refreshCcw, + size: 18, + color: AppColors.slate600, ), ), - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - AppPressable( - borderRadius: BorderRadius.circular(AppRadius.full), - onTap: _loadTodos, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.messageBtnWrap, - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.messageBtnBorder), - ), - child: const Icon( - LucideIcons.refreshCcw, - size: 18, - color: AppColors.slate600, - ), + ), + const SizedBox(width: AppSpacing.sm), + AppPressable( + borderRadius: BorderRadius.circular(AppRadius.full), + onTap: _addTodo, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.blue600, + borderRadius: BorderRadius.circular(AppRadius.full), + boxShadow: [ + BoxShadow( + color: AppColors.blue300.withValues(alpha: 0.28), + blurRadius: AppRadius.lg, + offset: const Offset(0, AppSpacing.xs), ), - ), - const SizedBox(width: AppSpacing.sm), - AppPressable( - borderRadius: BorderRadius.circular(AppRadius.full), - onTap: _addTodo, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.blue600, - borderRadius: BorderRadius.circular(AppRadius.full), - boxShadow: [ - BoxShadow( - color: AppColors.blue300.withValues(alpha: 0.28), - blurRadius: AppRadius.lg, - offset: const Offset(0, AppSpacing.xs), - ), - ], - ), - child: const Icon( - LucideIcons.plus, - size: 18, - color: AppColors.white, - ), - ), - ), - ], + ], + ), + child: const Icon( + LucideIcons.plus, + size: 18, + color: AppColors.white, + ), ), - ], - ), + ), + ], ), ); } @@ -395,9 +379,9 @@ class _TodoQuadrantsScreenState extends State { final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; if (viewType == CalendarViewType.month) { - context.push('/calendar/month'); + context.go('/calendar/month'); } else { - context.push('/calendar/dayweek?date=$dateStr'); + context.go('/calendar/dayweek?date=$dateStr'); } }, onHomeTap: () => context.go('/home'), diff --git a/apps/lib/shared/widgets/app_toggle_switch.dart b/apps/lib/shared/widgets/app_toggle_switch.dart new file mode 100644 index 0000000..a7aae5f --- /dev/null +++ b/apps/lib/shared/widgets/app_toggle_switch.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/design_tokens.dart'; + +class AppToggleSwitch extends StatelessWidget { + const AppToggleSwitch({ + super.key, + required this.value, + required this.onChanged, + this.activeBackgroundColor, + this.inactiveBackgroundColor, + this.activeBorderColor, + this.inactiveBorderColor, + }); + + final bool value; + final ValueChanged onChanged; + final Color? activeBackgroundColor; + final Color? inactiveBackgroundColor; + final Color? activeBorderColor; + final Color? inactiveBorderColor; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onChanged(!value), + child: Container( + width: AppSpacing.xxl + AppSpacing.xl, + height: AppSpacing.xl + AppSpacing.xs, + padding: const EdgeInsets.all(AppSpacing.xs / 2), + decoration: BoxDecoration( + color: value + ? (activeBackgroundColor ?? AppColors.blue100) + : (inactiveBackgroundColor ?? AppColors.surfaceTertiary), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: value + ? (activeBorderColor ?? AppColors.blue300) + : (inactiveBorderColor ?? AppColors.borderSecondary), + ), + ), + child: AnimatedAlign( + duration: const Duration(milliseconds: 150), + alignment: value ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: AppSpacing.lg + AppSpacing.xs, + height: AppSpacing.lg + AppSpacing.xs, + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.borderSecondary), + ), + ), + ), + ), + ); + } +} diff --git a/apps/lib/shared/widgets/back_title_page_header.dart b/apps/lib/shared/widgets/back_title_page_header.dart new file mode 100644 index 0000000..b475d89 --- /dev/null +++ b/apps/lib/shared/widgets/back_title_page_header.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/design_tokens.dart'; +import 'page_header.dart' as widgets; + +class BackTitlePageHeader extends StatelessWidget { + const BackTitlePageHeader({ + super.key, + required this.title, + this.onBack, + this.showBackButton = true, + this.trailing, + this.height = 64, + }); + + final String title; + final VoidCallback? onBack; + final bool showBackButton; + final Widget? trailing; + final double height; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: Stack( + alignment: Alignment.center, + children: [ + widgets.PageHeader( + leading: showBackButton + ? widgets.BackButton(onPressed: onBack) + : const SizedBox( + width: AppSpacing.xl * 2, + height: AppSpacing.xl * 2, + ), + trailing: + trailing ?? + const SizedBox( + width: AppSpacing.xl * 2, + height: AppSpacing.xl * 2, + ), + height: height, + ), + IgnorePointer( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xxl * 2, + ), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/lib/shared/widgets/destructive_action_sheet.dart b/apps/lib/shared/widgets/destructive_action_sheet.dart new file mode 100644 index 0000000..cafdae3 --- /dev/null +++ b/apps/lib/shared/widgets/destructive_action_sheet.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/design_tokens.dart'; +import 'app_button.dart'; + +Future showDestructiveActionSheet( + BuildContext context, { + required String title, + required String message, + required String confirmText, +}) async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return SafeArea( + top: false, + child: Container( + margin: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.none, + AppSpacing.md, + AppSpacing.md, + ), + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14, color: AppColors.slate500), + ), + const SizedBox(height: AppSpacing.lg), + SizedBox( + height: 52, + child: GestureDetector( + onTap: () => Navigator.of(sheetContext).pop(true), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.feedbackErrorIcon, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + confirmText, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.white, + ), + ), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + SizedBox( + height: 52, + child: AppButton( + text: '取消', + isOutlined: true, + onPressed: () => Navigator.of(sheetContext).pop(false), + ), + ), + ], + ), + ), + ); + }, + ); + return result == true; +} diff --git a/apps/lib/shared/widgets/detail_header_action_menu.dart b/apps/lib/shared/widgets/detail_header_action_menu.dart new file mode 100644 index 0000000..0db7cf9 --- /dev/null +++ b/apps/lib/shared/widgets/detail_header_action_menu.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/design_tokens.dart'; + +class DetailHeaderActionItem { + const DetailHeaderActionItem({ + required this.value, + required this.label, + required this.icon, + this.isDestructive = false, + }); + + final T value; + final String label; + final IconData icon; + final bool isDestructive; +} + +class DetailHeaderActionMenu extends StatefulWidget { + const DetailHeaderActionMenu({ + super.key, + required this.items, + required this.onSelected, + }); + + final List> items; + final ValueChanged onSelected; + + @override + State> createState() => + _DetailHeaderActionMenuState(); +} + +class _DetailHeaderActionMenuState extends State> { + static const double _buttonSize = AppSpacing.xl * 2; + static const double _menuWidth = AppSpacing.xxl * 8; + + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _menuEntry; + + bool get _isMenuOpen => _menuEntry != null; + + @override + void dispose() { + _hideMenu(); + super.dispose(); + } + + void _toggleMenu() { + if (_isMenuOpen) { + _hideMenu(); + return; + } + _showMenu(); + } + + void _showMenu() { + final overlay = Overlay.of(context); + _menuEntry = OverlayEntry( + builder: (context) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _hideMenu, + child: const SizedBox.expand(), + ), + ), + CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: const Offset( + _buttonSize - _menuWidth, + _buttonSize + AppSpacing.sm, + ), + child: _buildMenuCard(), + ), + ], + ); + }, + ); + + overlay.insert(_menuEntry!); + setState(() {}); + } + + void _hideMenu() { + _menuEntry?.remove(); + _menuEntry = null; + if (mounted) { + setState(() {}); + } + } + + void _handleSelect(T value) { + _hideMenu(); + widget.onSelected(value); + } + + Widget _buildMenuCard() { + return Material( + color: Colors.transparent, + child: Container( + width: _menuWidth, + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.borderSecondary), + boxShadow: [ + BoxShadow( + color: AppColors.slate300.withValues(alpha: 0.42), + blurRadius: AppSpacing.xl, + offset: const Offset(0, AppSpacing.sm), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (int i = 0; i < widget.items.length; i++) ...[ + _buildMenuItem(widget.items[i]), + if (i < widget.items.length - 1) + const Padding( + padding: EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Divider( + height: 1, + thickness: 1, + color: AppColors.slate100, + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildMenuItem(DetailHeaderActionItem item) { + final textColor = item.isDestructive + ? AppColors.red500 + : AppColors.slate700; + final pressedColor = item.isDestructive + ? AppColors.feedbackErrorSurface + : AppColors.surfaceInfoLight; + + return SizedBox( + height: AppSpacing.xxl * 2, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.md), + splashColor: pressedColor, + highlightColor: pressedColor, + onTap: () => _handleSelect(item.value), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + item.icon, + size: AppSpacing.lg + AppSpacing.xs, + color: textColor, + ), + const SizedBox(width: AppSpacing.md), + Text( + item.label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ], + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (widget.items.isEmpty) { + return const SizedBox.shrink(); + } + + return CompositedTransformTarget( + link: _layerLink, + child: GestureDetector( + onTap: _toggleMenu, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + width: _buttonSize, + height: _buttonSize, + decoration: BoxDecoration( + color: _isMenuOpen + ? AppColors.surfaceInfo + : AppColors.surfaceTertiary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: _isMenuOpen + ? AppColors.borderQuaternary + : AppColors.borderTertiary, + ), + ), + child: const Icon( + Icons.more_horiz, + size: AppSpacing.lg + AppSpacing.xs, + color: AppColors.slate600, + ), + ), + ), + ); + } +} diff --git a/apps/test/core/api/api_interceptor_test.dart b/apps/test/core/api/api_interceptor_test.dart index e56c27f..81ddf87 100644 --- a/apps/test/core/api/api_interceptor_test.dart +++ b/apps/test/core/api/api_interceptor_test.dart @@ -36,8 +36,13 @@ void main() { when(() => tokenStorage.getAccessToken()).thenAnswer((_) async => null); }); - DioException _unauthorized(String path) { - final requestOptions = RequestOptions(path: path); + DioException _unauthorized(String path, {bool withAuthHeader = false}) { + final requestOptions = RequestOptions( + path: path, + headers: withAuthHeader + ? {'Authorization': 'Bearer expired'} + : null, + ); return DioException( requestOptions: requestOptions, response: Response( @@ -109,4 +114,30 @@ void main() { expect(refreshCalls, 1); }); + + test('并发401刷新失败仅触发一次auth failure回调', () async { + var refreshCalls = 0; + var authFailureCalls = 0; + interceptor.onTokenRefresh = () async { + refreshCalls += 1; + await Future.delayed(const Duration(milliseconds: 20)); + return false; + }; + interceptor.onAuthFailure = () async { + authFailureCalls += 1; + }; + + interceptor.onError( + _unauthorized('/api/v1/agent/history', withAuthHeader: true), + handler, + ); + interceptor.onError( + _unauthorized('/api/v1/agent/history', withAuthHeader: true), + handler, + ); + await Future.delayed(const Duration(milliseconds: 80)); + + expect(refreshCalls, 1); + expect(authFailureCalls, 1); + }); } diff --git a/apps/test/features/auth/data/auth_repository_test.dart b/apps/test/features/auth/data/auth_repository_test.dart index d3f9178..be4269f 100644 --- a/apps/test/features/auth/data/auth_repository_test.dart +++ b/apps/test/features/auth/data/auth_repository_test.dart @@ -98,6 +98,18 @@ void main() { }, ); + test( + 'clearSessionLocalOnly clears local tokens without api revoke', + () async { + when(() => mockStorage.clear()).thenAnswer((_) async {}); + + await repository.clearSessionLocalOnly(); + + verify(() => mockStorage.clear()).called(1); + verifyNever(() => mockApi.deleteSession(any())); + }, + ); + test('refreshSession saves new tokens', () async { when(() => mockApi.refreshSession(any())).thenAnswer( (_) async => AuthResponse( diff --git a/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart b/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart index 2ae18e0..497fd2c 100644 --- a/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart +++ b/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart @@ -32,7 +32,10 @@ void main() { return authBloc; }, act: (bloc) => bloc.add(AuthStarted()), - expect: () => [AuthLoading(), AuthUnauthenticated()], + expect: () => [ + AuthLoading(), + const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut), + ], ); blocTest( @@ -65,11 +68,38 @@ void main() { when( () => mockRepository.refreshSession('expired_refresh'), ).thenThrow(Exception('Invalid refresh token')); - when(() => mockRepository.deleteSession()).thenAnswer((_) async {}); + when( + () => mockRepository.clearSessionLocalOnly(), + ).thenAnswer((_) async {}); return authBloc; }, act: (bloc) => bloc.add(AuthStarted()), - expect: () => [AuthLoading(), AuthUnauthenticated()], + expect: () => [ + AuthLoading(), + const AuthUnauthenticated( + reason: AuthUnauthenticatedReason.startupRecoveryFailed, + ), + ], + ); + + blocTest( + 'emits startupRecoveryFailed when storage read throws', + build: () { + when( + () => mockRepository.getRefreshToken(), + ).thenThrow(Exception('storage failed')); + when( + () => mockRepository.clearSessionLocalOnly(), + ).thenAnswer((_) async {}); + return authBloc; + }, + act: (bloc) => bloc.add(AuthStarted()), + expect: () => [ + AuthLoading(), + const AuthUnauthenticated( + reason: AuthUnauthenticatedReason.startupRecoveryFailed, + ), + ], ); blocTest( @@ -93,7 +123,30 @@ void main() { user: const AuthUser(id: '1', email: 'test@example.com'), ), act: (bloc) => bloc.add(AuthLoggedOut()), - expect: () => [AuthUnauthenticated()], + expect: () => [ + const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut), + ], + ); + + blocTest( + 'emits expired unauthenticated when session invalidated', + build: () { + when( + () => mockRepository.clearSessionLocalOnly(), + ).thenAnswer((_) async {}); + return authBloc; + }, + seed: () => AuthAuthenticated( + user: const AuthUser(id: '1', email: 'test@example.com'), + ), + act: (bloc) => bloc.add( + const AuthSessionInvalidated( + source: AuthInvalidationSource.unauthorized401, + ), + ), + expect: () => [ + const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired), + ], ); }); } diff --git a/apps/test/features/calendar/ui/create_event_sheet_test.dart b/apps/test/features/calendar/ui/create_event_sheet_test.dart new file mode 100644 index 0000000..46bc111 --- /dev/null +++ b/apps/test/features/calendar/ui/create_event_sheet_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; +import 'package:social_app/features/calendar/ui/widgets/create_event_sheet.dart'; + +void main() { + testWidgets('编辑日程时支持非默认提醒值', (tester) async { + final event = ScheduleItemModel( + id: 'evt_1', + ownerId: 'user_1', + title: '测试日程', + startAt: DateTime(2026, 3, 18, 10, 0), + endAt: DateTime(2026, 3, 18, 11, 0), + metadata: ScheduleMetadata(reminderMinutes: 20), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: CreateEventSheet(editingEvent: event)), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('进阶')); + await tester.pumpAndSettle(); + + expect(find.text('开始前20分钟'), findsOneWidget); + }); +} diff --git a/apps/test/features/calendar/ui/dayweek/day_event_layout_engine_test.dart b/apps/test/features/calendar/ui/dayweek/day_event_layout_engine_test.dart new file mode 100644 index 0000000..7fc846e --- /dev/null +++ b/apps/test/features/calendar/ui/dayweek/day_event_layout_engine_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; +import 'package:social_app/features/calendar/ui/dayweek/day_event_layout_engine.dart'; +import 'package:social_app/features/calendar/ui/dayweek/day_timeline_metrics.dart'; +import 'package:social_app/features/calendar/ui/dayweek/day_view_scale.dart'; + +void main() { + group('DayEventLayoutEngine', () { + const engine = DayEventLayoutEngine(); + const scale = DayViewScale(hourHeight: 60); + + test('maps event top and height by exact minutes', () { + final event = _event( + id: 'a', + start: DateTime(2026, 3, 18, 10, 15), + end: DateTime(2026, 3, 18, 11, 45), + ); + + final layouts = engine.layout( + events: [event], + scale: scale, + eventAreaLeft: DayTimelineMetrics.eventAreaLeft(), + eventAreaWidth: 200, + ); + + expect(layouts, hasLength(1)); + expect(layouts.first.startMinutes, 615); + expect(layouts.first.endMinutes, 705); + expect(layouts.first.top, 615); + expect(layouts.first.geometryHeight, 90); + expect(layouts.first.visualHeight, 90); + }); + + test('splits overlapped events into columns', () { + final e1 = _event( + id: 'a', + start: DateTime(2026, 3, 18, 9, 0), + end: DateTime(2026, 3, 18, 10, 0), + ); + final e2 = _event( + id: 'b', + start: DateTime(2026, 3, 18, 9, 30), + end: DateTime(2026, 3, 18, 10, 30), + ); + + final layouts = engine.layout( + events: [e1, e2], + scale: scale, + eventAreaLeft: DayTimelineMetrics.eventAreaLeft(), + eventAreaWidth: 200, + ); + + expect(layouts, hasLength(2)); + expect(layouts[0].columnCount, 2); + expect(layouts[1].columnCount, 2); + expect(layouts[0].width, closeTo(98, 0.001)); + expect(layouts[1].width, closeTo(98, 0.001)); + expect(layouts[0].left, DayTimelineMetrics.eventAreaLeft()); + expect( + layouts[1].left, + closeTo(DayTimelineMetrics.eventAreaLeft() + 102, 0.001), + ); + }); + + test('uses 1 pixel minimum visual height but preserves geometry', () { + final event = _event( + id: 'a', + start: DateTime(2026, 3, 18, 9, 0), + end: DateTime(2026, 3, 18, 9, 1), + ); + + final tinyScale = const DayViewScale( + hourHeight: DayViewScale.minHourHeight, + ); + final layouts = engine.layout( + events: [event], + scale: tinyScale, + eventAreaLeft: DayTimelineMetrics.eventAreaLeft(), + eventAreaWidth: 200, + ); + + expect(layouts, hasLength(1)); + expect( + layouts.first.geometryHeight, + closeTo(tinyScale.pixelsForMinutes(1), 0.001), + ); + expect(layouts.first.visualHeight, greaterThanOrEqualTo(1)); + }); + }); +} + +ScheduleItemModel _event({ + required String id, + required DateTime start, + required DateTime end, +}) { + return ScheduleItemModel( + id: id, + ownerId: 'owner', + title: 'event-$id', + startAt: start, + endAt: end, + ); +} diff --git a/apps/test/features/calendar/ui/dayweek/day_view_scale_test.dart b/apps/test/features/calendar/ui/dayweek/day_view_scale_test.dart new file mode 100644 index 0000000..b31d258 --- /dev/null +++ b/apps/test/features/calendar/ui/dayweek/day_view_scale_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/calendar/ui/dayweek/day_view_scale.dart'; + +void main() { + group('DayViewScale', () { + test('maps minutes to pixels and back', () { + const scale = DayViewScale(hourHeight: 60); + + expect(scale.pixelsForMinutes(30), 30); + expect(scale.pixelsForMinutes(75), 75); + expect(scale.minutesForPixels(90), 90); + }); + + test('clamps zoom height at boundaries', () { + const scale = DayViewScale(hourHeight: 34); + + final zoomIn = scale.zoomByFactor(20); + final zoomOut = scale.zoomByFactor(0.01); + + expect(zoomIn.hourHeight, DayViewScale.maxHourHeight); + expect(zoomOut.hourHeight, DayViewScale.minHourHeight); + }); + + test('ignores invalid zoom factor', () { + const scale = DayViewScale(hourHeight: 34); + + expect(scale.zoomByFactor(0).hourHeight, 34); + expect(scale.zoomByFactor(-1).hourHeight, 34); + }); + }); +} diff --git a/apps/test/features/chat/ui_schema_renderer_test.dart b/apps/test/features/chat/ui_schema_renderer_test.dart index bd63491..968b3a4 100644 --- a/apps/test/features/chat/ui_schema_renderer_test.dart +++ b/apps/test/features/chat/ui_schema_renderer_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart'; void main() { @@ -128,5 +129,93 @@ void main() { expect(find.textContaining('无效 UI Schema'), findsOneWidget); }); + + testWidgets('handles navigation action and jumps by path', (tester) async { + final schema = { + 'version': '2.0', + 'root': { + 'type': 'stack', + 'direction': 'vertical', + 'appearance': 'plain', + 'children': [ + { + 'type': 'button', + 'label': '查看待办', + 'style': 'primary', + 'action': { + 'type': 'navigation', + 'path': '/todo/123', + 'params': {'from': 'assistant'}, + }, + }, + ], + }, + }; + + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (context, state) => + Scaffold(body: UiSchemaRenderer.renderSchema(schema)), + ), + GoRoute( + path: '/todo/:id', + builder: (context, state) => Text( + 'todo detail ${state.pathParameters['id']} from ${state.uri.queryParameters['from']}', + ), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.tap(find.text('查看待办')); + await tester.pumpAndSettle(); + + expect(find.text('todo detail 123 from assistant'), findsOneWidget); + }); + + testWidgets('does not navigate for placeholder path', (tester) async { + final schema = { + 'version': '2.0', + 'root': { + 'type': 'stack', + 'direction': 'vertical', + 'appearance': 'plain', + 'children': [ + { + 'type': 'button', + 'label': '坏路径', + 'style': 'primary', + 'action': {'type': 'navigation', 'path': '/todo/:id'}, + }, + ], + }, + }; + + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (context, state) => + Scaffold(body: UiSchemaRenderer.renderSchema(schema)), + ), + GoRoute( + path: '/todo/:id', + builder: (context, state) => const Text('detail'), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.tap(find.text('坏路径')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 3)); + + expect(find.text('坏路径'), findsOneWidget); + expect(find.text('detail'), findsNothing); + }); }); } diff --git a/backend/alembic/versions/20260226_0001_initial_schema.py b/backend/alembic/versions/20260226_0001_initial_schema.py index 7364c90..fddc801 100644 --- a/backend/alembic/versions/20260226_0001_initial_schema.py +++ b/backend/alembic/versions/20260226_0001_initial_schema.py @@ -125,7 +125,7 @@ def upgrade() -> None: RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER - SET search_path = public + SET search_path = '' AS $$ BEGIN INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at) diff --git a/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py b/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py index 691bf82..5dbbb3c 100644 --- a/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py +++ b/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py @@ -52,8 +52,8 @@ def upgrade() -> None: op.execute( """ - CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() - RETURNS trigger + CREATE OR REPLACE FUNCTION public.generate_invite_code() + RETURNS TEXT LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' @@ -78,7 +78,7 @@ def upgrade() -> None: RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER - SET search_path = public + SET search_path = '' AS $$ DECLARE invite_code_value TEXT; diff --git a/backend/alembic/versions/435419f8121c_simplify_agent_architecture.py b/backend/alembic/versions/435419f8121c_simplify_agent_architecture.py index 1f8e832..cd26a1c 100644 --- a/backend/alembic/versions/435419f8121c_simplify_agent_architecture.py +++ b/backend/alembic/versions/435419f8121c_simplify_agent_architecture.py @@ -69,10 +69,15 @@ def upgrade() -> None: RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER - SET search_path = public + SET search_path = '' AS $$ + DECLARE + invite_code_value TEXT; + referrer_id UUID; + new_code TEXT; + attempts INT := 0; BEGIN - INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at) + INSERT INTO public.profiles (id, username, avatar_url, bio, settings, referred_by, created_at, updated_at) VALUES ( NEW.id, COALESCE( @@ -82,11 +87,54 @@ def upgrade() -> None: ), NULL, NULL, - '{"agent_prompts": {}}'::jsonb, + '{}'::jsonb, + NULL, now(), now() ) ON CONFLICT (id) DO NOTHING; + + LOOP + BEGIN + new_code := public.generate_invite_code(); + INSERT INTO public.invite_codes (code, owner_id, status, used_count, max_uses, expires_at, reward_config) + VALUES ( + new_code, + NEW.id, + 'active', + 0, + NULL, + NULL, + '{}'::jsonb + ); + EXIT; + EXCEPTION WHEN unique_violation THEN + attempts := attempts + 1; + IF attempts >= 100 THEN + RAISE EXCEPTION 'Failed to generate unique invite code after 100 attempts'; + END IF; + END; + END LOOP; + + invite_code_value := NEW.raw_user_meta_data ->> 'invite_code'; + IF invite_code_value IS NOT NULL AND length(invite_code_value) = 4 THEN + invite_code_value := upper(invite_code_value); + IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$' THEN + UPDATE public.invite_codes + SET used_count = used_count + 1 + WHERE code = invite_code_value + AND status = 'active' + AND (max_uses IS NULL OR used_count < max_uses) + AND (expires_at IS NULL OR expires_at > NOW()) + RETURNING owner_id INTO referrer_id; + + IF referrer_id IS NOT NULL THEN + UPDATE public.profiles + SET referred_by = referrer_id + WHERE id = NEW.id; + END IF; + END IF; + END IF; RETURN NEW; END; @@ -121,7 +169,7 @@ def downgrade() -> None: RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER - SET search_path = public + SET search_path = '' AS $$ BEGIN INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at) diff --git a/backend/alembic/versions/50ae013ce530_add_user_agent_catalog.py b/backend/alembic/versions/50ae013ce530_add_user_agent_catalog.py index 8872071..1fce495 100644 --- a/backend/alembic/versions/50ae013ce530_add_user_agent_catalog.py +++ b/backend/alembic/versions/50ae013ce530_add_user_agent_catalog.py @@ -71,7 +71,7 @@ def upgrade() -> None: RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER - SET search_path = public + SET search_path = '' AS $$ BEGIN INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at) @@ -114,7 +114,7 @@ def downgrade() -> None: RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER - SET search_path = public + SET search_path = '' AS $$ BEGIN INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at) diff --git a/backend/src/core/agentscope/prompts/route_prompt.py b/backend/src/core/agentscope/prompts/route_prompt.py new file mode 100644 index 0000000..5d369d4 --- /dev/null +++ b/backend/src/core/agentscope/prompts/route_prompt.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +from typing import Any, ClassVar + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError + + +class FrontendRoute(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + route_id: str + path: str + description: str + category: str + auth_required: bool + path_params: list[str] = Field(default_factory=list) + query_params: list[str] = Field(default_factory=list) + + +class FrontendRouteCatalog(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + version: str + routes: list[FrontendRoute] + + +def _default_catalog_path() -> Path: + return ( + Path(__file__).resolve().parents[2] + / "config" + / "static" + / "route" + / "frontend_routes.yaml" + ) + + +@lru_cache(maxsize=1) +def load_frontend_routes_catalog() -> FrontendRouteCatalog: + path = _default_catalog_path() + with path.open("r", encoding="utf-8") as file: + loaded: Any = yaml.safe_load(file) or {} + + if not isinstance(loaded, dict): + raise ValueError(f"Invalid frontend routes catalog format: {path}") + + try: + return FrontendRouteCatalog.model_validate(loaded) + except ValidationError as exc: + raise ValueError(f"Invalid frontend routes catalog data: {path}") from exc + + +def build_frontend_route_prompt() -> str: + catalog = load_frontend_routes_catalog() + + lines = [ + "[Frontend Route Catalog]", + f"version={catalog.version}", + "rules: use listed route_id only; output concrete path; no placeholders; no query in path; put query in params; params scalar only.", + "ROUTES:", + ] + + for route in catalog.routes: + path_params = ", ".join(route.path_params) if route.path_params else "none" + query_params = ", ".join(route.query_params) if route.query_params else "none" + lines.append( + "- " + f"route_id={route.route_id}; " + f"path={route.path}; " + f"path_params={path_params}; " + f"query_params={query_params}" + ) + + return "\n".join(lines) diff --git a/backend/src/core/agentscope/prompts/system_prompt.py b/backend/src/core/agentscope/prompts/system_prompt.py index 0cdbd6f..c15563a 100644 --- a/backend/src/core/agentscope/prompts/system_prompt.py +++ b/backend/src/core/agentscope/prompts/system_prompt.py @@ -9,6 +9,7 @@ from ag_ui.core.types import Tool from core.agentscope.prompts.agent_prompt import ( build_agent_prompt, ) +from core.agentscope.prompts.route_prompt import build_frontend_route_prompt from core.agentscope.prompts.tool_prompt import build_tools_prompt from schemas.agent.system_agent import AgentType from schemas.agent.forwarded_props import ClientTimeContext @@ -19,6 +20,7 @@ def _wrap_section(section: str, content: str) -> str: marker_map = { "env": ("", ""), "identity": ("", ""), + "route": ("", ""), "schema": ("", ""), "safety": ("", ""), "output": ("", ""), @@ -193,6 +195,10 @@ def _build_output_rules() -> str: ) +def _build_route_section() -> str: + return _wrap_section("route", build_frontend_route_prompt()) + + def build_system_prompt( *, agent_type: AgentType, @@ -202,7 +208,7 @@ def build_system_prompt( extra_context: str | None = None, tools: Sequence[Tool | dict[str, Any]] | None = None, ) -> str: - sections = [ + sections: list[str | None] = [ _build_identity_section(), _build_env_section( user_context=user_context, @@ -210,6 +216,7 @@ def build_system_prompt( runtime_client_time=runtime_client_time, extra_context=extra_context, ), + _build_route_section(), _build_safety_section(), build_agent_prompt( agent_type=agent_type, diff --git a/backend/src/core/config/static/route/frontend_routes.yaml b/backend/src/core/config/static/route/frontend_routes.yaml new file mode 100644 index 0000000..6a617e3 --- /dev/null +++ b/backend/src/core/config/static/route/frontend_routes.yaml @@ -0,0 +1,118 @@ +version: "1.0" +routes: + - route_id: auth.boot + path: /boot + description: Bootstraps auth session and redirects to login or home. + category: auth + auth_required: false + - route_id: auth.login + path: / + description: Login entry for unauthenticated users. + category: auth + auth_required: false + - route_id: auth.register + path: /register + description: Account registration page. + category: auth + auth_required: false + - route_id: auth.register_verification + path: /register/verification + description: Verifies registration code after signup. + category: auth + auth_required: false + - route_id: auth.reset_password + path: /reset-password + description: Resets password using verification flow. + category: auth + auth_required: false + - route_id: home.main + path: /home + description: Main assistant home screen. + category: home + auth_required: true + - route_id: message.invite_list + path: /messages/invites + description: Lists message invitations. + category: messages + auth_required: true + - route_id: message.invite_detail + path: /messages/invites/{id} + description: Shows details for a single invitation. + category: messages + auth_required: true + path_params: + - id + - route_id: contacts.list + path: /contacts + description: Contact list and quick relationship actions. + category: contacts + auth_required: true + - route_id: contacts.add + path: /contacts/add + description: Create or edit a contact profile. + category: contacts + auth_required: true + - route_id: calendar.dayweek + path: /calendar/dayweek + description: Day and week calendar view. + category: calendar + auth_required: true + query_params: + - date + - from + - route_id: calendar.month + path: /calendar/month + description: Month calendar overview. + category: calendar + auth_required: true + query_params: + - from + - route_id: calendar.event_detail + path: /calendar/events/{id} + description: Detail page for one calendar event. + category: calendar + auth_required: true + path_params: + - id + - route_id: todo.list + path: /todo + description: Todo quadrants and backlog overview. + category: todo + auth_required: true + - route_id: todo.detail + path: /todo/{id} + description: Detail page for one todo item. + category: todo + auth_required: true + path_params: + - id + - route_id: settings.main + path: /settings + description: Settings hub page. + category: settings + auth_required: true + - route_id: settings.features + path: /settings/features + description: Cycle planning settings page. + category: settings + auth_required: true + - route_id: settings.memory + path: /settings/memory + description: Memory preferences and controls. + category: settings + auth_required: true + - route_id: settings.account + path: /settings/account + description: Account profile and security entry points. + category: settings + auth_required: true + - route_id: settings.change_password + path: /change-password + description: Password change page. + category: settings + auth_required: true + - route_id: settings.edit_profile + path: /edit-profile + description: Profile editing page. + category: settings + auth_required: true diff --git a/backend/src/schemas/agent/ui_hints.py b/backend/src/schemas/agent/ui_hints.py index 5d8f26c..a5624ec 100644 --- a/backend/src/schemas/agent/ui_hints.py +++ b/backend/src/schemas/agent/ui_hints.py @@ -13,9 +13,16 @@ Version: 2.1 from __future__ import annotations from enum import Enum -from typing import Any, Literal +import re +from typing import Any, ClassVar, Literal from pydantic import BaseModel, ConfigDict, Field +from pydantic import field_validator + +_NAVIGATION_PATH_PATTERN = re.compile(r"^/[A-Za-z0-9/_-]*$") +_NAVIGATION_PARAM_KEY_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,31}$") +_MAX_NAVIGATION_PARAMS = 8 + # ============================================================ # Enums @@ -74,7 +81,7 @@ class UiHintIconSource(str, Enum): class UiHintBaseModel(BaseModel): - model_config = ConfigDict( + model_config: ClassVar[ConfigDict] = ConfigDict( extra="forbid", populate_by_name=True, ) @@ -90,6 +97,44 @@ class UiHintActionNavigation(UiHintBaseModel): path: str = Field(..., description="Internal route path.") params: dict[str, Any] | None = Field(default=None, description="Route params.") + @field_validator("path") + @classmethod + def validate_navigation_path(cls, value: str) -> str: + path = value.strip() + if not path: + raise ValueError("navigation path must not be empty") + if len(path) > 256: + raise ValueError("navigation path is too long") + if path.startswith("//") or "://" in path: + raise ValueError("navigation path must be internal") + if "?" in path or "#" in path: + raise ValueError("navigation path must not contain query or fragment") + if ":" in path: + raise ValueError("navigation path must be concrete without placeholders") + if _NAVIGATION_PATH_PATTERN.fullmatch(path) is None: + raise ValueError("navigation path contains unsupported characters") + return path + + @field_validator("params") + @classmethod + def validate_navigation_params( + cls, value: dict[str, Any] | None + ) -> dict[str, Any] | None: + if value is None: + return None + if len(value) > _MAX_NAVIGATION_PARAMS: + raise ValueError("navigation params exceed limit") + + normalized: dict[str, Any] = {} + for key, param_value in value.items(): + if _NAVIGATION_PARAM_KEY_PATTERN.fullmatch(key) is None: + raise ValueError("navigation param key is invalid") + if isinstance(param_value, (str, int, float, bool)): + normalized[key] = param_value + continue + raise ValueError("navigation params must be scalar") + return normalized + class UiHintActionUrl(UiHintBaseModel): type: Literal["url"] @@ -203,7 +248,7 @@ class UiHintsPayload(UiHintBaseModel): - 编译器负责转换为完整 UiSchemaRenderer """ - model_config = ConfigDict( + model_config: ClassVar[ConfigDict] = ConfigDict( extra="forbid", populate_by_name=True, json_schema_extra={ diff --git a/backend/tests/unit/core/agentscope/test_route_prompt.py b/backend/tests/unit/core/agentscope/test_route_prompt.py new file mode 100644 index 0000000..bb9124a --- /dev/null +++ b/backend/tests/unit/core/agentscope/test_route_prompt.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from core.agentscope.prompts.route_prompt import ( + build_frontend_route_prompt, + load_frontend_routes_catalog, +) + + +def test_load_frontend_routes_catalog_contains_known_routes() -> None: + catalog = load_frontend_routes_catalog() + + assert catalog.version == "1.0" + route_ids = {route.route_id for route in catalog.routes} + assert "home.main" in route_ids + assert "calendar.event_detail" in route_ids + assert "todo.detail" in route_ids + + +def test_build_frontend_route_prompt_has_guidance_and_routes() -> None: + prompt = build_frontend_route_prompt() + + assert "[Frontend Route Catalog]" in prompt + assert "version=1.0" in prompt + assert "route_id=home.main; path=/home;" in prompt + assert "route_id=calendar.event_detail; path=/calendar/events/{id};" in prompt diff --git a/backend/tests/unit/core/agentscope/test_system_prompt.py b/backend/tests/unit/core/agentscope/test_system_prompt.py index a536875..885fa53 100644 --- a/backend/tests/unit/core/agentscope/test_system_prompt.py +++ b/backend/tests/unit/core/agentscope/test_system_prompt.py @@ -148,7 +148,9 @@ def test_build_system_prompt_keeps_sections_focused_without_language_duplication assert "[Identity]" in prompt assert "[Runtime Context]" in prompt + assert "" in prompt assert "[Safety Rules]" in prompt + assert "[Frontend Route Catalog]" in prompt assert "[Agent Identity]" in prompt assert "[Available Tools]" in prompt assert "[Answer Style]" in prompt diff --git a/backend/tests/unit/schemas/agent/test_ui_hints_navigation.py b/backend/tests/unit/schemas/agent/test_ui_hints_navigation.py new file mode 100644 index 0000000..c4c137c --- /dev/null +++ b/backend/tests/unit/schemas/agent/test_ui_hints_navigation.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from schemas.agent.ui_hints import UiHintsPayload + + +def _base_payload() -> dict[str, object]: + return { + "intent": "status", + "status": "success", + "title": "Todo created", + } + + +def test_navigation_action_accepts_concrete_path_and_scalar_params() -> None: + payload = { + **_base_payload(), + "actions": [ + { + "label": "View todo", + "action": { + "type": "navigation", + "path": "/todo/123", + "params": {"from": "assistant", "focus": True}, + }, + } + ], + } + + parsed = UiHintsPayload.model_validate(payload) + + assert parsed.actions[0].action.type == "navigation" + + +def test_navigation_action_rejects_template_path_placeholder() -> None: + payload = { + **_base_payload(), + "actions": [ + { + "label": "Open", + "action": { + "type": "navigation", + "path": "/todo/:id", + }, + } + ], + } + + with pytest.raises(ValidationError): + UiHintsPayload.model_validate(payload) + + +def test_navigation_action_rejects_nested_params() -> None: + payload = { + **_base_payload(), + "actions": [ + { + "label": "Open", + "action": { + "type": "navigation", + "path": "/todo/123", + "params": {"filters": {"status": "open"}}, + }, + } + ], + } + + with pytest.raises(ValidationError): + UiHintsPayload.model_validate(payload) diff --git a/docs/plans/2026-03-18-auth-global-rewrite-design.md b/docs/plans/2026-03-18-auth-global-rewrite-design.md new file mode 100644 index 0000000..3df4a25 --- /dev/null +++ b/docs/plans/2026-03-18-auth-global-rewrite-design.md @@ -0,0 +1,102 @@ +# Auth 全局模块重写设计(跨端并存、同端互斥) + +## 1. 目标 + +- 彻底消除 Auth 分裂状态:`token` 状态与 `AuthBloc` 状态必须单一真相源。 +- 会话策略升级为: + - 同账号允许跨端并存:`mobile + web + desktop` + - 同账号同端互斥:同端新登录会挤下线旧设备 +- 保证任何 401 链路在刷新失败后都能统一收敛为“未登录 + 清理本地 + 路由回到登录页”。 +- 消除设备差异导致的不一致行为(部分机型“假登录”或“卡死页”)。 + +## 2. 边界与约束 + +- 仅重写 `apps/**` 的 Auth 客户端架构与规则,不改后端协议语义。 +- 保持现有登录/注册 UI 路由入口,避免用户操作路径变化。 +- 认证属于高风险域,重写必须覆盖: + - 启动恢复 + - 运行时 token 过期 + - 并发 401 + - 手动登出与自动过期登出的差异行为 + +## 3. 核心架构 + +### 3.1 单一真相源(Single Source of Truth) + +- `AuthBloc` 成为唯一认证状态源。 +- `ApiInterceptor` 只负责协议级拦截与刷新,不直接做路由跳转。 +- 401 刷新失败时,`ApiInterceptor -> ApiClient callback -> AuthBloc(AuthSessionInvalidated)`。 +- 路由守卫只看 `AuthBloc` 状态,不再做隐式 token 判定。 + +### 3.2 会话状态机 + +- `AuthInitial` +- `AuthLoading`(启动恢复/会话检查) +- `AuthAuthenticated(user)` +- `AuthUnauthenticated(reason)` + +`reason` 取值: +- `signedOut` +- `expired` +- `startupRecoveryFailed` + +### 3.3 登出语义分离 + +- 手动登出:`deleteSession()` + - 尝试调用后端注销 + - 最终清本地 +- 自动过期:`clearSessionLocalOnly()` + - 仅清本地 + - 不调用后端注销接口 + +### 3.4 并发与抖动控制 + +- `ApiInterceptor` 继续使用 refresh singleflight。 +- 新增 auth failure singleflight:多并发 401 刷新失败,只触发一次全局会话失效事件。 + +### 3.5 设备差异治理 + +- 启动时 token 读取异常必须兜底:进入 `AuthUnauthenticated(startupRecoveryFailed)`,避免卡死在 Boot。 +- `FlutterSecureStorage` 显式平台配置: + - Android 使用 `encryptedSharedPreferences` + - iOS 指定 keychain accessibility(保证行为稳定) + +## 4. 数据流 + +### 4.1 冷启动 + +1. `main` 触发 `AuthStarted` +2. `AuthBloc` 读取 refresh token +3. 有 refresh token -> 刷新会话 -> 成功进入 `AuthAuthenticated` +4. 无 token 或异常 -> `AuthUnauthenticated(startupRecoveryFailed)` + +### 4.2 运行时 API 请求 + +1. 请求携带 access token +2. 401 -> 触发 refresh +3. refresh 成功 -> 自动重试原请求 +4. refresh 失败 -> 触发一次全局 auth failure +5. `AuthBloc` 收到 `AuthSessionInvalidated(expired)` -> 清本地 -> `AuthUnauthenticated(expired)` +6. Router 根据状态回登录页 + +## 5. 测试策略 + +- `AuthBloc`: + - 启动读取 refresh token 异常兜底 + - session invalidated 事件导致统一未登录 +- `ApiInterceptor`: + - 并发 401 refresh 失败仅触发一次 auth failure +- `AuthRepository`: + - 手动登出 vs 自动过期清理行为差异 + +## 6. 迁移计划 + +- 先引入新事件/新回调/新状态原因,不改 UI 交互。 +- 再改路由守卫识别未登录原因。 +- 最后补齐 `apps/AGENTS.md` Auth 强约束,防止后续回归为“各处乱写”。 + +## 7. 风险与回滚 + +- 风险:回调链路接错导致频繁误登出。 +- 规避:并发 singleflight + 精确触发条件(仅 401 refresh 失败)。 +- 回滚:保留旧事件兼容层,出现异常可快速退回旧路由判定。 diff --git a/docs/plans/2026-03-18-auth-global-rewrite-plan.md b/docs/plans/2026-03-18-auth-global-rewrite-plan.md new file mode 100644 index 0000000..a0c43d0 --- /dev/null +++ b/docs/plans/2026-03-18-auth-global-rewrite-plan.md @@ -0,0 +1,158 @@ +# Auth Global Rewrite Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将 Flutter 客户端 Auth 重构为全局单一状态源,解决 401 后会话不一致、页面卡死和设备行为分裂问题。 + +**Architecture:** 以 `AuthBloc` 为唯一认证真相源,`ApiInterceptor` 仅负责协议层刷新与失败信号上抛。401 刷新失败后通过统一回调触发 `AuthSessionInvalidated`,由 `AuthBloc` 执行本地会话失效与状态切换,Router 仅根据 Auth 状态跳转。 + +**Tech Stack:** Flutter, flutter_bloc, dio, flutter_secure_storage, flutter_test, mocktail, bloc_test + +--- + +### Task 1: 定义 Auth 失效语义与事件模型 + +**Files:** +- Modify: `apps/lib/features/auth/presentation/bloc/auth_event.dart` +- Modify: `apps/lib/features/auth/presentation/bloc/auth_state.dart` +- Test: `apps/test/features/auth/presentation/bloc/auth_bloc_test.dart` + +**Step 1: Write the failing test** + +新增失败测试:收到 session invalidated 事件后,状态应进入 `AuthUnauthenticated(expired)`。 + +**Step 2: Run test to verify it fails** + +Run: `flutter test test/features/auth/presentation/bloc/auth_bloc_test.dart` +Expected: FAIL(事件/状态原因不存在) + +**Step 3: Write minimal implementation** + +新增失效来源枚举、失效事件、未登录原因字段。 + +**Step 4: Run test to verify it passes** + +Run: `flutter test test/features/auth/presentation/bloc/auth_bloc_test.dart` +Expected: PASS + +### Task 2: 重写 AuthBloc 启动恢复与失效收敛逻辑 + +**Files:** +- Modify: `apps/lib/features/auth/presentation/bloc/auth_bloc.dart` +- Modify: `apps/lib/features/auth/data/auth_repository.dart` +- Modify: `apps/lib/features/auth/data/auth_repository_impl.dart` +- Test: `apps/test/features/auth/presentation/bloc/auth_bloc_test.dart` +- Test: `apps/test/features/auth/data/auth_repository_test.dart` + +**Step 1: Write failing tests** + +- 启动读取 refresh token 抛异常 -> `AuthUnauthenticated(startupRecoveryFailed)` +- 自动过期登出只清本地不调后端 + +**Step 2: Run tests to verify failure** + +Run: `flutter test test/features/auth/presentation/bloc/auth_bloc_test.dart test/features/auth/data/auth_repository_test.dart` +Expected: FAIL + +**Step 3: Implement minimal code** + +- `AuthBloc._onStarted` 增加异常兜底 +- `AuthRepository` 新增 `clearSessionLocalOnly()` +- `AuthBloc` 处理 `AuthSessionInvalidated` + +**Step 4: Run tests to verify pass** + +Run: `flutter test test/features/auth/presentation/bloc/auth_bloc_test.dart test/features/auth/data/auth_repository_test.dart` +Expected: PASS + +### Task 3: 改造 ApiInterceptor / ApiClient 全局失效回调链 + +**Files:** +- Modify: `apps/lib/core/api/api_interceptor.dart` +- Modify: `apps/lib/core/api/api_client.dart` +- Modify: `apps/lib/core/di/injection.dart` +- Test: `apps/test/core/api/api_interceptor_test.dart` + +**Step 1: Write failing test** + +并发 401 + refresh 失败时,`onAuthFailure` 仅触发一次。 + +**Step 2: Run test to verify it fails** + +Run: `flutter test test/core/api/api_interceptor_test.dart` +Expected: FAIL + +**Step 3: Implement minimal code** + +- interceptor 新增 auth failure singleflight +- api client 新增 `setAuthFailureCallback` +- DI 中将回调绑定到 `AuthBloc(AuthSessionInvalidated)` + +**Step 4: Run test to verify pass** + +Run: `flutter test test/core/api/api_interceptor_test.dart` +Expected: PASS + +### Task 4: 平台安全存储配置与稳定性增强 + +**Files:** +- Modify: `apps/lib/core/di/injection.dart` + +**Step 1: Add platform options** + +为 `FlutterSecureStorage` 显式设置 Android/iOS 选项,减少机型差异。 + +**Step 2: Run targeted tests/analyze** + +Run: `flutter analyze lib/core/di/injection.dart` +Expected: PASS + +### Task 5: 路由与使用点适配 + +**Files:** +- Modify: `apps/lib/core/router/app_router.dart` +- Modify: `apps/lib/features/settings/ui/screens/account_screen.dart` +- Modify: `apps/lib/features/settings/ui/screens/change_password_screen.dart` + +**Step 1: Update route/auth checks** + +兼容 `AuthUnauthenticated(reason)` 新结构,保持原有登录流 UX。 + +**Step 2: Run focused tests** + +Run: `flutter test test/features/auth` +Expected: PASS + +### Task 6: 增加 Auth 全局强约束 + +**Files:** +- Modify: `apps/AGENTS.md` + +**Step 1: Add mandatory auth rules** + +新增“Auth 全局模块(MUST)”章节: +- 401 只允许走统一失效回调链 +- 禁止 feature 私自清 token/私自跳登录 +- Auth 状态只能由全局模块写入 + +**Step 2: Verify docs consistency** + +Run: `git diff -- apps/AGENTS.md` +Expected: 仅新增约束,不改现有视觉/UI强规则 + +### Task 7: 全量验证 + +**Files:** +- Modify if needed after fixes + +**Step 1: Run test suites** + +Run: `flutter test test/core/api/api_interceptor_test.dart test/features/auth` + +**Step 2: Run analyze on touched auth scope** + +Run: `flutter analyze lib/core/api lib/features/auth lib/core/router/app_router.dart lib/core/di/injection.dart` + +**Step 3: Report residual risks** + +输出剩余风险、可观测性建议、生产灰度建议。 diff --git a/docs/protocols/ui/data-flow.md b/docs/protocols/ui/data-flow.md index 4aba67f..81a9fe2 100644 --- a/docs/protocols/ui/data-flow.md +++ b/docs/protocols/ui/data-flow.md @@ -119,3 +119,29 @@ tool 结果不再走 UI 编译链路:`TOOL_CALL_RESULT` 提供 `tool_call_args 2. 再接入 `/events` 处理后续增量 3. 以 `runId` + `messageId/toolCallId` 做去重与合并 4. 统一消费 `ui_schema` + +--- + +## 7) Navigation Action 数据流(ui_schema.actions) + +### 7.1 后端生成 + +- runtime 使用 `ui_hints.action.type = navigation` 产出导航动作。 +- 编译后在 `ui_schema` 中保持 `action.type = navigation`、`action.path`、`action.params`。 +- 路由来源应受后端静态路由目录约束: + - `backend/src/core/config/static/route/frontend_routes.yaml` + +### 7.2 前端消费(统一解析规则) + +- 对 `type = navigation`,前端仅走一条解析路径: + 1. 读取 `path` 作为内部路由目标; + 2. 将 `params` 仅视为 query 参数(不用于 path 模板替换); + 3. 执行 GoRouter 跳转(建议 `context.go(...)`)。 +- `path` 必须是已落地页面路由,且应是已实参化路径(如 `/todo/123`,而非 `/todo/:id`)。 + +### 7.3 约束建议 + +- 为了让前端只保留一种解析逻辑,推荐强约束: + - `path` 只接受内部路由; + - `params` 只接受标量值(string/number/boolean); + - 禁止在 `params` 里放嵌套对象数组。 diff --git a/docs/protocols/ui/ui-schema.md b/docs/protocols/ui/ui-schema.md index 61357a3..a9e541c 100644 --- a/docs/protocols/ui/ui-schema.md +++ b/docs/protocols/ui/ui-schema.md @@ -282,6 +282,15 @@ interface NavigateAction { params?: Record; } +// Navigation Contract (current implementation constraint) +// 1) path MUST be an internal app route and MUST be fully materialized +// (e.g. '/todo/123', not '/todo/:id'). +// 2) path MUST NOT include query string or fragment. +// 3) params, when provided, is treated as query params only. +// 4) params values MUST be scalar (string | number | boolean). +// 5) Backend MUST generate path from route catalog +// `backend/src/core/config/static/route/frontend_routes.yaml`. + // URL action interface UrlAction { type: 'url';