feat: 实现 Auth 全局状态机与 401 统一处理机制

- 新增 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 失效路径
This commit is contained in:
qzl
2026-03-18 13:35:25 +08:00
parent 19981964fb
commit b34697660d
56 changed files with 2602 additions and 784 deletions
+34
View File
@@ -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. - **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. - 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) ## 3) Layout Mapping & Alignment (MUST)
- **MUST** explicitly set `crossAxisAlignment` for every `Row` / `Column` (do not rely on defaults). - **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? - Does the screen feel calm and premium?
- Is the assistant identity visually present? - Is the assistant identity visually present?
- Would this look plausible in a polished shipping app? - 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.
+15 -1
View File
@@ -15,7 +15,16 @@ class ApiClient implements IApiClient {
required TokenStorage tokenStorage, required TokenStorage tokenStorage,
Dio? dio, 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( final interceptor = ApiInterceptor(
tokenStorage: tokenStorage, tokenStorage: tokenStorage,
dio: effectiveDio, dio: effectiveDio,
@@ -50,6 +59,11 @@ class ApiClient implements IApiClient {
}; };
} }
void setAuthFailureCallback(Future<void> Function() onAuthFailure) {
_interceptor.onAuthFailure = onAuthFailure;
}
@override
Future<Response<T>> get<T>(String path, {Options? options}) async { Future<Response<T>> get<T>(String path, {Options? options}) async {
try { try {
return await _dio.get<T>(path, options: options); return await _dio.get<T>(path, options: options);
+32
View File
@@ -6,7 +6,9 @@ class ApiInterceptor extends Interceptor {
final Dio dio; final Dio dio;
final Duration refreshFailureCooldown; final Duration refreshFailureCooldown;
Future<bool> Function()? onTokenRefresh; Future<bool> Function()? onTokenRefresh;
Future<void> Function()? onAuthFailure;
Future<bool>? _refreshFuture; Future<bool>? _refreshFuture;
Future<void>? _authFailureFuture;
DateTime? _refreshBlockedUntil; DateTime? _refreshBlockedUntil;
static const _retriedRequestKey = '_auth_retry_once'; static const _retriedRequestKey = '_auth_retry_once';
@@ -34,6 +36,10 @@ class ApiInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) async { void onError(DioException err, ErrorInterceptorHandler handler) async {
final requestOptions = err.requestOptions; final requestOptions = err.requestOptions;
final isUnauthorized = err.response?.statusCode == 401;
final shouldHandleUnauthorized =
isUnauthorized && _isAuthenticatedRequest(requestOptions);
if (err.response?.statusCode == 401 && if (err.response?.statusCode == 401 &&
onTokenRefresh != null && onTokenRefresh != null &&
!_shouldSkipRefresh(requestOptions)) { !_shouldSkipRefresh(requestOptions)) {
@@ -57,11 +63,36 @@ class ApiInterceptor extends Interceptor {
// Retry failed, proceed with original error. // Retry failed, proceed with original error.
} }
} }
} else if (shouldHandleUnauthorized) {
await _notifyAuthFailureSingleflight();
} }
} else if (shouldHandleUnauthorized && _shouldSkipRefresh(requestOptions)) {
await _notifyAuthFailureSingleflight();
} }
handler.next(err); handler.next(err);
} }
bool _isAuthenticatedRequest(RequestOptions options) {
return options.headers['Authorization'] != null;
}
Future<void> _notifyAuthFailureSingleflight() {
final existing = _authFailureFuture;
if (existing != null) {
return existing;
}
final callback = onAuthFailure;
if (callback == null) {
return Future<void>.value();
}
final future = callback().whenComplete(() {
_authFailureFuture = null;
});
_authFailureFuture = future;
return future;
}
bool _shouldSkipRefresh(RequestOptions options) { bool _shouldSkipRefresh(RequestOptions options) {
final blockedUntil = _refreshBlockedUntil; final blockedUntil = _refreshBlockedUntil;
if (blockedUntil != null && DateTime.now().isBefore(blockedUntil)) { if (blockedUntil != null && DateTime.now().isBefore(blockedUntil)) {
@@ -101,6 +132,7 @@ class ApiInterceptor extends Interceptor {
void reset() { void reset() {
_refreshFuture = null; _refreshFuture = null;
_authFailureFuture = null;
_refreshBlockedUntil = null; _refreshBlockedUntil = null;
} }
} }
-8
View File
@@ -11,12 +11,4 @@ class Env {
} }
return 'http://localhost:5775'; return 'http://localhost:5775';
} }
static bool get isMockApi {
final fromDefine = const String.fromEnvironment('MOCK_API');
if (fromDefine.isNotEmpty) {
return fromDefine == 'true';
}
return false;
}
} }
+22 -6
View File
@@ -10,6 +10,7 @@ import '../../features/auth/data/auth_api.dart';
import '../../features/auth/data/auth_repository.dart'; import '../../features/auth/data/auth_repository.dart';
import '../../features/auth/data/auth_repository_impl.dart'; import '../../features/auth/data/auth_repository_impl.dart';
import '../../features/auth/presentation/bloc/auth_bloc.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/calendar_api.dart';
import '../../features/calendar/data/services/calendar_service.dart'; import '../../features/calendar/data/services/calendar_service.dart';
import '../../features/calendar/ui/calendar_state_manager.dart'; import '../../features/calendar/ui/calendar_state_manager.dart';
@@ -26,12 +27,18 @@ Future<void> configureDependencies() async {
await sl.reset(); await sl.reset();
} }
final IApiClient apiClient;
final SecureTokenStorage tokenStorage; final SecureTokenStorage tokenStorage;
final dio = Dio(BaseOptions(baseUrl: Env.apiUrl)); final dio = Dio(BaseOptions(baseUrl: Env.apiUrl));
tokenStorage = SecureTokenStorage(const FlutterSecureStorage()); tokenStorage = SecureTokenStorage(
apiClient = ApiClient( const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
),
);
final apiClient = ApiClient(
baseUrl: Env.apiUrl, baseUrl: Env.apiUrl,
tokenStorage: tokenStorage, tokenStorage: tokenStorage,
dio: dio, dio: dio,
@@ -69,12 +76,15 @@ Future<void> configureDependencies() async {
api: authApi, api: authApi,
tokenStorage: tokenStorage, tokenStorage: tokenStorage,
onLogout: () async { onLogout: () async {
(apiClient as ApiClient).resetInterceptor(); apiClient.resetInterceptor();
}, },
); );
sl.registerSingleton<AuthRepository>(authRepository); sl.registerSingleton<AuthRepository>(authRepository);
(apiClient as ApiClient).setRefreshCallback((token) async { final authBloc = AuthBloc(authRepository);
sl.registerSingleton<AuthBloc>(authBloc);
apiClient.setRefreshCallback((token) async {
try { try {
await authRepository.refreshSession(token); await authRepository.refreshSession(token);
return true; return true;
@@ -83,6 +93,12 @@ Future<void> configureDependencies() async {
} }
}); });
sl.registerSingleton<AuthBloc>(AuthBloc(authRepository)); apiClient.setAuthFailureCallback(() async {
authBloc.add(
const AuthSessionInvalidated(
source: AuthInvalidationSource.unauthorized401,
),
);
});
sl.registerSingleton<CalendarStateManager>(CalendarStateManager()); sl.registerSingleton<CalendarStateManager>(CalendarStateManager());
} }
@@ -11,6 +11,7 @@ abstract class AuthRepository {
Future<AuthResponse> createSession(LoginRequest request); Future<AuthResponse> createSession(LoginRequest request);
Future<AuthResponse> refreshSession(String refreshToken); Future<AuthResponse> refreshSession(String refreshToken);
Future<void> deleteSession(); Future<void> deleteSession();
Future<void> clearSessionLocalOnly();
Future<String?> getAccessToken(); Future<String?> getAccessToken();
Future<String?> getRefreshToken(); Future<String?> getRefreshToken();
Future<bool> isAuthenticated(); Future<bool> isAuthenticated();
@@ -64,9 +64,6 @@ class AuthRepositoryImpl implements AuthRepository {
@override @override
Future<void> deleteSession() async { Future<void> deleteSession() async {
if (_onLogout != null) {
await _onLogout!();
}
final refreshToken = await _tokenStorage.getRefreshToken(); final refreshToken = await _tokenStorage.getRefreshToken();
if (refreshToken != null) { if (refreshToken != null) {
try { try {
@@ -75,6 +72,14 @@ class AuthRepositoryImpl implements AuthRepository {
// ignore API errors during logout // ignore API errors during logout
} }
} }
await clearSessionLocalOnly();
}
@override
Future<void> clearSessionLocalOnly() async {
if (_onLogout != null) {
await _onLogout();
}
await _tokenStorage.clear(); await _tokenStorage.clear();
} }
@@ -10,13 +10,14 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
on<AuthStarted>(_onStarted); on<AuthStarted>(_onStarted);
on<AuthLoggedIn>(_onLoggedIn); on<AuthLoggedIn>(_onLoggedIn);
on<AuthLoggedOut>(_onLoggedOut); on<AuthLoggedOut>(_onLoggedOut);
on<AuthSessionInvalidated>(_onSessionInvalidated);
} }
Future<void> _onStarted(AuthStarted event, Emitter<AuthState> emit) async { Future<void> _onStarted(AuthStarted event, Emitter<AuthState> emit) async {
emit(AuthLoading()); emit(AuthLoading());
try {
final refreshToken = await _repository.getRefreshToken(); final refreshToken = await _repository.getRefreshToken();
if (refreshToken != null) { if (refreshToken != null) {
try {
final response = await _repository.refreshSession(refreshToken); final response = await _repository.refreshSession(refreshToken);
emit( emit(
AuthAuthenticated( AuthAuthenticated(
@@ -24,11 +25,23 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
), ),
); );
return; return;
}
emit(
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
);
} catch (_) { } catch (_) {
await _repository.deleteSession(); try {
await _repository.clearSessionLocalOnly();
} catch (_) {
// Keep state convergence even when storage cleanup fails.
} finally {
emit(
const AuthUnauthenticated(
reason: AuthUnauthenticatedReason.startupRecoveryFailed,
),
);
} }
} }
emit(AuthUnauthenticated());
} }
void _onLoggedIn(AuthLoggedIn event, Emitter<AuthState> emit) { void _onLoggedIn(AuthLoggedIn event, Emitter<AuthState> emit) {
@@ -39,7 +52,29 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthLoggedOut event, AuthLoggedOut event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
try {
await _repository.deleteSession(); await _repository.deleteSession();
emit(AuthUnauthenticated()); } catch (_) {
// Keep state convergence even when logout cleanup fails.
} finally {
emit(
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
);
}
}
Future<void> _onSessionInvalidated(
AuthSessionInvalidated event,
Emitter<AuthState> emit,
) async {
try {
await _repository.clearSessionLocalOnly();
} catch (_) {
// Keep state convergence even when local cleanup fails.
} finally {
emit(
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired),
);
}
} }
} }
@@ -1,6 +1,8 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../data/models/auth_response.dart'; import '../../data/models/auth_response.dart';
enum AuthInvalidationSource { unauthorized401 }
abstract class AuthEvent extends Equatable { abstract class AuthEvent extends Equatable {
const AuthEvent(); const AuthEvent();
@@ -20,3 +22,12 @@ class AuthLoggedIn extends AuthEvent {
} }
class AuthLoggedOut extends AuthEvent {} class AuthLoggedOut extends AuthEvent {}
class AuthSessionInvalidated extends AuthEvent {
final AuthInvalidationSource source;
const AuthSessionInvalidated({required this.source});
@override
List<Object?> get props => [source];
}
@@ -14,6 +14,8 @@ class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {} class AuthLoading extends AuthState {}
enum AuthUnauthenticatedReason { signedOut, expired, startupRecoveryFailed }
class AuthAuthenticated extends AuthState { class AuthAuthenticated extends AuthState {
final AuthUser user; final AuthUser user;
@@ -23,4 +25,13 @@ class AuthAuthenticated extends AuthState {
List<Object?> get props => [user]; List<Object?> get props => [user];
} }
class AuthUnauthenticated extends AuthState {} class AuthUnauthenticated extends AuthState {
final AuthUnauthenticatedReason reason;
const AuthUnauthenticated({
this.reason = AuthUnauthenticatedReason.signedOut,
});
@override
List<Object?> get props => [reason];
}
@@ -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<DayEventLayout> layout({
required List<ScheduleItemModel> 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>>[];
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 = <DayEventLayout>[];
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;
}
@@ -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();
}
}
@@ -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);
}
}
@@ -1,15 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/di/injection.dart'; import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart'; import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_pressable.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_state_manager.dart';
import '../calendar_time_utils.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/bottom_dock.dart';
import '../widgets/create_event_sheet.dart'; import '../widgets/create_event_sheet.dart';
import '../../data/services/calendar_service.dart';
import '../../data/models/schedule_item_model.dart';
class CalendarDayWeekScreen extends StatefulWidget { class CalendarDayWeekScreen extends StatefulWidget {
final DateTime? initialDate; final DateTime? initialDate;
@@ -29,20 +33,20 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
with WidgetsBindingObserver { with WidgetsBindingObserver {
static const double _dayItemWidth = 44; static const double _dayItemWidth = 44;
static const double _dayItemGap = 12; static const double _dayItemGap = 12;
static const double _eventLeftOffset = 52; static const double _minEventTapHeight = 32;
static const double _defaultHourHeight = 34.0; static const List<String> _dayNames = ['', '', '', '', '', '', ''];
static const double _minHourHeight = 17.0;
static const double _maxHourHeight = 68.0;
double _hourHeight = _defaultHourHeight; final DayEventLayoutEngine _layoutEngine = const DayEventLayoutEngine();
final Map<int, Offset> _activePointers = {}; final Map<int, Offset> _activePointers = {};
final ScrollController _dayStripController = ScrollController();
DayViewScale _scale = DayViewScale.defaultScale();
DayViewScale _pinchStartScale = DayViewScale.defaultScale();
double? _pinchStartDistance; double? _pinchStartDistance;
double _pinchStartHourHeight = _defaultHourHeight;
late final CalendarStateManager _calendarManager; late final CalendarStateManager _calendarManager;
late DateTime _selectedDate; late DateTime _selectedDate;
late List<DateTime> _monthDates; late List<DateTime> _monthDates;
final ScrollController _dayStripController = ScrollController();
List<ScheduleItemModel> _events = const []; List<ScheduleItemModel> _events = const [];
@override @override
@@ -55,7 +59,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
_calendarManager.resetToToday(); _calendarManager.resetToToday();
} }
_selectedDate = _calendarManager.selectedDate; _selectedDate = widget.initialDate ?? _calendarManager.selectedDate;
_updateMonthDates(); _updateMonthDates();
_loadEvents(); _loadEvents();
@@ -159,7 +163,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
final today = DateTime.now(); final today = DateTime.now();
setState(() { setState(() {
_selectedDate = today; _selectedDate = today;
_hourHeight = _defaultHourHeight; _scale = DayViewScale.defaultScale();
}); });
_calendarManager.setSelectedDate(today); _calendarManager.setSelectedDate(today);
_updateMonthDates(); _updateMonthDates();
@@ -172,7 +176,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
if (_activePointers.length == 2) { if (_activePointers.length == 2) {
final pointers = _activePointers.values.toList(growable: false); final pointers = _activePointers.values.toList(growable: false);
_pinchStartDistance = (pointers[0] - pointers[1]).distance; _pinchStartDistance = (pointers[0] - pointers[1]).distance;
_pinchStartHourHeight = _hourHeight; _pinchStartScale = _scale;
} }
} }
@@ -192,28 +196,27 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
return; return;
} }
final nextHeight = final nextScale = _pinchStartScale.zoomByFactor(
(_pinchStartHourHeight * (currentDistance / startDistance)).clamp( currentDistance / startDistance,
_minHourHeight,
_maxHourHeight,
); );
if ((nextHeight - _hourHeight).abs() < 0.1) { if ((nextScale.hourHeight - _scale.hourHeight).abs() < 0.1) {
return; return;
} }
setState(() { setState(() {
_hourHeight = nextHeight; _scale = nextScale;
}); });
} }
void _handlePointerUp(PointerUpEvent event) { void _handlePointerUp(PointerUpEvent event) {
_activePointers.remove(event.pointer); _handlePointerRemove(event.pointer);
if (_activePointers.length < 2) {
_pinchStartDistance = null;
}
} }
void _handlePointerCancel(PointerCancelEvent event) { void _handlePointerCancel(PointerCancelEvent event) {
_activePointers.remove(event.pointer); _handlePointerRemove(event.pointer);
}
void _handlePointerRemove(int pointer) {
_activePointers.remove(pointer);
if (_activePointers.length < 2) { if (_activePointers.length < 2) {
_pinchStartDistance = null; _pinchStartDistance = null;
} }
@@ -221,6 +224,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
Widget _buildHeader() { Widget _buildHeader() {
final monthLabel = '${_selectedDate.year}${_selectedDate.month}'; final monthLabel = '${_selectedDate.year}${_selectedDate.month}';
final isNotToday = !isSameDay(_selectedDate, DateTime.now());
return SizedBox( return SizedBox(
height: 68, height: 68,
@@ -281,7 +285,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
), ),
), ),
const Spacer(), const Spacer(),
if (!isSameDay(_selectedDate, DateTime.now())) if (isNotToday)
AppPressable( AppPressable(
borderRadius: BorderRadius.circular(AppRadius.xl), borderRadius: BorderRadius.circular(AppRadius.xl),
onTap: _goToToday, onTap: _goToToday,
@@ -305,8 +309,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
), ),
), ),
), ),
if (!isSameDay(_selectedDate, DateTime.now())) if (isNotToday) const SizedBox(width: 8),
const SizedBox(width: 8),
AppPressable( AppPressable(
borderRadius: BorderRadius.circular(AppRadius.full), borderRadius: BorderRadius.circular(AppRadius.full),
onTap: () => CreateEventSheet.show( onTap: () => CreateEventSheet.show(
@@ -413,14 +416,12 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
} }
Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) { Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) {
final dayNames = ['', '', '', '', '', '', ''];
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
dayNames[date.weekday % 7], _dayNames[date.weekday % 7],
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: isWeekend ? AppColors.slate400 : AppColors.slate600, color: isWeekend ? AppColors.slate400 : AppColors.slate600,
@@ -456,110 +457,203 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
Widget _buildTimelineBoard() { Widget _buildTimelineBoard() {
final now = DateTime.now(); final now = DateTime.now();
final showCurrent = shouldShowCurrentMarker(_selectedDate, 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);
final layouts = _layoutEngine.layout(
events: _events,
scale: _scale,
eventAreaLeft: eventAreaLeft,
eventAreaWidth: eventAreaWidth,
);
return SizedBox( return SizedBox(
height: boardHeight,
child: Stack(
children: [
RepaintBoundary(
child: _buildTimelineGrid(
boardHeight: boardHeight,
eventAreaLeft: eventAreaLeft,
),
),
if (showCurrent)
_buildCurrentTimeMarker(now: now, boardHeight: boardHeight),
RepaintBoundary(
child: Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Column( for (final layout in layouts)
children: [ _buildEventCard(layout: layout, boardHeight: boardHeight),
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),
], ],
), ),
..._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<int> _calculateEventColumns(List<ScheduleItemModel> events) { Widget _buildHourTick({
if (events.isEmpty) return []; 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<int>.filled(events.length, -1); return Stack(
final columnHeights = <int, int>{}; children: [
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;
}
List<Widget> _buildPositionedEvents(
List<ScheduleItemModel> events,
List<int> columns,
) {
if (events.isEmpty) return [];
final maxColumn = columns.reduce((a, b) => a > b ? a : b) + 1;
final eventWidgets = <Widget>[];
for (var i = 0; i < events.length; i++) {
final event = events[i];
final column = columns[i];
final eventColor = _parseColor(event.metadata?.color);
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( Positioned(
top: top, top: y,
left: left, left: eventAreaLeft,
right: maxColumn > 1 ? null : 16, right: 0,
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}');
},
child: Container( child: Container(
margin: const EdgeInsets.only(right: 4), height: 1,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 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,
),
),
),
],
);
}
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);
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),
),
),
),
],
),
),
),
);
}
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;
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: DayTimelineMetrics.eventColumnGap,
),
padding: isCompact
? const EdgeInsets.symmetric(horizontal: 4, vertical: 2)
: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: eventColor.withValues(alpha: 0.2), color: eventColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all(color: eventColor, width: 1), border: Border.all(color: eventColor, width: 1),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Container( Container(
width: 6, width: 6,
@@ -569,10 +663,11 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
), ),
const SizedBox(width: 4), if (!isCompact) const SizedBox(width: 4),
if (!isCompact)
Expanded( Expanded(
child: Text( child: Text(
event.title, layout.event.title,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -586,16 +681,16 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
), ),
), ),
), ),
],
), ),
), ),
); );
} }
return eventWidgets;
}
Color _parseColor(String? hex) { Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return AppColors.blue600; if (hex == null || hex.isEmpty) {
return AppColors.blue600;
}
try { try {
return Color(int.parse(hex.replaceFirst('#', '0xFF'))); return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) { } catch (_) {
@@ -603,74 +698,12 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
} }
} }
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() { Widget _buildBottomDock() {
return BottomDock( return BottomDock(
activeTab: DockTab.calendar, activeTab: DockTab.calendar,
onTodoTap: () { onTodoTap: () {
_calendarManager.setViewType(CalendarViewType.day); _calendarManager.setViewType(CalendarViewType.day);
context.push('/todo'); context.go('/todo');
}, },
onCalendarTap: () { onCalendarTap: () {
_calendarManager.setViewType(CalendarViewType.day); _calendarManager.setViewType(CalendarViewType.day);
@@ -5,12 +5,16 @@ import '../../../../core/di/injection.dart';
import '../../../../core/notifications/local_notification_service.dart'; import '../../../../core/notifications/local_notification_service.dart';
import '../../../../core/theme/design_tokens.dart'; import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.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/services/calendar_service.dart';
import '../../data/models/schedule_item_model.dart'; import '../../data/models/schedule_item_model.dart';
import '../widgets/create_event_sheet.dart'; import '../widgets/create_event_sheet.dart';
import '../widgets/calendar_share_dialog.dart'; import '../widgets/calendar_share_dialog.dart';
enum _CalendarHeaderAction { edit, delete, share }
class CalendarEventDetailScreen extends StatefulWidget { class CalendarEventDetailScreen extends StatefulWidget {
final String eventId; final String eventId;
@@ -95,8 +99,9 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
backgroundColor: const Color(0xFFF8FAFC), backgroundColor: const Color(0xFFF8FAFC),
body: SafeArea( body: SafeArea(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildHeader(context), _buildHeader(event),
Expanded(child: _buildDetailOverlay(event)), Expanded(child: _buildDetailOverlay(event)),
_buildInputContainer(), _buildInputContainer(),
], ],
@@ -105,19 +110,81 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
); );
} }
Widget _buildHeader(BuildContext context) { Widget _buildHeader(ScheduleItemModel event) {
return SizedBox( return BackTitlePageHeader(
height: 64, title: '日程详情',
child: Padding( onBack: () => context.pop(),
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 8), trailing: _buildHeaderActions(event),
child: Row( );
children: [ }
widgets.BackButton(onPressed: () => Navigator.of(context).pop()),
], Widget _buildHeaderActions(ScheduleItemModel event) {
), final items = <DetailHeaderActionItem<_CalendarHeaderAction>>[];
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) { Widget _buildDetailOverlay(ScheduleItemModel event) {
final startAt = event.startAt; final startAt = event.startAt;
@@ -198,10 +265,11 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
Widget _buildTitleRow(ScheduleItemModel event) { Widget _buildTitleRow(ScheduleItemModel event) {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Container( Container(
width: 4, width: 4,
@@ -226,114 +294,28 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
], ],
), ),
), ),
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({ Future<void> _showDeleteConfirmation() async {
required VoidCallback onTap, final confirmed = await showDestructiveActionSheet(
required IconData icon, context,
required Color iconColor, title: '删除日程',
required Color backgroundColor, message: '确定要删除这个日程吗?',
required Color borderColor, confirmText: '确认删除',
}) {
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,
),
),
); );
} if (!confirmed) {
return;
void _showDeleteConfirmation() { }
showDialog( await sl<CalendarService>().deleteEvent(widget.eventId);
context: context, try {
builder: (context) => AlertDialog( await sl<LocalNotificationService>().cancelEventReminder(widget.eventId);
title: const Text('删除日程'), } catch (_) {}
content: const Text('确定要删除这个日程吗?'), if (!mounted) {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () async {
await sl<CalendarService>().deleteEvent(widget.eventId);
try {
await sl<LocalNotificationService>().cancelEventReminder(
widget.eventId,
);
} catch (_) {}
if (!context.mounted) {
return; return;
} }
Navigator.pop(context);
context.pop(); context.pop();
},
child: Text('删除', style: TextStyle(color: AppColors.red500)),
),
],
),
);
} }
Widget _buildDetailField(String label, String value) { Widget _buildDetailField(String label, String value) {
@@ -522,7 +522,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
activeTab: DockTab.calendar, activeTab: DockTab.calendar,
onTodoTap: () { onTodoTap: () {
_calendarManager.setViewType(CalendarViewType.month); _calendarManager.setViewType(CalendarViewType.month);
context.push('/todo'); context.go('/todo');
}, },
onCalendarTap: () {}, onCalendarTap: () {},
onHomeTap: () => context.go('/home'), onHomeTap: () => context.go('/home'),
@@ -57,6 +57,17 @@ class CreateEventSheet extends StatefulWidget {
class _CreateEventSheetState extends State<CreateEventSheet> class _CreateEventSheetState extends State<CreateEventSheet>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
static const List<int?> _defaultReminderOptions = [
null,
0,
5,
10,
15,
30,
60,
120,
];
late TabController _tabController; late TabController _tabController;
final _titleController = TextEditingController(); final _titleController = TextEditingController();
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
@@ -89,7 +100,9 @@ class _CreateEventSheetState extends State<CreateEventSheet>
_endDate = event.endAt; _endDate = event.endAt;
_endTime = event.endAt; _endTime = event.endAt;
_selectedColor = event.metadata?.color ?? '#3B82F6'; _selectedColor = event.metadata?.color ?? '#3B82F6';
_reminderMinutes = event.metadata?.reminderMinutes ?? 15; _reminderMinutes = _sanitizeReminderMinutes(
event.metadata?.reminderMinutes,
);
} else { } else {
final now = final now =
widget.initialDate ?? _roundToNearestMinute(DateTime.now(), 5); widget.initialDate ?? _roundToNearestMinute(DateTime.now(), 5);
@@ -512,7 +525,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
} }
Widget _buildReminderPicker() { Widget _buildReminderPicker() {
const options = <int?>[null, 0, 5, 10, 15, 30, 60, 120]; final options = _buildReminderOptions();
String labelOf(int? value) { String labelOf(int? value) {
if (value == null) { if (value == null) {
return '无提醒'; return '无提醒';
@@ -573,6 +586,24 @@ class _CreateEventSheetState extends State<CreateEventSheet>
); );
} }
int? _sanitizeReminderMinutes(int? minutes) {
if (minutes == null || minutes < 0) {
return null;
}
return minutes;
}
List<int?> _buildReminderOptions() {
final current = _sanitizeReminderMinutes(_reminderMinutes);
final nonNull = _defaultReminderOptions.whereType<int>().toSet();
if (current != null) {
nonNull.add(current);
}
final sorted = nonNull.toList()..sort();
return [null, ...sorted];
}
Future<void> _saveEvent() async { Future<void> _saveEvent() async {
if (_titleController.text.trim().isEmpty || _saving) return; if (_titleController.text.trim().isEmpty || _saving) return;
setState(() { setState(() {
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; 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/core/theme/design_tokens.dart';
import 'package:social_app/shared/widgets/toast/toast.dart'; import 'package:social_app/shared/widgets/toast/toast.dart';
@@ -168,12 +169,7 @@ class UiSchemaRenderer {
onPressed: disabled onPressed: disabled
? null ? null
: () { : () {
final actionType = _asString(action?['type']); _handleAction(context, action);
if (actionType == 'copy') {
Toast.show(context, '已复制', type: ToastType.success);
} else {
Toast.show(context, '该操作暂未接入', type: ToastType.info);
}
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
elevation: 0, elevation: 0,
@@ -203,6 +199,85 @@ class UiSchemaRenderer {
); );
} }
static void _handleAction(
BuildContext context,
Map<String, dynamic>? 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<String, dynamic>? 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<String, String> _extractNavigationQueryParams(
Map<String, dynamic>? params,
) {
if (params == null || params.isEmpty) {
return const {};
}
final query = <String, String>{};
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<String, dynamic> node) { static Widget _renderKv(Map<String, dynamic> node) {
final items = _asList( final items = _asList(
node['items'], node['items'],
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.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/app_input.dart';
import '../../../../shared/widgets/link_button.dart'; import '../../../../shared/widgets/link_button.dart';
import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast.dart';
@@ -37,15 +37,18 @@ class _AddContactScreenState extends State<AddContactScreen> {
backgroundColor: AppColors.surfaceSecondary, backgroundColor: AppColors.surfaceSecondary,
body: SafeArea( body: SafeArea(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
widgets.PageHeader( BackTitlePageHeader(
leading: widgets.BackButton(), title: isEditing ? '编辑联系人' : '添加联系人',
onBack: () => context.pop(),
trailing: _buildConfirmButton(), trailing: _buildConfirmButton(),
), ),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildAvatarSection(), _buildAvatarSection(),
const SizedBox(height: 14), const SizedBox(height: 14),
@@ -5,7 +5,7 @@ import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/toast/index.dart'; import '../../../../shared/widgets/toast/index.dart';
import '../../../../shared/widgets/app_button.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 '../../../friends/data/friends_api.dart';
import '../../../users/data/models/user_response.dart'; import '../../../users/data/models/user_response.dart';
import '../../../users/data/users_api.dart'; import '../../../users/data/users_api.dart';
@@ -267,8 +267,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
backgroundColor: AppColors.surfaceSecondary, backgroundColor: AppColors.surfaceSecondary,
body: SafeArea( body: SafeArea(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
widgets.PageHeader(leading: widgets.BackButton()), BackTitlePageHeader(title: '联系人', onBack: () => context.pop()),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
@@ -10,7 +10,7 @@ import '../../../auth/presentation/bloc/auth_event.dart';
import '../../../auth/presentation/bloc/auth_state.dart'; import '../../../auth/presentation/bloc/auth_state.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../widgets/account_section_card.dart'; import '../widgets/account_section_card.dart';
import '../widgets/account_surface_scaffold.dart'; import '../widgets/settings_page_scaffold.dart';
class AccountScreen extends StatelessWidget { class AccountScreen extends StatelessWidget {
const AccountScreen({super.key}); const AccountScreen({super.key});
@@ -22,10 +22,8 @@ class AccountScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AccountSurfaceScaffold( return SettingsPageScaffold(
title: '账户', title: '账户',
subtitle: null,
compactHeaderTitle: true,
onBack: () => context.pop(), onBack: () => context.pop(),
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -13,7 +13,7 @@ import '../../../auth/presentation/bloc/auth_state.dart';
import '../../../../features/auth/presentation/cubits/reset_password_cubit.dart'; import '../../../../features/auth/presentation/cubits/reset_password_cubit.dart';
import '../../../../features/auth/data/auth_repository.dart'; import '../../../../features/auth/data/auth_repository.dart';
import '../widgets/account_section_card.dart'; import '../widgets/account_section_card.dart';
import '../widgets/account_surface_scaffold.dart'; import '../widgets/settings_page_scaffold.dart';
class ChangePasswordScreen extends StatelessWidget { class ChangePasswordScreen extends StatelessWidget {
const ChangePasswordScreen({super.key}); const ChangePasswordScreen({super.key});
@@ -95,9 +95,8 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> {
Toast.show(context, state.errorMessage!, type: ToastType.error); Toast.show(context, state.errorMessage!, type: ToastType.error);
} }
}, },
child: AccountSurfaceScaffold( child: SettingsPageScaffold(
title: '修改密码', title: '修改密码',
subtitle: '通过邮箱验证码修改密码',
onBack: () => context.pop(), onBack: () => context.pop(),
body: _buildForm(), body: _buildForm(),
footer: BlocBuilder<ResetPasswordCubit, ResetPasswordState>( footer: BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
@@ -9,7 +9,7 @@ import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../users/data/models/user_response.dart'; import '../../../users/data/models/user_response.dart';
import '../../../users/data/users_api.dart'; import '../../../users/data/users_api.dart';
import '../widgets/account_section_card.dart'; import '../widgets/account_section_card.dart';
import '../widgets/account_surface_scaffold.dart'; import '../widgets/settings_page_scaffold.dart';
class EditProfileScreen extends StatefulWidget { class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({super.key}); const EditProfileScreen({super.key});
@@ -119,9 +119,8 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AccountSurfaceScaffold( return SettingsPageScaffold(
title: '编辑资料', title: '编辑资料',
subtitle: '编辑账户资料',
onBack: () => context.pop(), onBack: () => context.pop(),
body: _isLoading body: _isLoading
? const Center( ? const Center(
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.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 { class FeaturesScreen extends StatefulWidget {
const FeaturesScreen({super.key}); const FeaturesScreen({super.key});
@@ -17,16 +19,10 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return SettingsPageScaffold(
backgroundColor: AppColors.surfaceSecondary, title: '周期计划',
body: SafeArea( onBack: () => context.pop(),
child: Column( body: Column(
children: [
const widgets.PageHeader(leading: widgets.BackButton()),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionTitle('每日'), _buildSectionTitle('每日'),
@@ -38,11 +34,6 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
_buildWeeklyList(), _buildWeeklyList(),
], ],
), ),
),
),
],
),
),
); );
} }
@@ -59,6 +50,7 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
Widget _buildDailyList() { Widget _buildDailyList() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildFeatureCard( _buildFeatureCard(
icon: Icons.alarm, icon: Icons.alarm,
@@ -87,6 +79,7 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
Widget _buildWeeklyList() { Widget _buildWeeklyList() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildFeatureCard( _buildFeatureCard(
icon: Icons.calendar_view_week, icon: Icons.calendar_view_week,
@@ -128,9 +121,10 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.white, color: AppColors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE4ECF6)), border: Border.all(color: AppColors.borderSecondary),
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Container( Container(
width: 40, width: 40,
@@ -167,40 +161,9 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
], ],
), ),
), ),
_buildToggle(value, onChanged), AppToggleSwitch(value: value, onChanged: onChanged),
], ],
), ),
); );
} }
Widget _buildToggle(bool value, ValueChanged<bool> 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)),
),
),
),
),
);
}
} }
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.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 '../../data/services/memory_service.dart';
import '../widgets/settings_page_scaffold.dart';
class MemoryScreen extends StatefulWidget { class MemoryScreen extends StatefulWidget {
const MemoryScreen({super.key}); const MemoryScreen({super.key});
@@ -23,16 +25,10 @@ class _MemoryScreenState extends State<MemoryScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return SettingsPageScaffold(
backgroundColor: AppColors.surfaceSecondary, title: '我的记忆',
body: SafeArea( onBack: () => context.pop(),
child: Column( body: 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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildToggleCard(), _buildToggleCard(),
@@ -46,11 +42,6 @@ class _MemoryScreenState extends State<MemoryScreen> {
_buildManageButton(), _buildManageButton(),
], ],
), ),
),
),
],
),
),
); );
} }
@@ -63,8 +54,10 @@ class _MemoryScreenState extends State<MemoryScreen> {
border: Border.all(color: AppColors.borderSecondary), border: Border.all(color: AppColors.borderSecondary),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text( const Text(
@@ -114,9 +107,10 @@ class _MemoryScreenState extends State<MemoryScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.white, color: AppColors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE1E8F3)), border: Border.all(color: AppColors.borderSecondary),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
for (int i = 0; i < _memoryItems.length; i++) ...[ for (int i = 0; i < _memoryItems.length; i++) ...[
_buildMemoryItem(_memoryItems[i]), _buildMemoryItem(_memoryItems[i]),
@@ -136,9 +130,10 @@ class _MemoryScreenState extends State<MemoryScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceTertiary, color: AppColors.surfaceTertiary,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE8EDF7)), border: Border.all(color: AppColors.borderSecondary),
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Container( Container(
width: 32, width: 32,
@@ -175,7 +170,11 @@ class _MemoryScreenState extends State<MemoryScreen> {
], ],
), ),
), ),
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<MemoryScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.white, color: AppColors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFDCE6F4)), border: Border.all(color: AppColors.borderSecondary),
), ),
child: const Center( child: const Center(
child: Text( child: Text(
@@ -207,33 +206,6 @@ class _MemoryScreenState extends State<MemoryScreen> {
} }
Widget _buildToggle(bool value, ValueChanged<bool> onChanged) { Widget _buildToggle(bool value, ValueChanged<bool> onChanged) {
return GestureDetector( return AppToggleSwitch(value: value, onChanged: onChanged);
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)),
),
),
),
),
);
} }
} }
@@ -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/di/injection.dart';
import 'package:social_app/core/theme/design_tokens.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/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.dart';
import 'package:social_app/shared/widgets/toast/toast_type.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/friends/data/friends_api.dart';
import 'package:social_app/features/settings/data/settings_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/models/user_response.dart';
import 'package:social_app/features/users/data/users_api.dart'; import 'package:social_app/features/users/data/users_api.dart';
import '../widgets/settings_page_scaffold.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -65,22 +65,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return SettingsPageScaffold(
backgroundColor: AppColors.surfaceSecondary, title: '设置',
body: SafeArea( onBack: () => context.pop(),
child: Column( body: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
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: [ children: [
_buildProfileHero(), _buildProfileHero(),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -91,11 +80,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildMenuCard(context), _buildMenuCard(context),
], ],
), ),
),
),
],
),
),
); );
} }
@@ -253,7 +237,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: _buildActionCard( child: _buildActionCard(
icon: Icons.auto_awesome, icon: Icons.auto_awesome,
iconColor: const Color(0xFF8B5CF6), iconColor: const Color(0xFF8B5CF6),
title: '常用功能', title: '周期计划',
subtitle: '已启用:会议提醒', subtitle: '已启用:会议提醒',
onTap: () => context.push('/settings/features'), onTap: () => context.push('/settings/features'),
), ),
@@ -459,7 +443,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
icon: Icons.system_update, icon: Icons.system_update,
title: '检查更新', title: '检查更新',
trailing: 'v${AppConstants.version}', trailing: 'v${AppConstants.version}',
onTap: () => _checkForUpdates(context), onTap: _checkForUpdates,
), ),
], ],
), ),
@@ -528,7 +512,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Future<void> _checkForUpdates(BuildContext context) async { Future<void> _checkForUpdates() async {
try { try {
final settingsApi = sl<SettingsApi>(); final settingsApi = sl<SettingsApi>();
final result = await settingsApi.checkUpdates( final result = await settingsApi.checkUpdates(
@@ -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,
),
),
],
),
),
);
}
}
@@ -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,
),
),
],
),
),
);
}
}
@@ -4,13 +4,17 @@ import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/di/injection.dart'; import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart'; import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.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/app_button.dart';
import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../calendar/data/calendar_api.dart'; import '../../../calendar/data/calendar_api.dart';
import '../../data/todo_api.dart'; import '../../data/todo_api.dart';
enum _TodoHeaderAction { edit, delete }
class TodoDetailScreen extends StatefulWidget { class TodoDetailScreen extends StatefulWidget {
final String todoId; final String todoId;
@@ -87,8 +91,9 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
backgroundColor: AppColors.todoBg, backgroundColor: AppColors.todoBg,
body: SafeArea( body: SafeArea(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildHeader(context), _buildHeader(),
Expanded(child: _buildContent()), Expanded(child: _buildContent()),
], ],
), ),
@@ -96,39 +101,47 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
); );
} }
Widget _buildHeader(BuildContext context) { Widget _buildHeader() {
return SizedBox( return BackTitlePageHeader(
height: 64, title: '待办详情',
child: Padding( onBack: () => context.pop(),
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 8), trailing: _buildHeaderMenu(),
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? _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() { Widget _buildContent() {
if (_isLoading) { if (_isLoading) {
return const Center(child: AppLoadingIndicator(size: 22)); return const Center(child: AppLoadingIndicator(size: 22));
@@ -382,22 +395,11 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
} }
void _deleteTodo() async { void _deleteTodo() async {
final confirm = await showDialog<bool>( final confirm = await showDestructiveActionSheet(
context: context, context,
builder: (context) => AlertDialog( title: '删除待办',
title: const Text('确认删除'), message: '确定要删除这个待办吗?',
content: const Text('确定要删除这个待办吗?'), confirmText: '确认删除',
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)),
),
],
),
); );
if (confirm == true) { if (confirm == true) {
@@ -8,6 +8,7 @@ import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/app_pull_refresh_feedback.dart'; import '../../../../shared/widgets/app_pull_refresh_feedback.dart';
import '../../../../shared/widgets/app_pressable.dart'; import '../../../../shared/widgets/app_pressable.dart';
import '../../../../shared/widgets/app_sheet_input_field.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.dart';
import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../calendar/data/calendar_api.dart'; import '../../../calendar/data/calendar_api.dart';
@@ -167,24 +168,10 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
} }
Widget _buildHeader() { Widget _buildHeader() {
return SizedBox( return BackTitlePageHeader(
height: 72, title: '待办事项',
child: Padding( showBackButton: false,
padding: const EdgeInsets.only(left: 16, right: 16, top: 14, bottom: 8), trailing: Row(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'待办事项',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@@ -233,9 +220,6 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
), ),
], ],
), ),
],
),
),
); );
} }
@@ -395,9 +379,9 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
final dateStr = final dateStr =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
if (viewType == CalendarViewType.month) { if (viewType == CalendarViewType.month) {
context.push('/calendar/month'); context.go('/calendar/month');
} else { } else {
context.push('/calendar/dayweek?date=$dateStr'); context.go('/calendar/dayweek?date=$dateStr');
} }
}, },
onHomeTap: () => context.go('/home'), onHomeTap: () => context.go('/home'),
@@ -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<bool> 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),
),
),
),
),
);
}
}
@@ -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,
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import '../../core/theme/design_tokens.dart';
import 'app_button.dart';
Future<bool> showDestructiveActionSheet(
BuildContext context, {
required String title,
required String message,
required String confirmText,
}) async {
final result = await showModalBottomSheet<bool>(
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;
}
@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import '../../core/theme/design_tokens.dart';
class DetailHeaderActionItem<T> {
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<T> extends StatefulWidget {
const DetailHeaderActionMenu({
super.key,
required this.items,
required this.onSelected,
});
final List<DetailHeaderActionItem<T>> items;
final ValueChanged<T> onSelected;
@override
State<DetailHeaderActionMenu<T>> createState() =>
_DetailHeaderActionMenuState<T>();
}
class _DetailHeaderActionMenuState<T> extends State<DetailHeaderActionMenu<T>> {
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<T> 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,
),
),
),
);
}
}
+33 -2
View File
@@ -36,8 +36,13 @@ void main() {
when(() => tokenStorage.getAccessToken()).thenAnswer((_) async => null); when(() => tokenStorage.getAccessToken()).thenAnswer((_) async => null);
}); });
DioException _unauthorized(String path) { DioException _unauthorized(String path, {bool withAuthHeader = false}) {
final requestOptions = RequestOptions(path: path); final requestOptions = RequestOptions(
path: path,
headers: withAuthHeader
? <String, dynamic>{'Authorization': 'Bearer expired'}
: null,
);
return DioException( return DioException(
requestOptions: requestOptions, requestOptions: requestOptions,
response: Response<dynamic>( response: Response<dynamic>(
@@ -109,4 +114,30 @@ void main() {
expect(refreshCalls, 1); expect(refreshCalls, 1);
}); });
test('并发401刷新失败仅触发一次auth failure回调', () async {
var refreshCalls = 0;
var authFailureCalls = 0;
interceptor.onTokenRefresh = () async {
refreshCalls += 1;
await Future<void>.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<void>.delayed(const Duration(milliseconds: 80));
expect(refreshCalls, 1);
expect(authFailureCalls, 1);
});
} }
@@ -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 { test('refreshSession saves new tokens', () async {
when(() => mockApi.refreshSession(any())).thenAnswer( when(() => mockApi.refreshSession(any())).thenAnswer(
(_) async => AuthResponse( (_) async => AuthResponse(
@@ -32,7 +32,10 @@ void main() {
return authBloc; return authBloc;
}, },
act: (bloc) => bloc.add(AuthStarted()), act: (bloc) => bloc.add(AuthStarted()),
expect: () => [AuthLoading(), AuthUnauthenticated()], expect: () => [
AuthLoading(),
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
],
); );
blocTest<AuthBloc, AuthState>( blocTest<AuthBloc, AuthState>(
@@ -65,11 +68,38 @@ void main() {
when( when(
() => mockRepository.refreshSession('expired_refresh'), () => mockRepository.refreshSession('expired_refresh'),
).thenThrow(Exception('Invalid refresh token')); ).thenThrow(Exception('Invalid refresh token'));
when(() => mockRepository.deleteSession()).thenAnswer((_) async {}); when(
() => mockRepository.clearSessionLocalOnly(),
).thenAnswer((_) async {});
return authBloc; return authBloc;
}, },
act: (bloc) => bloc.add(AuthStarted()), act: (bloc) => bloc.add(AuthStarted()),
expect: () => [AuthLoading(), AuthUnauthenticated()], expect: () => [
AuthLoading(),
const AuthUnauthenticated(
reason: AuthUnauthenticatedReason.startupRecoveryFailed,
),
],
);
blocTest<AuthBloc, AuthState>(
'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<AuthBloc, AuthState>( blocTest<AuthBloc, AuthState>(
@@ -93,7 +123,30 @@ void main() {
user: const AuthUser(id: '1', email: 'test@example.com'), user: const AuthUser(id: '1', email: 'test@example.com'),
), ),
act: (bloc) => bloc.add(AuthLoggedOut()), act: (bloc) => bloc.add(AuthLoggedOut()),
expect: () => [AuthUnauthenticated()], expect: () => [
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
],
);
blocTest<AuthBloc, AuthState>(
'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),
],
); );
}); });
} }
@@ -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);
});
}
@@ -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,
);
}
@@ -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);
});
});
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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'; import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart';
void main() { void main() {
@@ -128,5 +129,93 @@ void main() {
expect(find.textContaining('无效 UI Schema'), findsOneWidget); 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);
});
}); });
} }
@@ -125,7 +125,7 @@ def upgrade() -> None:
RETURNS trigger RETURNS trigger
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER SECURITY DEFINER
SET search_path = public SET search_path = ''
AS $$ AS $$
BEGIN 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, created_at, updated_at)
@@ -52,8 +52,8 @@ def upgrade() -> None:
op.execute( op.execute(
""" """
CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() CREATE OR REPLACE FUNCTION public.generate_invite_code()
RETURNS trigger RETURNS TEXT
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER SECURITY DEFINER
SET search_path = '' SET search_path = ''
@@ -78,7 +78,7 @@ def upgrade() -> None:
RETURNS trigger RETURNS trigger
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER SECURITY DEFINER
SET search_path = public SET search_path = ''
AS $$ AS $$
DECLARE DECLARE
invite_code_value TEXT; invite_code_value TEXT;
@@ -69,10 +69,15 @@ def upgrade() -> None:
RETURNS trigger RETURNS trigger
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER SECURITY DEFINER
SET search_path = public SET search_path = ''
AS $$ AS $$
DECLARE
invite_code_value TEXT;
referrer_id UUID;
new_code TEXT;
attempts INT := 0;
BEGIN 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 ( VALUES (
NEW.id, NEW.id,
COALESCE( COALESCE(
@@ -82,12 +87,55 @@ def upgrade() -> None:
), ),
NULL, NULL,
NULL, NULL,
'{"agent_prompts": {}}'::jsonb, '{}'::jsonb,
NULL,
now(), now(),
now() now()
) )
ON CONFLICT (id) DO NOTHING; 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; RETURN NEW;
END; END;
$$ $$
@@ -121,7 +169,7 @@ def downgrade() -> None:
RETURNS trigger RETURNS trigger
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER SECURITY DEFINER
SET search_path = public SET search_path = ''
AS $$ AS $$
BEGIN 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, created_at, updated_at)
@@ -71,7 +71,7 @@ def upgrade() -> None:
RETURNS trigger RETURNS trigger
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER SECURITY DEFINER
SET search_path = public SET search_path = ''
AS $$ AS $$
BEGIN 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, created_at, updated_at)
@@ -114,7 +114,7 @@ def downgrade() -> None:
RETURNS trigger RETURNS trigger
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER SECURITY DEFINER
SET search_path = public SET search_path = ''
AS $$ AS $$
BEGIN 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, created_at, updated_at)
@@ -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)
@@ -9,6 +9,7 @@ from ag_ui.core.types import Tool
from core.agentscope.prompts.agent_prompt import ( from core.agentscope.prompts.agent_prompt import (
build_agent_prompt, 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 core.agentscope.prompts.tool_prompt import build_tools_prompt
from schemas.agent.system_agent import AgentType from schemas.agent.system_agent import AgentType
from schemas.agent.forwarded_props import ClientTimeContext from schemas.agent.forwarded_props import ClientTimeContext
@@ -19,6 +20,7 @@ def _wrap_section(section: str, content: str) -> str:
marker_map = { marker_map = {
"env": ("<!-- ENV_START -->", "<!-- ENV_END -->"), "env": ("<!-- ENV_START -->", "<!-- ENV_END -->"),
"identity": ("<!-- IDENTITY_START -->", "<!-- IDENTITY_END -->"), "identity": ("<!-- IDENTITY_START -->", "<!-- IDENTITY_END -->"),
"route": ("<!-- ROUTE_START -->", "<!-- ROUTE_END -->"),
"schema": ("<!-- SCHEMA_START -->", "<!-- SCHEMA_END -->"), "schema": ("<!-- SCHEMA_START -->", "<!-- SCHEMA_END -->"),
"safety": ("<!-- SAFETY_START -->", "<!-- SAFETY_END -->"), "safety": ("<!-- SAFETY_START -->", "<!-- SAFETY_END -->"),
"output": ("<!-- OUTPUT_START -->", "<!-- OUTPUT_END -->"), "output": ("<!-- OUTPUT_START -->", "<!-- OUTPUT_END -->"),
@@ -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( def build_system_prompt(
*, *,
agent_type: AgentType, agent_type: AgentType,
@@ -202,7 +208,7 @@ def build_system_prompt(
extra_context: str | None = None, extra_context: str | None = None,
tools: Sequence[Tool | dict[str, Any]] | None = None, tools: Sequence[Tool | dict[str, Any]] | None = None,
) -> str: ) -> str:
sections = [ sections: list[str | None] = [
_build_identity_section(), _build_identity_section(),
_build_env_section( _build_env_section(
user_context=user_context, user_context=user_context,
@@ -210,6 +216,7 @@ def build_system_prompt(
runtime_client_time=runtime_client_time, runtime_client_time=runtime_client_time,
extra_context=extra_context, extra_context=extra_context,
), ),
_build_route_section(),
_build_safety_section(), _build_safety_section(),
build_agent_prompt( build_agent_prompt(
agent_type=agent_type, agent_type=agent_type,
@@ -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
+48 -3
View File
@@ -13,9 +13,16 @@ Version: 2.1
from __future__ import annotations from __future__ import annotations
from enum import Enum 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 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 # Enums
@@ -74,7 +81,7 @@ class UiHintIconSource(str, Enum):
class UiHintBaseModel(BaseModel): class UiHintBaseModel(BaseModel):
model_config = ConfigDict( model_config: ClassVar[ConfigDict] = ConfigDict(
extra="forbid", extra="forbid",
populate_by_name=True, populate_by_name=True,
) )
@@ -90,6 +97,44 @@ class UiHintActionNavigation(UiHintBaseModel):
path: str = Field(..., description="Internal route path.") path: str = Field(..., description="Internal route path.")
params: dict[str, Any] | None = Field(default=None, description="Route params.") 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): class UiHintActionUrl(UiHintBaseModel):
type: Literal["url"] type: Literal["url"]
@@ -203,7 +248,7 @@ class UiHintsPayload(UiHintBaseModel):
- 编译器负责转换为完整 UiSchemaRenderer - 编译器负责转换为完整 UiSchemaRenderer
""" """
model_config = ConfigDict( model_config: ClassVar[ConfigDict] = ConfigDict(
extra="forbid", extra="forbid",
populate_by_name=True, populate_by_name=True,
json_schema_extra={ json_schema_extra={
@@ -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
@@ -148,7 +148,9 @@ def test_build_system_prompt_keeps_sections_focused_without_language_duplication
assert "[Identity]" in prompt assert "[Identity]" in prompt
assert "[Runtime Context]" in prompt assert "[Runtime Context]" in prompt
assert "<!-- ROUTE_START -->" in prompt
assert "[Safety Rules]" in prompt assert "[Safety Rules]" in prompt
assert "[Frontend Route Catalog]" in prompt
assert "[Agent Identity]" in prompt assert "[Agent Identity]" in prompt
assert "[Available Tools]" in prompt assert "[Available Tools]" in prompt
assert "[Answer Style]" in prompt assert "[Answer Style]" in prompt
@@ -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)
@@ -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 失败)。
- 回滚:保留旧事件兼容层,出现异常可快速退回旧路由判定。
@@ -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**
输出剩余风险、可观测性建议、生产灰度建议。
+26
View File
@@ -119,3 +119,29 @@ tool 结果不再走 UI 编译链路:`TOOL_CALL_RESULT` 提供 `tool_call_args
2. 再接入 `/events` 处理后续增量 2. 再接入 `/events` 处理后续增量
3.`runId` + `messageId/toolCallId` 做去重与合并 3.`runId` + `messageId/toolCallId` 做去重与合并
4. 统一消费 `ui_schema` 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` 里放嵌套对象数组。
+9
View File
@@ -282,6 +282,15 @@ interface NavigateAction {
params?: Record<string, any>; params?: Record<string, any>;
} }
// 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 // URL action
interface UrlAction { interface UrlAction {
type: 'url'; type: 'url';