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.
- When creating new UI components, ensure they follow the design tokens and visual design language.
## 2.1) Navigation/Header Reuse Rules (MUST)
- For page groups with clear parent-child relationships (e.g., Settings and its subpages), **MUST** use one shared header pattern: back button + page title.
- **MUST** extract shared page scaffolds/header wrappers instead of duplicating `SafeArea + header + scroll` structures across sibling pages.
- Detail-page right-side actions (edit/delete/share etc.) **MUST** use a shared action-menu component, not per-page ad-hoc button groups.
- Header action menus **MUST NOT** overlap the trigger button area; menu surfaces should open below/right-aligned to the trigger and preserve title readability.
## 2.2) Interaction Surface Reuse Rules (MUST)
- Repeated state-switch controls (toggle/switch UI) **MUST** be extracted into shared widgets.
- Destructive confirmations (delete/remove) **MUST** use shared project-style confirmation surfaces (e.g., unified action sheet), not platform-default dialog styles.
- **MUST NOT** use raw platform-default popup/dialog/dropdown visuals when they break project visual language; use token-driven shared components instead.
## 3) Layout Mapping & Alignment (MUST)
- **MUST** explicitly set `crossAxisAlignment` for every `Row` / `Column` (do not rely on defaults).
@@ -118,3 +131,24 @@ Before finalizing any UI, mentally verify:
- Does the screen feel calm and premium?
- Is the assistant identity visually present?
- Would this look plausible in a polished shipping app?
## 9) Auth Global Module Rules (MUST)
Auth is a global module. All auth/session behavior MUST follow a single state machine.
- **MUST** treat `AuthBloc` as the single source of truth for authentication state.
- **MUST NOT** implement ad-hoc auth state in feature modules (no parallel flags, no local auth caches).
- **MUST** route all 401 refresh-failure handling through the global callback chain:
`ApiInterceptor -> ApiClient auth failure callback -> AuthBloc(AuthSessionInvalidated)`.
- **MUST NOT** clear tokens directly inside feature/page code.
- **MUST NOT** navigate to login directly from feature code on token errors; rely on router redirect driven by global auth state.
- **MUST** distinguish logout semantics:
- manual logout: revoke server session + clear local session
- auto expiry/logout on refresh failure: clear local session only
- **MUST** ensure startup session recovery has exception fallback and never leaves app stuck in boot/loading state.
- **MUST** add/maintain tests for:
- startup recovery fallback
- concurrent 401 refresh failure singleflight
- session invalidation -> unauthenticated redirect path
If a new auth-related requirement cannot fit this model, update this section first, then implement code.
+15 -1
View File
@@ -15,7 +15,16 @@ class ApiClient implements IApiClient {
required TokenStorage tokenStorage,
Dio? dio,
}) {
final effectiveDio = dio ?? Dio(BaseOptions(baseUrl: baseUrl));
final effectiveDio =
dio ??
Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
),
);
final interceptor = ApiInterceptor(
tokenStorage: tokenStorage,
dio: effectiveDio,
@@ -50,6 +59,11 @@ class ApiClient implements IApiClient {
};
}
void setAuthFailureCallback(Future<void> Function() onAuthFailure) {
_interceptor.onAuthFailure = onAuthFailure;
}
@override
Future<Response<T>> get<T>(String path, {Options? options}) async {
try {
return await _dio.get<T>(path, options: options);
+32
View File
@@ -6,7 +6,9 @@ class ApiInterceptor extends Interceptor {
final Dio dio;
final Duration refreshFailureCooldown;
Future<bool> Function()? onTokenRefresh;
Future<void> Function()? onAuthFailure;
Future<bool>? _refreshFuture;
Future<void>? _authFailureFuture;
DateTime? _refreshBlockedUntil;
static const _retriedRequestKey = '_auth_retry_once';
@@ -34,6 +36,10 @@ class ApiInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final requestOptions = err.requestOptions;
final isUnauthorized = err.response?.statusCode == 401;
final shouldHandleUnauthorized =
isUnauthorized && _isAuthenticatedRequest(requestOptions);
if (err.response?.statusCode == 401 &&
onTokenRefresh != null &&
!_shouldSkipRefresh(requestOptions)) {
@@ -57,11 +63,36 @@ class ApiInterceptor extends Interceptor {
// Retry failed, proceed with original error.
}
}
} else if (shouldHandleUnauthorized) {
await _notifyAuthFailureSingleflight();
}
} else if (shouldHandleUnauthorized && _shouldSkipRefresh(requestOptions)) {
await _notifyAuthFailureSingleflight();
}
handler.next(err);
}
bool _isAuthenticatedRequest(RequestOptions options) {
return options.headers['Authorization'] != null;
}
Future<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) {
final blockedUntil = _refreshBlockedUntil;
if (blockedUntil != null && DateTime.now().isBefore(blockedUntil)) {
@@ -101,6 +132,7 @@ class ApiInterceptor extends Interceptor {
void reset() {
_refreshFuture = null;
_authFailureFuture = null;
_refreshBlockedUntil = null;
}
}
-8
View File
@@ -11,12 +11,4 @@ class Env {
}
return 'http://localhost:5775';
}
static bool get isMockApi {
final fromDefine = const String.fromEnvironment('MOCK_API');
if (fromDefine.isNotEmpty) {
return fromDefine == 'true';
}
return false;
}
}
+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_impl.dart';
import '../../features/auth/presentation/bloc/auth_bloc.dart';
import '../../features/auth/presentation/bloc/auth_event.dart';
import '../../features/calendar/data/calendar_api.dart';
import '../../features/calendar/data/services/calendar_service.dart';
import '../../features/calendar/ui/calendar_state_manager.dart';
@@ -26,12 +27,18 @@ Future<void> configureDependencies() async {
await sl.reset();
}
final IApiClient apiClient;
final SecureTokenStorage tokenStorage;
final dio = Dio(BaseOptions(baseUrl: Env.apiUrl));
tokenStorage = SecureTokenStorage(const FlutterSecureStorage());
apiClient = ApiClient(
tokenStorage = SecureTokenStorage(
const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
),
);
final apiClient = ApiClient(
baseUrl: Env.apiUrl,
tokenStorage: tokenStorage,
dio: dio,
@@ -69,12 +76,15 @@ Future<void> configureDependencies() async {
api: authApi,
tokenStorage: tokenStorage,
onLogout: () async {
(apiClient as ApiClient).resetInterceptor();
apiClient.resetInterceptor();
},
);
sl.registerSingleton<AuthRepository>(authRepository);
(apiClient as ApiClient).setRefreshCallback((token) async {
final authBloc = AuthBloc(authRepository);
sl.registerSingleton<AuthBloc>(authBloc);
apiClient.setRefreshCallback((token) async {
try {
await authRepository.refreshSession(token);
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());
}
@@ -11,6 +11,7 @@ abstract class AuthRepository {
Future<AuthResponse> createSession(LoginRequest request);
Future<AuthResponse> refreshSession(String refreshToken);
Future<void> deleteSession();
Future<void> clearSessionLocalOnly();
Future<String?> getAccessToken();
Future<String?> getRefreshToken();
Future<bool> isAuthenticated();
@@ -64,9 +64,6 @@ class AuthRepositoryImpl implements AuthRepository {
@override
Future<void> deleteSession() async {
if (_onLogout != null) {
await _onLogout!();
}
final refreshToken = await _tokenStorage.getRefreshToken();
if (refreshToken != null) {
try {
@@ -75,6 +72,14 @@ class AuthRepositoryImpl implements AuthRepository {
// ignore API errors during logout
}
}
await clearSessionLocalOnly();
}
@override
Future<void> clearSessionLocalOnly() async {
if (_onLogout != null) {
await _onLogout();
}
await _tokenStorage.clear();
}
@@ -10,13 +10,14 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
on<AuthStarted>(_onStarted);
on<AuthLoggedIn>(_onLoggedIn);
on<AuthLoggedOut>(_onLoggedOut);
on<AuthSessionInvalidated>(_onSessionInvalidated);
}
Future<void> _onStarted(AuthStarted event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final refreshToken = await _repository.getRefreshToken();
if (refreshToken != null) {
try {
final response = await _repository.refreshSession(refreshToken);
emit(
AuthAuthenticated(
@@ -24,11 +25,23 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
),
);
return;
}
emit(
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
);
} 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) {
@@ -39,7 +52,29 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthLoggedOut event,
Emitter<AuthState> emit,
) async {
try {
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 '../../data/models/auth_response.dart';
enum AuthInvalidationSource { unauthorized401 }
abstract class AuthEvent extends Equatable {
const AuthEvent();
@@ -20,3 +22,12 @@ class AuthLoggedIn extends AuthEvent {
}
class AuthLoggedOut extends AuthEvent {}
class AuthSessionInvalidated extends AuthEvent {
final AuthInvalidationSource source;
const AuthSessionInvalidated({required this.source});
@override
List<Object?> get props => [source];
}
@@ -14,6 +14,8 @@ class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
enum AuthUnauthenticatedReason { signedOut, expired, startupRecoveryFailed }
class AuthAuthenticated extends AuthState {
final AuthUser user;
@@ -23,4 +25,13 @@ class AuthAuthenticated extends AuthState {
List<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:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_service.dart';
import '../calendar_state_manager.dart';
import '../calendar_time_utils.dart';
import '../dayweek/day_event_layout_engine.dart';
import '../dayweek/day_timeline_metrics.dart';
import '../dayweek/day_view_scale.dart';
import '../widgets/bottom_dock.dart';
import '../widgets/create_event_sheet.dart';
import '../../data/services/calendar_service.dart';
import '../../data/models/schedule_item_model.dart';
class CalendarDayWeekScreen extends StatefulWidget {
final DateTime? initialDate;
@@ -29,20 +33,20 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
with WidgetsBindingObserver {
static const double _dayItemWidth = 44;
static const double _dayItemGap = 12;
static const double _eventLeftOffset = 52;
static const double _defaultHourHeight = 34.0;
static const double _minHourHeight = 17.0;
static const double _maxHourHeight = 68.0;
static const double _minEventTapHeight = 32;
static const List<String> _dayNames = ['', '', '', '', '', '', ''];
double _hourHeight = _defaultHourHeight;
final DayEventLayoutEngine _layoutEngine = const DayEventLayoutEngine();
final Map<int, Offset> _activePointers = {};
final ScrollController _dayStripController = ScrollController();
DayViewScale _scale = DayViewScale.defaultScale();
DayViewScale _pinchStartScale = DayViewScale.defaultScale();
double? _pinchStartDistance;
double _pinchStartHourHeight = _defaultHourHeight;
late final CalendarStateManager _calendarManager;
late DateTime _selectedDate;
late List<DateTime> _monthDates;
final ScrollController _dayStripController = ScrollController();
List<ScheduleItemModel> _events = const [];
@override
@@ -55,7 +59,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
_calendarManager.resetToToday();
}
_selectedDate = _calendarManager.selectedDate;
_selectedDate = widget.initialDate ?? _calendarManager.selectedDate;
_updateMonthDates();
_loadEvents();
@@ -159,7 +163,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
final today = DateTime.now();
setState(() {
_selectedDate = today;
_hourHeight = _defaultHourHeight;
_scale = DayViewScale.defaultScale();
});
_calendarManager.setSelectedDate(today);
_updateMonthDates();
@@ -172,7 +176,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
if (_activePointers.length == 2) {
final pointers = _activePointers.values.toList(growable: false);
_pinchStartDistance = (pointers[0] - pointers[1]).distance;
_pinchStartHourHeight = _hourHeight;
_pinchStartScale = _scale;
}
}
@@ -192,28 +196,27 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
return;
}
final nextHeight =
(_pinchStartHourHeight * (currentDistance / startDistance)).clamp(
_minHourHeight,
_maxHourHeight,
final nextScale = _pinchStartScale.zoomByFactor(
currentDistance / startDistance,
);
if ((nextHeight - _hourHeight).abs() < 0.1) {
if ((nextScale.hourHeight - _scale.hourHeight).abs() < 0.1) {
return;
}
setState(() {
_hourHeight = nextHeight;
_scale = nextScale;
});
}
void _handlePointerUp(PointerUpEvent event) {
_activePointers.remove(event.pointer);
if (_activePointers.length < 2) {
_pinchStartDistance = null;
}
_handlePointerRemove(event.pointer);
}
void _handlePointerCancel(PointerCancelEvent event) {
_activePointers.remove(event.pointer);
_handlePointerRemove(event.pointer);
}
void _handlePointerRemove(int pointer) {
_activePointers.remove(pointer);
if (_activePointers.length < 2) {
_pinchStartDistance = null;
}
@@ -221,6 +224,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
Widget _buildHeader() {
final monthLabel = '${_selectedDate.year}${_selectedDate.month}';
final isNotToday = !isSameDay(_selectedDate, DateTime.now());
return SizedBox(
height: 68,
@@ -281,7 +285,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
),
),
const Spacer(),
if (!isSameDay(_selectedDate, DateTime.now()))
if (isNotToday)
AppPressable(
borderRadius: BorderRadius.circular(AppRadius.xl),
onTap: _goToToday,
@@ -305,8 +309,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
),
),
),
if (!isSameDay(_selectedDate, DateTime.now()))
const SizedBox(width: 8),
if (isNotToday) const SizedBox(width: 8),
AppPressable(
borderRadius: BorderRadius.circular(AppRadius.full),
onTap: () => CreateEventSheet.show(
@@ -413,14 +416,12 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
}
Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) {
final dayNames = ['', '', '', '', '', '', ''];
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
dayNames[date.weekday % 7],
_dayNames[date.weekday % 7],
style: TextStyle(
fontSize: 11,
color: isWeekend ? AppColors.slate400 : AppColors.slate600,
@@ -456,110 +457,203 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
Widget _buildTimelineBoard() {
final now = DateTime.now();
final showCurrent = shouldShowCurrentMarker(_selectedDate, now);
final events = _events;
final eventColumns = _calculateEventColumns(events);
return LayoutBuilder(
builder: (context, constraints) {
final boardWidth = constraints.maxWidth;
final boardHeight = DayTimelineMetrics.timelineHeight(_scale);
final eventAreaLeft = DayTimelineMetrics.eventAreaLeft();
final eventAreaWidth = DayTimelineMetrics.eventAreaWidth(boardWidth);
final layouts = _layoutEngine.layout(
events: _events,
scale: _scale,
eventAreaLeft: eventAreaLeft,
eventAreaWidth: eventAreaWidth,
);
return SizedBox(
height: boardHeight,
child: Stack(
children: [
RepaintBoundary(
child: _buildTimelineGrid(
boardHeight: boardHeight,
eventAreaLeft: eventAreaLeft,
),
),
if (showCurrent)
_buildCurrentTimeMarker(now: now, boardHeight: boardHeight),
RepaintBoundary(
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
children: [
for (var hour = 0; hour <= 23; hour++) ...[
_buildTimelineRow(formatHour(hour)),
if (showCurrent && now.hour == hour)
_buildTimelineRow(formatHm(now), isCurrentTime: true),
],
_buildTimelineRow(formatHour(24), isDisabled: true),
for (final layout in layouts)
_buildEventCard(layout: layout, boardHeight: boardHeight),
],
),
..._buildPositionedEvents(events, eventColumns),
),
],
),
);
},
);
}
Widget _buildTimelineGrid({
required double boardHeight,
required double eventAreaLeft,
}) {
return SizedBox(
height: boardHeight,
child: Stack(
children: [
for (var hour = 0; hour <= DayTimelineMetrics.hoursInDay; hour++)
_buildHourTick(
hour: hour,
boardHeight: boardHeight,
eventAreaLeft: eventAreaLeft,
),
],
),
);
}
List<int> _calculateEventColumns(List<ScheduleItemModel> events) {
if (events.isEmpty) return [];
Widget _buildHourTick({
required int hour,
required double boardHeight,
required double eventAreaLeft,
}) {
final minute = hour * DayTimelineMetrics.minutesInHour;
final y = _scale.pixelsForMinutes(minute);
final isDisabled = hour == DayTimelineMetrics.hoursInDay;
final labelTop = (y - 7).clamp(0.0, boardHeight - 14);
final columns = List<int>.filled(events.length, -1);
final columnHeights = <int, int>{};
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(
return Stack(
children: [
Positioned(
top: top,
left: left,
right: maxColumn > 1 ? null : 16,
width: maxColumn > 1 ? eventWidth - 4 : null,
height: height.clamp(24.0, double.infinity),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
context.push('/calendar/events/${event.id}');
},
top: y,
left: eventAreaLeft,
right: 0,
child: Container(
margin: const EdgeInsets.only(right: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
height: 1,
color: isDisabled ? AppColors.blue50 : AppColors.border,
),
),
Positioned(
top: labelTop,
left: 0,
width: DayTimelineMetrics.timeLabelWidth,
child: Text(
formatHour(hour),
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isDisabled ? AppColors.slate300 : AppColors.slate400,
),
),
),
],
);
}
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(
color: eventColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: eventColor, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 6,
@@ -569,10 +663,11 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
if (!isCompact) const SizedBox(width: 4),
if (!isCompact)
Expanded(
child: Text(
event.title,
layout.event.title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
@@ -586,16 +681,16 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
),
),
),
],
),
),
);
}
return eventWidgets;
}
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return AppColors.blue600;
if (hex == null || hex.isEmpty) {
return AppColors.blue600;
}
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
@@ -603,74 +698,12 @@ class _CalendarDayWeekScreenState extends State<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() {
return BottomDock(
activeTab: DockTab.calendar,
onTodoTap: () {
_calendarManager.setViewType(CalendarViewType.day);
context.push('/todo');
context.go('/todo');
},
onCalendarTap: () {
_calendarManager.setViewType(CalendarViewType.day);
@@ -5,12 +5,16 @@ import '../../../../core/di/injection.dart';
import '../../../../core/notifications/local_notification_service.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../../shared/widgets/detail_header_action_menu.dart';
import '../../../../shared/widgets/destructive_action_sheet.dart';
import '../../data/services/calendar_service.dart';
import '../../data/models/schedule_item_model.dart';
import '../widgets/create_event_sheet.dart';
import '../widgets/calendar_share_dialog.dart';
enum _CalendarHeaderAction { edit, delete, share }
class CalendarEventDetailScreen extends StatefulWidget {
final String eventId;
@@ -95,8 +99,9 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
backgroundColor: const Color(0xFFF8FAFC),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context),
_buildHeader(event),
Expanded(child: _buildDetailOverlay(event)),
_buildInputContainer(),
],
@@ -105,19 +110,81 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
);
}
Widget _buildHeader(BuildContext context) {
return SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 8),
child: Row(
children: [
widgets.BackButton(onPressed: () => Navigator.of(context).pop()),
],
),
Widget _buildHeader(ScheduleItemModel event) {
return BackTitlePageHeader(
title: '日程详情',
onBack: () => context.pop(),
trailing: _buildHeaderActions(event),
);
}
Widget _buildHeaderActions(ScheduleItemModel event) {
final items = <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) {
final startAt = event.startAt;
@@ -198,10 +265,11 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
Widget _buildTitleRow(ScheduleItemModel event) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 4,
@@ -226,114 +294,28 @@ class _CalendarEventDetailScreenState extends State<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({
required VoidCallback onTap,
required IconData icon,
required Color iconColor,
required Color backgroundColor,
required Color borderColor,
}) {
return SizedBox(
width: AppSpacing.xxl * 2,
height: AppSpacing.xxl * 2,
child: TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
padding: const EdgeInsets.all(AppSpacing.none),
backgroundColor: backgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
side: BorderSide(color: borderColor),
),
),
child: Icon(
icon,
size: AppSpacing.lg + AppSpacing.xs,
color: iconColor,
),
),
Future<void> _showDeleteConfirmation() async {
final confirmed = await showDestructiveActionSheet(
context,
title: '删除日程',
message: '确定要删除这个日程吗?',
confirmText: '确认删除',
);
}
void _showDeleteConfirmation() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('删除日程'),
content: const Text('确定要删除这个日程吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () async {
await sl<CalendarService>().deleteEvent(widget.eventId);
try {
await sl<LocalNotificationService>().cancelEventReminder(
widget.eventId,
);
} catch (_) {}
if (!context.mounted) {
if (!confirmed) {
return;
}
await sl<CalendarService>().deleteEvent(widget.eventId);
try {
await sl<LocalNotificationService>().cancelEventReminder(widget.eventId);
} catch (_) {}
if (!mounted) {
return;
}
Navigator.pop(context);
context.pop();
},
child: Text('删除', style: TextStyle(color: AppColors.red500)),
),
],
),
);
}
Widget _buildDetailField(String label, String value) {
@@ -522,7 +522,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
activeTab: DockTab.calendar,
onTodoTap: () {
_calendarManager.setViewType(CalendarViewType.month);
context.push('/todo');
context.go('/todo');
},
onCalendarTap: () {},
onHomeTap: () => context.go('/home'),
@@ -57,6 +57,17 @@ class CreateEventSheet extends StatefulWidget {
class _CreateEventSheetState extends State<CreateEventSheet>
with SingleTickerProviderStateMixin {
static const List<int?> _defaultReminderOptions = [
null,
0,
5,
10,
15,
30,
60,
120,
];
late TabController _tabController;
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
@@ -89,7 +100,9 @@ class _CreateEventSheetState extends State<CreateEventSheet>
_endDate = event.endAt;
_endTime = event.endAt;
_selectedColor = event.metadata?.color ?? '#3B82F6';
_reminderMinutes = event.metadata?.reminderMinutes ?? 15;
_reminderMinutes = _sanitizeReminderMinutes(
event.metadata?.reminderMinutes,
);
} else {
final now =
widget.initialDate ?? _roundToNearestMinute(DateTime.now(), 5);
@@ -512,7 +525,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
}
Widget _buildReminderPicker() {
const options = <int?>[null, 0, 5, 10, 15, 30, 60, 120];
final options = _buildReminderOptions();
String labelOf(int? value) {
if (value == null) {
return '无提醒';
@@ -573,6 +586,24 @@ class _CreateEventSheetState extends State<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 {
if (_titleController.text.trim().isEmpty || _saving) return;
setState(() {
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:social_app/core/theme/design_tokens.dart';
import 'package:social_app/shared/widgets/toast/toast.dart';
@@ -168,12 +169,7 @@ class UiSchemaRenderer {
onPressed: disabled
? null
: () {
final actionType = _asString(action?['type']);
if (actionType == 'copy') {
Toast.show(context, '已复制', type: ToastType.success);
} else {
Toast.show(context, '该操作暂未接入', type: ToastType.info);
}
_handleAction(context, action);
},
style: ElevatedButton.styleFrom(
elevation: 0,
@@ -203,6 +199,85 @@ class UiSchemaRenderer {
);
}
static void _handleAction(
BuildContext context,
Map<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) {
final items = _asList(
node['items'],
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../../shared/widgets/app_input.dart';
import '../../../../shared/widgets/link_button.dart';
import '../../../../shared/widgets/toast/toast.dart';
@@ -37,15 +37,18 @@ class _AddContactScreenState extends State<AddContactScreen> {
backgroundColor: AppColors.surfaceSecondary,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
widgets.PageHeader(
leading: widgets.BackButton(),
BackTitlePageHeader(
title: isEditing ? '编辑联系人' : '添加联系人',
onBack: () => context.pop(),
trailing: _buildConfirmButton(),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildAvatarSection(),
const SizedBox(height: 14),
@@ -5,7 +5,7 @@ import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/toast/index.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../friends/data/friends_api.dart';
import '../../../users/data/models/user_response.dart';
import '../../../users/data/users_api.dart';
@@ -267,8 +267,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
backgroundColor: AppColors.surfaceSecondary,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
widgets.PageHeader(leading: widgets.BackButton()),
BackTitlePageHeader(title: '联系人', onBack: () => context.pop()),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
@@ -10,7 +10,7 @@ import '../../../auth/presentation/bloc/auth_event.dart';
import '../../../auth/presentation/bloc/auth_state.dart';
import '../../../../shared/widgets/app_button.dart';
import '../widgets/account_section_card.dart';
import '../widgets/account_surface_scaffold.dart';
import '../widgets/settings_page_scaffold.dart';
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@@ -22,10 +22,8 @@ class AccountScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AccountSurfaceScaffold(
return SettingsPageScaffold(
title: '账户',
subtitle: null,
compactHeaderTitle: true,
onBack: () => context.pop(),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -13,7 +13,7 @@ import '../../../auth/presentation/bloc/auth_state.dart';
import '../../../../features/auth/presentation/cubits/reset_password_cubit.dart';
import '../../../../features/auth/data/auth_repository.dart';
import '../widgets/account_section_card.dart';
import '../widgets/account_surface_scaffold.dart';
import '../widgets/settings_page_scaffold.dart';
class ChangePasswordScreen extends StatelessWidget {
const ChangePasswordScreen({super.key});
@@ -95,9 +95,8 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> {
Toast.show(context, state.errorMessage!, type: ToastType.error);
}
},
child: AccountSurfaceScaffold(
child: SettingsPageScaffold(
title: '修改密码',
subtitle: '通过邮箱验证码修改密码',
onBack: () => context.pop(),
body: _buildForm(),
footer: BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
@@ -9,7 +9,7 @@ import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../users/data/models/user_response.dart';
import '../../../users/data/users_api.dart';
import '../widgets/account_section_card.dart';
import '../widgets/account_surface_scaffold.dart';
import '../widgets/settings_page_scaffold.dart';
class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({super.key});
@@ -119,9 +119,8 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
@override
Widget build(BuildContext context) {
return AccountSurfaceScaffold(
return SettingsPageScaffold(
title: '编辑资料',
subtitle: '编辑账户资料',
onBack: () => context.pop(),
body: _isLoading
? const Center(
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../../shared/widgets/app_toggle_switch.dart';
import '../widgets/settings_page_scaffold.dart';
class FeaturesScreen extends StatefulWidget {
const FeaturesScreen({super.key});
@@ -17,16 +19,10 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.surfaceSecondary,
body: SafeArea(
child: Column(
children: [
const widgets.PageHeader(leading: widgets.BackButton()),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
return SettingsPageScaffold(
title: '周期计划',
onBack: () => context.pop(),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('每日'),
@@ -38,11 +34,6 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
_buildWeeklyList(),
],
),
),
),
],
),
),
);
}
@@ -59,6 +50,7 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
Widget _buildDailyList() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildFeatureCard(
icon: Icons.alarm,
@@ -87,6 +79,7 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
Widget _buildWeeklyList() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildFeatureCard(
icon: Icons.calendar_view_week,
@@ -128,9 +121,10 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE4ECF6)),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 40,
@@ -167,40 +161,9 @@ class _FeaturesScreenState extends State<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:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../../shared/widgets/app_toggle_switch.dart';
import '../../data/services/memory_service.dart';
import '../widgets/settings_page_scaffold.dart';
class MemoryScreen extends StatefulWidget {
const MemoryScreen({super.key});
@@ -23,16 +25,10 @@ class _MemoryScreenState extends State<MemoryScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.surfaceSecondary,
body: SafeArea(
child: Column(
children: [
widgets.PageHeader(leading: widgets.BackButton(), height: 56),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
return SettingsPageScaffold(
title: '我的记忆',
onBack: () => context.pop(),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildToggleCard(),
@@ -46,11 +42,6 @@ class _MemoryScreenState extends State<MemoryScreen> {
_buildManageButton(),
],
),
),
),
],
),
),
);
}
@@ -63,8 +54,10 @@ class _MemoryScreenState extends State<MemoryScreen> {
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
@@ -114,9 +107,10 @@ class _MemoryScreenState extends State<MemoryScreen> {
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE1E8F3)),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (int i = 0; i < _memoryItems.length; i++) ...[
_buildMemoryItem(_memoryItems[i]),
@@ -136,9 +130,10 @@ class _MemoryScreenState extends State<MemoryScreen> {
decoration: BoxDecoration(
color: AppColors.surfaceTertiary,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE8EDF7)),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 32,
@@ -175,7 +170,11 @@ class _MemoryScreenState extends State<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(
color: AppColors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFDCE6F4)),
border: Border.all(color: AppColors.borderSecondary),
),
child: const Center(
child: Text(
@@ -207,33 +206,6 @@ class _MemoryScreenState extends State<MemoryScreen> {
}
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)),
),
),
),
),
);
return AppToggleSwitch(value: value, onChanged: onChanged);
}
}
@@ -4,13 +4,13 @@ import 'package:social_app/core/constants/app_constants.dart';
import 'package:social_app/core/di/injection.dart';
import 'package:social_app/core/theme/design_tokens.dart';
import 'package:social_app/shared/widgets/app_loading_indicator.dart';
import 'package:social_app/shared/widgets/page_header.dart' as widgets;
import 'package:social_app/shared/widgets/toast/toast.dart';
import 'package:social_app/shared/widgets/toast/toast_type.dart';
import 'package:social_app/features/friends/data/friends_api.dart';
import 'package:social_app/features/settings/data/settings_api.dart';
import 'package:social_app/features/users/data/models/user_response.dart';
import 'package:social_app/features/users/data/users_api.dart';
import '../widgets/settings_page_scaffold.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@@ -65,22 +65,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.surfaceSecondary,
body: SafeArea(
child: Column(
children: [
const widgets.PageHeader(leading: widgets.BackButton()),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.sm,
AppSpacing.xl,
AppSpacing.xl,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
return SettingsPageScaffold(
title: '设置',
onBack: () => context.pop(),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildProfileHero(),
const SizedBox(height: 16),
@@ -91,11 +80,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildMenuCard(context),
],
),
),
),
],
),
),
);
}
@@ -253,7 +237,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: _buildActionCard(
icon: Icons.auto_awesome,
iconColor: const Color(0xFF8B5CF6),
title: '常用功能',
title: '周期计划',
subtitle: '已启用:会议提醒',
onTap: () => context.push('/settings/features'),
),
@@ -459,7 +443,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
icon: Icons.system_update,
title: '检查更新',
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 {
final settingsApi = sl<SettingsApi>();
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/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../../shared/widgets/detail_header_action_menu.dart';
import '../../../../shared/widgets/destructive_action_sheet.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../calendar/data/calendar_api.dart';
import '../../data/todo_api.dart';
enum _TodoHeaderAction { edit, delete }
class TodoDetailScreen extends StatefulWidget {
final String todoId;
@@ -87,8 +91,9 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
backgroundColor: AppColors.todoBg,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context),
_buildHeader(),
Expanded(child: _buildContent()),
],
),
@@ -96,39 +101,47 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
);
}
Widget _buildHeader(BuildContext context) {
return SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 8),
child: Row(
children: [
widgets.BackButton(onPressed: () => Navigator.of(context).pop()),
const Spacer(),
if (_todo != null) ...[
IconButton(
onPressed: _editTodo,
icon: const Icon(
LucideIcons.pencil,
size: 20,
color: AppColors.slate600,
),
),
IconButton(
onPressed: _deleteTodo,
icon: const Icon(
LucideIcons.trash2,
size: 20,
color: Colors.red,
),
),
],
],
),
),
Widget _buildHeader() {
return BackTitlePageHeader(
title: '待办详情',
onBack: () => context.pop(),
trailing: _buildHeaderMenu(),
);
}
Widget? _buildHeaderMenu() {
if (_todo == null) {
return null;
}
return DetailHeaderActionMenu<_TodoHeaderAction>(
items: const [
DetailHeaderActionItem<_TodoHeaderAction>(
value: _TodoHeaderAction.edit,
label: '编辑',
icon: LucideIcons.pencil,
),
DetailHeaderActionItem<_TodoHeaderAction>(
value: _TodoHeaderAction.delete,
label: '删除',
icon: LucideIcons.trash2,
isDestructive: true,
),
],
onSelected: _handleHeaderAction,
);
}
void _handleHeaderAction(_TodoHeaderAction action) {
switch (action) {
case _TodoHeaderAction.edit:
_editTodo();
return;
case _TodoHeaderAction.delete:
_deleteTodo();
return;
}
}
Widget _buildContent() {
if (_isLoading) {
return const Center(child: AppLoadingIndicator(size: 22));
@@ -382,22 +395,11 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
}
void _deleteTodo() async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: const Text('确定要删除这个待办吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('删除', style: TextStyle(color: Colors.red)),
),
],
),
final confirm = await showDestructiveActionSheet(
context,
title: '删除待办',
message: '确定要删除这个待办吗?',
confirmText: '确认删除',
);
if (confirm == true) {
@@ -8,6 +8,7 @@ import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/app_pull_refresh_feedback.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../../../shared/widgets/app_sheet_input_field.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../calendar/data/calendar_api.dart';
@@ -167,24 +168,10 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
}
Widget _buildHeader() {
return SizedBox(
height: 72,
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 14, bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'待办事项',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
Row(
return BackTitlePageHeader(
title: '待办事项',
showBackButton: false,
trailing: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -233,9 +220,6 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
),
],
),
],
),
),
);
}
@@ -395,9 +379,9 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
final dateStr =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
if (viewType == CalendarViewType.month) {
context.push('/calendar/month');
context.go('/calendar/month');
} else {
context.push('/calendar/dayweek?date=$dateStr');
context.go('/calendar/dayweek?date=$dateStr');
}
},
onHomeTap: () => context.go('/home'),
@@ -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);
});
DioException _unauthorized(String path) {
final requestOptions = RequestOptions(path: path);
DioException _unauthorized(String path, {bool withAuthHeader = false}) {
final requestOptions = RequestOptions(
path: path,
headers: withAuthHeader
? <String, dynamic>{'Authorization': 'Bearer expired'}
: null,
);
return DioException(
requestOptions: requestOptions,
response: Response<dynamic>(
@@ -109,4 +114,30 @@ void main() {
expect(refreshCalls, 1);
});
test('并发401刷新失败仅触发一次auth failure回调', () async {
var refreshCalls = 0;
var authFailureCalls = 0;
interceptor.onTokenRefresh = () async {
refreshCalls += 1;
await Future<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 {
when(() => mockApi.refreshSession(any())).thenAnswer(
(_) async => AuthResponse(
@@ -32,7 +32,10 @@ void main() {
return authBloc;
},
act: (bloc) => bloc.add(AuthStarted()),
expect: () => [AuthLoading(), AuthUnauthenticated()],
expect: () => [
AuthLoading(),
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
],
);
blocTest<AuthBloc, AuthState>(
@@ -65,11 +68,38 @@ void main() {
when(
() => mockRepository.refreshSession('expired_refresh'),
).thenThrow(Exception('Invalid refresh token'));
when(() => mockRepository.deleteSession()).thenAnswer((_) async {});
when(
() => mockRepository.clearSessionLocalOnly(),
).thenAnswer((_) async {});
return authBloc;
},
act: (bloc) => bloc.add(AuthStarted()),
expect: () => [AuthLoading(), AuthUnauthenticated()],
expect: () => [
AuthLoading(),
const AuthUnauthenticated(
reason: AuthUnauthenticatedReason.startupRecoveryFailed,
),
],
);
blocTest<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>(
@@ -93,7 +123,30 @@ void main() {
user: const AuthUser(id: '1', email: 'test@example.com'),
),
act: (bloc) => bloc.add(AuthLoggedOut()),
expect: () => [AuthUnauthenticated()],
expect: () => [
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
],
);
blocTest<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_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart';
void main() {
@@ -128,5 +129,93 @@ void main() {
expect(find.textContaining('无效 UI Schema'), findsOneWidget);
});
testWidgets('handles navigation action and jumps by path', (tester) async {
final schema = {
'version': '2.0',
'root': {
'type': 'stack',
'direction': 'vertical',
'appearance': 'plain',
'children': [
{
'type': 'button',
'label': '查看待办',
'style': 'primary',
'action': {
'type': 'navigation',
'path': '/todo/123',
'params': {'from': 'assistant'},
},
},
],
},
};
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) =>
Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
),
GoRoute(
path: '/todo/:id',
builder: (context, state) => Text(
'todo detail ${state.pathParameters['id']} from ${state.uri.queryParameters['from']}',
),
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.tap(find.text('查看待办'));
await tester.pumpAndSettle();
expect(find.text('todo detail 123 from assistant'), findsOneWidget);
});
testWidgets('does not navigate for placeholder path', (tester) async {
final schema = {
'version': '2.0',
'root': {
'type': 'stack',
'direction': 'vertical',
'appearance': 'plain',
'children': [
{
'type': 'button',
'label': '坏路径',
'style': 'primary',
'action': {'type': 'navigation', 'path': '/todo/:id'},
},
],
},
};
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) =>
Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
),
GoRoute(
path: '/todo/:id',
builder: (context, state) => const Text('detail'),
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.tap(find.text('坏路径'));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 3));
expect(find.text('坏路径'), findsOneWidget);
expect(find.text('detail'), findsNothing);
});
});
}
@@ -125,7 +125,7 @@ def upgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
@@ -52,8 +52,8 @@ def upgrade() -> None:
op.execute(
"""
CREATE OR REPLACE FUNCTION public.create_profile_for_new_user()
RETURNS trigger
CREATE OR REPLACE FUNCTION public.generate_invite_code()
RETURNS TEXT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
@@ -78,7 +78,7 @@ def upgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
DECLARE
invite_code_value TEXT;
@@ -69,10 +69,15 @@ def upgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
DECLARE
invite_code_value TEXT;
referrer_id UUID;
new_code TEXT;
attempts INT := 0;
BEGIN
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, referred_by, created_at, updated_at)
VALUES (
NEW.id,
COALESCE(
@@ -82,12 +87,55 @@ def upgrade() -> None:
),
NULL,
NULL,
'{"agent_prompts": {}}'::jsonb,
'{}'::jsonb,
NULL,
now(),
now()
)
ON CONFLICT (id) DO NOTHING;
LOOP
BEGIN
new_code := public.generate_invite_code();
INSERT INTO public.invite_codes (code, owner_id, status, used_count, max_uses, expires_at, reward_config)
VALUES (
new_code,
NEW.id,
'active',
0,
NULL,
NULL,
'{}'::jsonb
);
EXIT;
EXCEPTION WHEN unique_violation THEN
attempts := attempts + 1;
IF attempts >= 100 THEN
RAISE EXCEPTION 'Failed to generate unique invite code after 100 attempts';
END IF;
END;
END LOOP;
invite_code_value := NEW.raw_user_meta_data ->> 'invite_code';
IF invite_code_value IS NOT NULL AND length(invite_code_value) = 4 THEN
invite_code_value := upper(invite_code_value);
IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$' THEN
UPDATE public.invite_codes
SET used_count = used_count + 1
WHERE code = invite_code_value
AND status = 'active'
AND (max_uses IS NULL OR used_count < max_uses)
AND (expires_at IS NULL OR expires_at > NOW())
RETURNING owner_id INTO referrer_id;
IF referrer_id IS NOT NULL THEN
UPDATE public.profiles
SET referred_by = referrer_id
WHERE id = NEW.id;
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$
@@ -121,7 +169,7 @@ def downgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
@@ -71,7 +71,7 @@ def upgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
@@ -114,7 +114,7 @@ def downgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
@@ -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 (
build_agent_prompt,
)
from core.agentscope.prompts.route_prompt import build_frontend_route_prompt
from core.agentscope.prompts.tool_prompt import build_tools_prompt
from schemas.agent.system_agent import AgentType
from schemas.agent.forwarded_props import ClientTimeContext
@@ -19,6 +20,7 @@ def _wrap_section(section: str, content: str) -> str:
marker_map = {
"env": ("<!-- ENV_START -->", "<!-- ENV_END -->"),
"identity": ("<!-- IDENTITY_START -->", "<!-- IDENTITY_END -->"),
"route": ("<!-- ROUTE_START -->", "<!-- ROUTE_END -->"),
"schema": ("<!-- SCHEMA_START -->", "<!-- SCHEMA_END -->"),
"safety": ("<!-- SAFETY_START -->", "<!-- SAFETY_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(
*,
agent_type: AgentType,
@@ -202,7 +208,7 @@ def build_system_prompt(
extra_context: str | None = None,
tools: Sequence[Tool | dict[str, Any]] | None = None,
) -> str:
sections = [
sections: list[str | None] = [
_build_identity_section(),
_build_env_section(
user_context=user_context,
@@ -210,6 +216,7 @@ def build_system_prompt(
runtime_client_time=runtime_client_time,
extra_context=extra_context,
),
_build_route_section(),
_build_safety_section(),
build_agent_prompt(
agent_type=agent_type,
@@ -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 enum import Enum
from typing import Any, Literal
import re
from typing import Any, ClassVar, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import field_validator
_NAVIGATION_PATH_PATTERN = re.compile(r"^/[A-Za-z0-9/_-]*$")
_NAVIGATION_PARAM_KEY_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,31}$")
_MAX_NAVIGATION_PARAMS = 8
# ============================================================
# Enums
@@ -74,7 +81,7 @@ class UiHintIconSource(str, Enum):
class UiHintBaseModel(BaseModel):
model_config = ConfigDict(
model_config: ClassVar[ConfigDict] = ConfigDict(
extra="forbid",
populate_by_name=True,
)
@@ -90,6 +97,44 @@ class UiHintActionNavigation(UiHintBaseModel):
path: str = Field(..., description="Internal route path.")
params: dict[str, Any] | None = Field(default=None, description="Route params.")
@field_validator("path")
@classmethod
def validate_navigation_path(cls, value: str) -> str:
path = value.strip()
if not path:
raise ValueError("navigation path must not be empty")
if len(path) > 256:
raise ValueError("navigation path is too long")
if path.startswith("//") or "://" in path:
raise ValueError("navigation path must be internal")
if "?" in path or "#" in path:
raise ValueError("navigation path must not contain query or fragment")
if ":" in path:
raise ValueError("navigation path must be concrete without placeholders")
if _NAVIGATION_PATH_PATTERN.fullmatch(path) is None:
raise ValueError("navigation path contains unsupported characters")
return path
@field_validator("params")
@classmethod
def validate_navigation_params(
cls, value: dict[str, Any] | None
) -> dict[str, Any] | None:
if value is None:
return None
if len(value) > _MAX_NAVIGATION_PARAMS:
raise ValueError("navigation params exceed limit")
normalized: dict[str, Any] = {}
for key, param_value in value.items():
if _NAVIGATION_PARAM_KEY_PATTERN.fullmatch(key) is None:
raise ValueError("navigation param key is invalid")
if isinstance(param_value, (str, int, float, bool)):
normalized[key] = param_value
continue
raise ValueError("navigation params must be scalar")
return normalized
class UiHintActionUrl(UiHintBaseModel):
type: Literal["url"]
@@ -203,7 +248,7 @@ class UiHintsPayload(UiHintBaseModel):
- 编译器负责转换为完整 UiSchemaRenderer
"""
model_config = ConfigDict(
model_config: ClassVar[ConfigDict] = ConfigDict(
extra="forbid",
populate_by_name=True,
json_schema_extra={
@@ -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 "[Runtime Context]" in prompt
assert "<!-- ROUTE_START -->" in prompt
assert "[Safety Rules]" in prompt
assert "[Frontend Route Catalog]" in prompt
assert "[Agent Identity]" in prompt
assert "[Available Tools]" in prompt
assert "[Answer Style]" in prompt
@@ -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` 处理后续增量
3.`runId` + `messageId/toolCallId` 做去重与合并
4. 统一消费 `ui_schema`
---
## 7) Navigation Action 数据流(ui_schema.actions
### 7.1 后端生成
- runtime 使用 `ui_hints.action.type = navigation` 产出导航动作。
- 编译后在 `ui_schema` 中保持 `action.type = navigation``action.path``action.params`
- 路由来源应受后端静态路由目录约束:
- `backend/src/core/config/static/route/frontend_routes.yaml`
### 7.2 前端消费(统一解析规则)
-`type = navigation`,前端仅走一条解析路径:
1. 读取 `path` 作为内部路由目标;
2.`params` 仅视为 query 参数(不用于 path 模板替换);
3. 执行 GoRouter 跳转(建议 `context.go(...)`)。
- `path` 必须是已落地页面路由,且应是已实参化路径(如 `/todo/123`,而非 `/todo/:id`)。
### 7.3 约束建议
- 为了让前端只保留一种解析逻辑,推荐强约束:
- `path` 只接受内部路由;
- `params` 只接受标量值(string/number/boolean);
- 禁止在 `params` 里放嵌套对象数组。
+9
View File
@@ -282,6 +282,15 @@ interface NavigateAction {
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
interface UrlAction {
type: 'url';