From fcf98b1142662e972bc5f6dc572390ade9f13fa2 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 19:00:24 +0800 Subject: [PATCH] feat(apps): refine login consent and calendar day/month UX --- .../auth/presentation/cubits/login_cubit.dart | 2 +- .../auth/ui/screens/login_screen.dart | 12 +- .../data/services/calendar_service.dart | 40 ++++++- .../ui/dayweek/day_event_layout_engine.dart | 82 +++++++++++-- .../calendar/ui/dayweek/day_view_scale.dart | 4 +- .../ui/screens/calendar_dayweek_screen.dart | 61 ++++++++-- .../screens/calendar_event_detail_screen.dart | 18 ++- .../ui/screens/calendar_month_screen.dart | 46 +++++++- .../calendar/ui/widgets/bottom_dock.dart | 2 +- ...3-20-navigation-cache-decoupling-design.md | 39 ++++-- ...on-cache-decoupling-implementation-plan.md | 111 +++++++++++++++++- 11 files changed, 359 insertions(+), 58 deletions(-) diff --git a/apps/lib/features/auth/presentation/cubits/login_cubit.dart b/apps/lib/features/auth/presentation/cubits/login_cubit.dart index 839cdac..9e828c4 100644 --- a/apps/lib/features/auth/presentation/cubits/login_cubit.dart +++ b/apps/lib/features/auth/presentation/cubits/login_cubit.dart @@ -130,6 +130,7 @@ class LoginCubit extends Cubit { final requestPhone = state.e164Phone; emit(state.copyWith(isSendingCode: true, errorMessage: null)); + _startResendCooldown(); try { await _repository.sendOtp(requestPhone); if (isClosed) { @@ -146,7 +147,6 @@ class LoginCubit extends Cubit { errorMessage: null, ), ); - _startResendCooldown(); return true; } catch (e) { if (isClosed) { diff --git a/apps/lib/features/auth/ui/screens/login_screen.dart b/apps/lib/features/auth/ui/screens/login_screen.dart index a9e673d..9065c5a 100644 --- a/apps/lib/features/auth/ui/screens/login_screen.dart +++ b/apps/lib/features/auth/ui/screens/login_screen.dart @@ -54,6 +54,12 @@ class _LoginViewState extends State { } Future _handleLogin() async { + if (!_agreedToTerms) { + final confirmed = await _showAgreementDialog(); + if (!confirmed || !mounted) return; + setState(() => _agreedToTerms = true); + } + final cubit = context.read(); cubit.phoneChanged(_phoneController.text); cubit.codeChanged(_codeController.text); @@ -70,12 +76,6 @@ class _LoginViewState extends State { } Future _handleSendCode() async { - if (!_agreedToTerms) { - final confirmed = await _showAgreementDialog(); - if (!confirmed || !mounted) return; - setState(() => _agreedToTerms = true); - } - final cubit = context.read(); cubit.phoneChanged(_phoneController.text); final sent = await cubit.sendCode(); diff --git a/apps/lib/features/calendar/data/services/calendar_service.dart b/apps/lib/features/calendar/data/services/calendar_service.dart index c1cf9e8..11045ce 100644 --- a/apps/lib/features/calendar/data/services/calendar_service.dart +++ b/apps/lib/features/calendar/data/services/calendar_service.dart @@ -1,4 +1,6 @@ import 'package:social_app/core/api/i_api_client.dart'; +import 'package:social_app/core/cache/cache_invalidator.dart'; +import 'package:social_app/core/di/injection.dart'; import '../calendar_api.dart'; import '../models/schedule_item_model.dart'; @@ -37,11 +39,15 @@ class CalendarService { } Future addEvent(ScheduleItemModel event) async { - return _api.create(event); + final created = await _api.create(event); + _invalidateEventCache(created); + return created; } Future updateEvent(ScheduleItemModel event) async { - return _api.update(event); + final updated = await _api.update(event); + _invalidateEventCache(updated); + return updated; } Future archiveEvent(String id) async { @@ -49,10 +55,38 @@ class CalendarService { if (event == null) { return null; } - return updateEvent(event.copyWith(status: ScheduleStatus.archived)); + final updatedEvent = await updateEvent( + event.copyWith(status: ScheduleStatus.archived), + ); + _invalidateEventCache(updatedEvent); + return updatedEvent; + } + + void _invalidateEventCache(ScheduleItemModel event) { + try { + final invalidator = sl(); + var current = DateTime( + event.startAt.year, + event.startAt.month, + event.startAt.day, + ); + final end = DateTime( + event.endAt?.year ?? event.startAt.year, + event.endAt?.month ?? event.startAt.month, + event.endAt?.day ?? event.startAt.day, + ); + while (!current.isAfter(end)) { + invalidator.invalidateCalendarDay(current); + current = current.add(const Duration(days: 1)); + } + } catch (_) {} } Future deleteEvent(String id) async { + final event = await getEventById(id); + if (event != null) { + _invalidateEventCache(event); + } await _api.delete(id); } } diff --git a/apps/lib/features/calendar/ui/dayweek/day_event_layout_engine.dart b/apps/lib/features/calendar/ui/dayweek/day_event_layout_engine.dart index 8b06bf2..d2e8ae2 100644 --- a/apps/lib/features/calendar/ui/dayweek/day_event_layout_engine.dart +++ b/apps/lib/features/calendar/ui/dayweek/day_event_layout_engine.dart @@ -36,6 +36,7 @@ class DayEventLayoutEngine { required DayViewScale scale, required double eventAreaLeft, required double eventAreaWidth, + required DateTime viewDate, double columnGap = DayTimelineMetrics.eventColumnGap, }) { if (events.isEmpty || eventAreaWidth <= 0) { @@ -44,7 +45,8 @@ class DayEventLayoutEngine { final sorted = events - .map(_EventSpan.fromEvent) + .map((e) => _EventSpan.fromEvent(e, viewDate)) + .expand((spans) => spans) .where((span) => span.endMinutes > span.startMinutes) .toList() ..sort((a, b) { @@ -149,19 +151,73 @@ class _EventSpan { 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); + static List<_EventSpan> fromEvent( + ScheduleItemModel event, + DateTime viewDate, + ) { + final startAt = event.startAt; + final endAt = event.endAt; + final viewDateOnly = DateTime(viewDate.year, viewDate.month, viewDate.day); + final startDateOnly = DateTime(startAt.year, startAt.month, startAt.day); + final endDateOnly = endAt != null + ? DateTime(endAt.year, endAt.month, endAt.day) + : startDateOnly; + + final startMinOfDay = _minutesOfDay(startAt); + final endMinOfDay = endAt != null + ? _minutesOfDay(endAt) + : startMinOfDay + 60; + + if (endDateOnly.isAfter(startDateOnly)) { + if (viewDateOnly.isAtSameMomentAs(startDateOnly)) { + final clampedStart = DayTimelineMetrics.clampMinuteOfDay(startMinOfDay); + final clampedEnd = DayTimelineMetrics.minutesInDay; + if (clampedEnd > clampedStart) { + return [ + _EventSpan( + event: event, + startMinutes: clampedStart, + endMinutes: clampedEnd, + ), + ]; + } + } else if (viewDateOnly.isAtSameMomentAs(endDateOnly)) { + final clampedStart = 0; + final clampedEnd = DayTimelineMetrics.clampMinuteOfDay(endMinOfDay); + if (clampedEnd > clampedStart) { + return [ + _EventSpan( + event: event, + startMinutes: clampedStart, + endMinutes: clampedEnd, + ), + ]; + } + } else if (viewDateOnly.isAfter(startDateOnly) && + viewDateOnly.isBefore(endDateOnly)) { + return [ + _EventSpan( + event: event, + startMinutes: 0, + endMinutes: DayTimelineMetrics.minutesInDay, + ), + ]; + } + return const []; } - return _EventSpan( - event: event, - startMinutes: clampedStart, - endMinutes: clampedEnd, - ); + + final clampedStart = DayTimelineMetrics.clampMinuteOfDay(startMinOfDay); + var clampedEnd = DayTimelineMetrics.clampMinuteOfDay(endMinOfDay); + if (clampedEnd <= clampedStart) { + clampedEnd = clampedStart + 1; + } + return [ + _EventSpan( + event: event, + startMinutes: clampedStart, + endMinutes: clampedEnd, + ), + ]; } } diff --git a/apps/lib/features/calendar/ui/dayweek/day_view_scale.dart b/apps/lib/features/calendar/ui/dayweek/day_view_scale.dart index b8007a3..d0c4223 100644 --- a/apps/lib/features/calendar/ui/dayweek/day_view_scale.dart +++ b/apps/lib/features/calendar/ui/dayweek/day_view_scale.dart @@ -1,6 +1,6 @@ class DayViewScale { static const double defaultHourHeight = 34.0; - static const double minHourHeight = 17.0; + static const double minHourHeight = 34.0; static const double maxHourHeight = 68.0; final double hourHeight; @@ -8,7 +8,7 @@ class DayViewScale { const DayViewScale({required this.hourHeight}); factory DayViewScale.defaultScale() { - return const DayViewScale(hourHeight: defaultHourHeight); + return const DayViewScale(hourHeight: minHourHeight); } DayViewScale copyWith({double? hourHeight}) { diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index f5a7b69..650b6cc 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -41,6 +41,7 @@ class _CalendarDayWeekScreenState extends State final DayEventLayoutEngine _layoutEngine = const DayEventLayoutEngine(); final Map _activePointers = {}; final ScrollController _dayStripController = ScrollController(); + final ScrollController _timelineController = ScrollController(); DayViewScale _scale = DayViewScale.defaultScale(); DayViewScale _pinchStartScale = DayViewScale.defaultScale(); @@ -67,6 +68,7 @@ class _CalendarDayWeekScreenState extends State WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToSelectedDate(); + _scrollTimelineToNow(); }); } @@ -91,6 +93,7 @@ class _CalendarDayWeekScreenState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); _dayStripController.dispose(); + _timelineController.dispose(); super.dispose(); } @@ -125,6 +128,7 @@ class _CalendarDayWeekScreenState extends State onPointerCancel: _handlePointerCancel, behavior: HitTestBehavior.translucent, child: SingleChildScrollView( + controller: _timelineController, child: Padding( padding: const EdgeInsets.only( left: AppSpacing.lg, @@ -160,6 +164,7 @@ class _CalendarDayWeekScreenState extends State _calendarManager.setSelectedDate(today); _updateMonthDates(); _scrollToSelectedDate(animate: true); + _scrollTimelineToNow(animate: true); _loadEvents(); } @@ -194,6 +199,10 @@ class _CalendarDayWeekScreenState extends State if ((nextScale.hourHeight - _scale.hourHeight).abs() < 0.1) { return; } + if (nextScale.hourHeight < _scale.hourHeight && + _scale.hourHeight <= DayViewScale.minHourHeight) { + return; + } setState(() { _scale = nextScale; }); @@ -410,6 +419,29 @@ class _CalendarDayWeekScreenState extends State _dayStripController.jumpTo(offset); } + void _scrollTimelineToNow({bool animate = false}) { + if (!_timelineController.hasClients) { + return; + } + final now = DateTime.now(); + final minuteOfDay = now.hour * 60 + now.minute; + final targetY = _scale.pixelsForMinutes(minuteOfDay); + final viewportHeight = _timelineController.position.viewportDimension; + final offset = targetY - (viewportHeight / 3); + final max = _timelineController.position.maxScrollExtent; + final clampedOffset = offset.clamp(0.0, max); + + if (animate) { + _timelineController.animateTo( + clampedOffset, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + return; + } + _timelineController.jumpTo(clampedOffset); + } + Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) { return Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -465,6 +497,7 @@ class _CalendarDayWeekScreenState extends State scale: _scale, eventAreaLeft: eventAreaLeft, eventAreaWidth: eventAreaWidth, + viewDate: _selectedDate, ); return SizedBox( @@ -521,19 +554,17 @@ class _CalendarDayWeekScreenState extends State }) { 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 isLastHour = hour == DayTimelineMetrics.hoursInDay; + final adjustedY = isLastHour ? boardHeight - 1 : y; + final labelTop = (adjustedY - 7).clamp(0.0, boardHeight - 14); return Stack( children: [ Positioned( - top: y, + top: adjustedY, left: eventAreaLeft, right: 0, - child: Container( - height: 1, - color: isDisabled ? AppColors.blue50 : AppColors.border, - ), + child: Container(height: 1, color: AppColors.border), ), Positioned( top: labelTop, @@ -545,7 +576,7 @@ class _CalendarDayWeekScreenState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: isDisabled ? AppColors.slate300 : AppColors.slate400, + color: AppColors.slate400, ), ), ), @@ -609,10 +640,16 @@ class _CalendarDayWeekScreenState extends State required DayEventLayout layout, required double boardHeight, }) { - final eventColor = resolveEventColor( - status: layout.event.status, - colorHex: layout.event.metadata?.color, - ); + final isArchived = layout.event.status == ScheduleStatus.archived; + Color eventColor; + if (isArchived) { + eventColor = AppColors.slate400; + } else { + eventColor = resolveEventColor( + status: layout.event.status, + colorHex: layout.event.metadata?.color, + ); + } final isCompact = layout.visualHeight < 20; final tapHeight = layout.visualHeight < _minEventTapHeight ? _minEventTapHeight diff --git a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart index 842ae29..60104fb 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart @@ -320,6 +320,7 @@ class _CalendarEventDetailScreenState extends State { const SizedBox(height: AppSpacing.xs), Text( timeRange, + maxLines: 2, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, @@ -507,12 +508,21 @@ class _CalendarEventDetailScreenState extends State { } String _formatRangeLabel(DateTime startAt, DateTime? endAt) { - final dateLabel = - '${startAt.month}月${startAt.day}日 ${_getWeekday(startAt.weekday)}'; + final startLabel = + '${startAt.month}月${startAt.day}日 ${_getWeekday(startAt.weekday)} ${_formatTime(startAt)}'; if (endAt == null) { - return '$dateLabel ${_formatTime(startAt)}'; + return startLabel; } - return '$dateLabel ${_formatTime(startAt)} - ${_formatTime(endAt)}'; + final isSameDay = + startAt.year == endAt.year && + startAt.month == endAt.month && + startAt.day == endAt.day; + if (isSameDay) { + return '$startLabel - ${_formatTime(endAt)}'; + } + final endLabel = + '${endAt.month}月${endAt.day}日 ${_getWeekday(endAt.weekday)} ${_formatTime(endAt)}'; + return '开始: $startLabel\n结束: $endLabel'; } Widget _buildStatusBadge(ScheduleStatus status) { diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index 610f1ec..e9fcec0 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -107,6 +107,9 @@ class _CalendarMonthScreenState extends State } Widget _buildHeader() { + final today = DateTime.now(); + final isNotToday = !isSameDay(_selectedDate, today); + return SizedBox( height: 76, child: Padding( @@ -148,6 +151,31 @@ class _CalendarMonthScreenState extends State ), ), const Spacer(), + if (isNotToday) + AppPressable( + borderRadius: BorderRadius.circular(AppRadius.xl), + onTap: _goToToday, + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: AppColors.messageBtnWrap, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.messageBtnBorder), + ), + child: const Center( + child: Text( + '今天', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.slate700, + ), + ), + ), + ), + ), + if (isNotToday) const SizedBox(width: 8), AppPressable( borderRadius: BorderRadius.circular(AppRadius.full), onTap: () async { @@ -181,6 +209,20 @@ class _CalendarMonthScreenState extends State ); } + void _goToToday() { + final today = DateTime.now(); + final targetMonth = DateTime(today.year, today.month, 1); + setState(() { + _selectedDate = today; + if (_currentMonth.year != targetMonth.year || + _currentMonth.month != targetMonth.month) { + _currentMonth = targetMonth; + } + }); + _calendarManager.setSelectedDate(today); + _loadMonthEvents(forceRefresh: true); + } + Widget _buildMonthContent() { return Column( children: [ @@ -280,9 +322,7 @@ class _CalendarMonthScreenState extends State '${AppRoutes.calendarDayWeek}?date=${formatYmd(date)}', ); }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 140), - curve: Curves.easeOut, + child: Container( width: 36, height: 36, decoration: BoxDecoration( diff --git a/apps/lib/features/calendar/ui/widgets/bottom_dock.dart b/apps/lib/features/calendar/ui/widgets/bottom_dock.dart index 3e1b73e..d80d38d 100644 --- a/apps/lib/features/calendar/ui/widgets/bottom_dock.dart +++ b/apps/lib/features/calendar/ui/widgets/bottom_dock.dart @@ -26,7 +26,7 @@ class BottomDock extends StatelessWidget { left: AppSpacing.xl, right: AppSpacing.xl, top: AppSpacing.md, - bottom: AppSpacing.lg, + bottom: AppSpacing.sm, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/docs/plans/2026-03-20-navigation-cache-decoupling-design.md b/docs/plans/2026-03-20-navigation-cache-decoupling-design.md index 3aab454..40de36c 100644 --- a/docs/plans/2026-03-20-navigation-cache-decoupling-design.md +++ b/docs/plans/2026-03-20-navigation-cache-decoupling-design.md @@ -22,6 +22,8 @@ 4. 启动体验采用「本地优先 + 后台静默刷新」策略,减少进入 App 的重复请求。 5. 数据只在必要时刷新:手动下拉、写操作失效、生命周期关键点、缓存策略命中。 6. 主页按钮语义固定为“回主页”,不再变成“返回上一页”。 +7. 一级页面唯一为 Home,日历日/月视图、待办、设置均为二级页面;二级页面侧滑只允许返回一级页面,不允许直接退出 App。 +8. App 退出入口仅存在于一级页面(Home)。 ### 2.2 非目标 @@ -40,14 +42,20 @@ ### 4.1 导航分层 -采用两级导航: +采用分级导航: -1. 一级(主容器):`StatefulShellRoute.indexedStack` - - 分支:Home / Calendar / Todo - - 作用:保活分支页面,避免 tab 切换重建。 -2. 二级(分支内部) - - Calendar 分支:管理 month/day 主视图切换 + event detail/edit/share 子路由。 - - Todo 分支:管理 list/detail/edit 子路由。 +1. 一级页面(唯一):Home + - 仅 Home 允许触发系统退出路径。 +2. 二级页面(主业务入口) + - Calendar Day/Month + - Todo List(Quadrants) + - Settings + - 规则:二级页面的系统返回/侧滑返回统一回 Home,不允许直接退出 App。 +3. 三级页面(细节页) + - Calendar event detail/edit/share + - Todo detail/edit + - Settings 子页面(account/profile edit 等) + - 规则:三级页面返回到上一级(二级或三级上层)。 ### 4.2 状态与数据边界 @@ -151,6 +159,13 @@ 2. 硬过期数据需可见提醒(弱提示,不阻断基础浏览)。 3. 提供稳定手动刷新入口。 +### 6.5 日历提醒取消动作的一致性兜底 + +1. 用户在提醒弹层点击“取消/归档”时,前端必须立即发送归档请求,要求后端立刻将事件归档/过期。 +2. “延迟归档(outbox/pending)”仅在 App 进程不可用(被杀/未启动)时生效,作为离线或冷启动兜底。 +3. App 冷启动或恢复前台后,必须优先冲刷 pending 归档请求,确保最终一致性。 +4. 对用户可见行为要求:点击取消后 UI 立即反映归档状态,网络失败时展示重试提示,并保留 pending 记录。 + ## 7. 导航与页面职责重构 ### 7.1 路由重构 @@ -165,6 +180,8 @@ 1. Dock Home 统一执行“切到 Home 分支/`go('/home')`”。 2. `returnToHomePreserveState` 仅用于非 Dock 的返回策略场景。 3. 消除 `canPop -> pop` 对主页按钮语义的影响。 +4. 二级页面(Calendar Day/Month、Todo、Settings)统一拦截系统返回和侧滑返回,目标固定为 Home。 +5. App 退出只允许在 Home 页面生效(可采用双击退出或系统默认行为)。 ### 7.3 页面职责收敛 @@ -182,6 +199,7 @@ 1. 引入 shell + 分支保活。 2. Dock 接口改造与主 tab 切换实现。 3. Home 按钮语义修正。 +4. 建立分级返回约束:二级 -> Home,三级 -> 上一级,退出仅 Home。 ### M2 统一缓存骨架 @@ -206,6 +224,7 @@ 1. 清理旧缓存与重复加载逻辑。 2. 补齐测试与性能观测。 3. 评估参数并收敛默认策略。 +4. 验证提醒“点击取消即实时归档”与“App 关闭时延迟归档兜底”双路径。 ## 9. 验收标准 @@ -215,18 +234,21 @@ 2. 日/月切换响应明显变快。 3. 首次冷启动可先看到本地缓存内容。 4. Dock Home 始终回主页。 +5. 二级页面侧滑返回永远回 Home,不直接退出 App。 ### 9.2 网络验收 1. 切换页面时网络请求显著减少。 2. 写操作后关联数据可及时更新。 3. 手动刷新可强制拉取并回写缓存。 +4. 提醒取消动作触发实时归档请求,成功率可观测。 ### 9.3 一致性验收 1. 不出现旧响应覆盖新数据。 2. 离线后恢复在线可自动静默同步。 3. 软过期/硬过期行为符合策略定义。 +4. 提醒归档在在线/离线/冷启动场景下保持最终一致。 ## 10. 测试与验证计划 @@ -241,12 +263,15 @@ 1. Dock 切换不重建分支主页面。 2. 日/月切换不重复触发全量加载。 3. Home 按钮行为稳定。 +4. 二级页面系统返回不会触发 App 退出。 ### 10.3 集成回归 1. Calendar -> Todo -> Calendar 多轮切换请求计数。 2. Todo 完成后列表更新与缓存一致性。 3. profile 更新后设置页/其他依赖页可见一致。 +4. 提醒取消 -> 立即归档 -> 日历列表刷新链路。 +5. App 杀进程后触发提醒,重启后 pending 归档自动冲刷。 ## 11. 风险与回滚 diff --git a/docs/plans/2026-03-20-navigation-cache-decoupling-implementation-plan.md b/docs/plans/2026-03-20-navigation-cache-decoupling-implementation-plan.md index 72c624d..7249f1b 100644 --- a/docs/plans/2026-03-20-navigation-cache-decoupling-implementation-plan.md +++ b/docs/plans/2026-03-20-navigation-cache-decoupling-implementation-plan.md @@ -2,14 +2,61 @@ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -**Goal:** 完成 Home/Calendar/Todo 的解耦切换与统一缓存改造,实现本地优先显示、后台静默刷新、写后精准失效,并修复 Dock 回主页语义。 +**Goal:** 完成导航分级回退(一级唯一 Home)与统一缓存改造,实现本地优先显示、后台静默刷新、写后精准失效,并落地“提醒取消即实时归档 + App 关闭时延迟归档兜底”。 -**Architecture:** 路由层采用 `StatefulShellRoute.indexedStack` 维持主分支保活;数据层新增 `core/cache` 统一缓存模块(memory + persistent + hybrid);业务层通过 repository 接入缓存策略,页面仅负责发意图和渲染状态。写操作触发精准失效,读取遵循 soft/hard TTL + minimum refresh interval。 +**Architecture:** 路由层采用“一级唯一 Home + 二级业务页 + 三级细节页”的分级返回模型,二级页面返回统一回 Home,退出入口仅 Home;数据层新增 `core/cache` 统一缓存模块(memory + persistent + hybrid);业务层通过 repository 接入缓存策略与失效器。提醒动作采用实时归档优先,pending outbox 仅用于 App 不可用场景兜底。 **Tech Stack:** Flutter, go_router, get_it, shared_preferences, flutter_test, mocktail --- +### Task 0: 锁定导航分级与退出语义(一级/二级/三级) + +**Files:** +- Modify: `apps/lib/core/router/app_router.dart` +- Modify: `apps/lib/features/home/ui/navigation/home_return_policy.dart` +- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart` +- Modify: `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart` +- Modify: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart` +- Modify: `apps/lib/features/settings/ui/screens/settings_screen.dart` +- Test: `apps/test/features/home/ui/navigation/home_return_policy_test.dart` + +**Step 1: Write the failing test** + +```dart +test('second-level pages should return to home instead of exiting app', () { + final action = resolveHomeReturnAction( + canPop: false, + isAuthEntry: false, + forceGoHome: true, + ); + expect(action, HomeReturnAction.goHome); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd apps && flutter test test/features/home/ui/navigation/home_return_policy_test.dart` +Expected: FAIL with old return behavior. + +**Step 3: Write minimal implementation** + +```dart +if (forceGoHome) return HomeReturnAction.goHome; +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd apps && flutter test test/features/home/ui/navigation/home_return_policy_test.dart` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/core/router/app_router.dart apps/lib/features/home/ui/navigation/home_return_policy.dart apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart apps/lib/features/calendar/ui/screens/calendar_month_screen.dart apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart apps/lib/features/settings/ui/screens/settings_screen.dart apps/test/features/home/ui/navigation/home_return_policy_test.dart +git commit -m "feat: enforce hierarchical back navigation and home-only exit" +``` + ### Task 1: 建立统一缓存核心模型与策略 **Files:** @@ -376,7 +423,54 @@ git add apps/lib/core/cache/cache_refresh_coordinator.dart apps/lib/main.dart ap git commit -m "feat: add app lifecycle refresh coordinator" ``` -### Task 9: 全量验证与文档同步 +### Task 9: 提醒取消实时归档与延迟归档兜底收敛 + +**Files:** +- Modify: `apps/lib/features/calendar/reminders/reminder_action_executor.dart` +- Modify: `apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart` +- Modify: `apps/lib/core/notifications/local_notification_service.dart` +- Modify: `apps/lib/main.dart` +- Modify: `apps/test/features/calendar/reminders/reminder_action_executor_test.dart` +- Modify: `apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart` + +**Step 1: Write the failing test** + +```dart +test('archive action should send remote archive immediately when app active', () async { + // Arrange active app + online gateway + // Act archive action + // Assert remote archive called once and local pending outbox not created +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd apps && flutter test test/features/calendar/reminders/reminder_action_executor_test.dart` +Expected: FAIL with delayed-only behavior. + +**Step 3: Write minimal implementation** + +```dart +if (isAppActive) { + await repository.archiveNow(eventId); +} else { + await outbox.enqueueArchive(eventId); +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd apps && flutter test test/features/calendar/reminders/reminder_action_executor_test.dart test/features/calendar/reminders/reminder_notification_bridge_test.dart` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/features/calendar/reminders/reminder_action_executor.dart apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart apps/lib/core/notifications/local_notification_service.dart apps/lib/main.dart apps/test/features/calendar/reminders/reminder_action_executor_test.dart apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart +git commit -m "fix: prioritize realtime reminder archive with cold-start fallback" +``` + +### Task 10: 全量验证与文档同步 **Files:** - Modify: `docs/protocols/*`(仅当路由/数据契约文档需更新时) @@ -409,6 +503,8 @@ Expected: No errors. 3. 日/月切换不触发无必要请求。 4. Dock Home 始终回主页。 5. 写后数据可见一致,失败可回滚提示。 +6. 二级页面侧滑返回只回 Home,不直接退出。 +7. 提醒点击取消时立刻归档;仅在 App 不可用时走 pending 兜底。 **Step 5: Commit** @@ -419,16 +515,17 @@ git commit -m "docs: finalize navigation decoupling and unified cache rollout" ## 实施顺序约束 -1. 必须先完成 Task 1-3 再改业务页面(否则会出现重复实现)。 -2. Task 5(路由壳层)与 Task 6/7(业务接入)要分开提交,便于回滚。 +1. 必须先完成 Task 0-3 再改业务页面(否则会出现重复实现)。 +2. Task 0(分级返回)与 Task 5(路由壳层)要分开提交,便于单独回滚。 3. 每个 Task 的测试必须在本 Task 完成后立即执行,避免堆积回归。 4. 不允许在未通过 focused tests 的情况下进入全量验证。 ## 回滚策略 -1. 若导航回归:回滚 Task 5 提交,保留缓存模块提交。 +1. 若返回语义回归:先回滚 Task 0 提交,再评估 Task 5。 2. 若缓存一致性异常:优先回滚 Task 6/7 的 repository 接入提交。 3. 若生命周期刷新过于频繁:关闭 Task 8 coordinator 挂载,保留手动刷新兜底。 +4. 若提醒实时归档异常:回滚 Task 9,仅保留 outbox 兜底路径。 ## Done 定义 @@ -436,3 +533,5 @@ git commit -m "docs: finalize navigation decoupling and unified cache rollout" 2. 主页按钮行为稳定,无“返回上一页”误行为。 3. 切换页面请求数明显下降,写后一致性符合设计预期。 4. 统一缓存已接管用户信息、日历、待办三域。 +5. 二级页面不再可直接侧滑退出 App。 +6. 提醒归档满足“实时优先、关闭兜底”策略。