feat(apps): refine login consent and calendar day/month UX

This commit is contained in:
qzl
2026-03-20 19:00:24 +08:00
parent 2e94908eaa
commit fcf98b1142
11 changed files with 359 additions and 58 deletions
@@ -130,6 +130,7 @@ class LoginCubit extends Cubit<LoginState> {
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<LoginState> {
errorMessage: null,
),
);
_startResendCooldown();
return true;
} catch (e) {
if (isClosed) {
@@ -54,6 +54,12 @@ class _LoginViewState extends State<LoginView> {
}
Future<void> _handleLogin() async {
if (!_agreedToTerms) {
final confirmed = await _showAgreementDialog();
if (!confirmed || !mounted) return;
setState(() => _agreedToTerms = true);
}
final cubit = context.read<LoginCubit>();
cubit.phoneChanged(_phoneController.text);
cubit.codeChanged(_codeController.text);
@@ -70,12 +76,6 @@ class _LoginViewState extends State<LoginView> {
}
Future<void> _handleSendCode() async {
if (!_agreedToTerms) {
final confirmed = await _showAgreementDialog();
if (!confirmed || !mounted) return;
setState(() => _agreedToTerms = true);
}
final cubit = context.read<LoginCubit>();
cubit.phoneChanged(_phoneController.text);
final sent = await cubit.sendCode();
@@ -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<ScheduleItemModel> addEvent(ScheduleItemModel event) async {
return _api.create(event);
final created = await _api.create(event);
_invalidateEventCache(created);
return created;
}
Future<ScheduleItemModel> updateEvent(ScheduleItemModel event) async {
return _api.update(event);
final updated = await _api.update(event);
_invalidateEventCache(updated);
return updated;
}
Future<ScheduleItemModel?> 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<CacheInvalidator>();
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<void> deleteEvent(String id) async {
final event = await getEventById(id);
if (event != null) {
_invalidateEventCache(event);
}
await _api.delete(id);
}
}
@@ -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,
),
];
}
}
@@ -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}) {
@@ -41,6 +41,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
final DayEventLayoutEngine _layoutEngine = const DayEventLayoutEngine();
final Map<int, Offset> _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<CalendarDayWeekScreen>
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToSelectedDate();
_scrollTimelineToNow();
});
}
@@ -91,6 +93,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_dayStripController.dispose();
_timelineController.dispose();
super.dispose();
}
@@ -125,6 +128,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
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<CalendarDayWeekScreen>
_calendarManager.setSelectedDate(today);
_updateMonthDates();
_scrollToSelectedDate(animate: true);
_scrollTimelineToNow(animate: true);
_loadEvents();
}
@@ -194,6 +199,10 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
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<CalendarDayWeekScreen>
_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<CalendarDayWeekScreen>
scale: _scale,
eventAreaLeft: eventAreaLeft,
eventAreaWidth: eventAreaWidth,
viewDate: _selectedDate,
);
return SizedBox(
@@ -521,19 +554,17 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
}) {
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<CalendarDayWeekScreen>
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isDisabled ? AppColors.slate300 : AppColors.slate400,
color: AppColors.slate400,
),
),
),
@@ -609,10 +640,16 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
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
@@ -320,6 +320,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
const SizedBox(height: AppSpacing.xs),
Text(
timeRange,
maxLines: 2,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
@@ -507,12 +508,21 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
}
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) {
@@ -107,6 +107,9 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
}
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<CalendarMonthScreen>
),
),
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<CalendarMonthScreen>
);
}
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<CalendarMonthScreen>
'${AppRoutes.calendarDayWeek}?date=${formatYmd(date)}',
);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOut,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
@@ -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,
@@ -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 ListQuadrants
- 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. 风险与回滚
@@ -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. 提醒归档满足“实时优先、关闭兜底”策略。