refactor(agent): restructure visibility masks, task queues, and memory service
Visibility mask refactoring: - Replace dead UI_REALTIME bit with CONTEXT_ASSEMBLY (bit 1) - Remove visibility_consumer_bit from SystemAgentLLMConfig and system_agents.yaml - Simplify _resolve_user_message_visibility_mask: chat->UI_HISTORY|CONTEXT_ASSEMBLY, automation->0 - Simplify _resolve_stage_visibility_mask: memory->UI_HISTORY, router/worker->UI_HISTORY|CONTEXT_ASSEMBLY - Remove stage_visibility_bit_map from store.py Task queue renaming: - Replace default_broker/bulk_broker/critical_broker with worker_agent_broker/worker_automation_broker - Queue names: 'default'/'bulk'/'critical' -> 'agent'/'automation' - Rename run_command_task -> run_command_task_agent/run_command_task_automation - AgentService derives queue from runtime_mode: chat->agent, automation->automation Architecture cleanup: - Move context_service.py from runtime/ to agentscope/services/ - Add MemoryService in v1/memory/ following repository/service pattern - Move consumer_registry.py and pipeline_spec.py from schemas/agent to agentscope/schemas/ - Delete dead code: registry_builder.py, VisibilityBitRef - Delete superseded plan docs
This commit is contained in:
@@ -1,310 +0,0 @@
|
||||
# 前端导航解耦与统一缓存重构设计
|
||||
|
||||
## 1. 背景与问题定义
|
||||
|
||||
当前 `apps` 端在日历(日/月)与待办页面中存在以下系统性问题:
|
||||
|
||||
1. 页面切换语义错误:将业务 tab 切换实现为 `push/go` 混用,导致页面重建与路由栈膨胀。
|
||||
2. 数据刷新触发错误:页面通过路由监听触发 `load`,频繁重复请求后端。
|
||||
3. 状态职责耦合:导航状态、页面状态、数据状态边界不清,导致“切换逻辑改动会牵出数据 bug”。
|
||||
4. 回主页语义不一致:Dock 首页按钮被 `canPop -> pop` 策略污染,行为变成“返回上页”。
|
||||
5. 缓存能力分散:仅存在局部的个人信息缓存(`SettingsUserCache`),缺少统一可复用缓存模块。
|
||||
|
||||
目标是完成一次结构化重构,建立「解耦的导航切换 + 统一缓存 + 可控一致性」体系。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
1. Home/Calendar/Todo 切换不重建主页面(保持页面实例与滚动状态)。
|
||||
2. 日/月视图切换不触发整页重建和无必要网络请求。
|
||||
3. 建立统一缓存模块,合并个人信息缓存并覆盖 Calendar/Todo 数据读取。
|
||||
4. 启动体验采用「本地优先 + 后台静默刷新」策略,减少进入 App 的重复请求。
|
||||
5. 数据只在必要时刷新:手动下拉、写操作失效、生命周期关键点、缓存策略命中。
|
||||
6. 主页按钮语义固定为“回主页”,不再变成“返回上一页”。
|
||||
7. 一级页面唯一为 Home,日历日/月视图、待办、设置均为二级页面;二级页面侧滑只允许返回一级页面,不允许直接退出 App。
|
||||
8. App 退出入口仅存在于一级页面(Home)。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
1. 本次不改后端协议与接口契约。
|
||||
2. 本次不引入复杂离线同步冲突解决(如多端 CRDT)。
|
||||
3. 本次不引入全量本地数据库迁移(先基于 SharedPreferences 持久化层)。
|
||||
|
||||
## 3. 复杂度与风险分级
|
||||
|
||||
- Complexity: `S3`
|
||||
- 跨 router、calendar、todo、settings、DI 的架构级调整。
|
||||
- Risk Tier: `L1`
|
||||
- 不触及鉴权协议和支付等高风险域,但涉及导航返回栈与数据一致性高回归区。
|
||||
|
||||
## 4. 架构总览
|
||||
|
||||
### 4.1 导航分层
|
||||
|
||||
采用分级导航:
|
||||
|
||||
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 状态与数据边界
|
||||
|
||||
1. 导航状态:Shell 当前分支 index、Calendar 内部视图类型。
|
||||
2. UI 状态:选中日期、滚动位置、拖拽态、loading/error。
|
||||
3. 数据状态:统一缓存模块管理(内存 + 持久化 + 网络回写)。
|
||||
|
||||
结论:页面只发“意图”,不直接承担缓存与路由策略。
|
||||
|
||||
## 5. 统一缓存模块设计
|
||||
|
||||
## 5.1 模块结构
|
||||
|
||||
新增 `apps/lib/core/cache/`:
|
||||
|
||||
1. `cache_key.dart`
|
||||
- 统一 key 命名规范。
|
||||
2. `cache_policy.dart`
|
||||
- TTL、软/硬过期、最小刷新间隔、刷新原因枚举。
|
||||
3. `cache_entry.dart`
|
||||
- 标准缓存实体(data/fetchedAt/expiresAt/version/dirty)。
|
||||
4. `cache_store.dart`
|
||||
- 抽象接口(get/set/remove/invalidateNamespace)。
|
||||
5. `memory_cache_store.dart`
|
||||
- 会话级热缓存。
|
||||
6. `persistent_cache_store.dart`
|
||||
- 本地冷缓存(SharedPreferences JSON)。
|
||||
7. `hybrid_cache_store.dart`
|
||||
- 两级缓存协调与 singleflight 去重。
|
||||
8. `cache_invalidator.dart`
|
||||
- 统一精准失效入口。
|
||||
|
||||
### 5.2 key 设计(首版)
|
||||
|
||||
1. 用户信息
|
||||
- `user:profile:me`
|
||||
2. 日历
|
||||
- `calendar:day:YYYY-MM-DD`
|
||||
- `calendar:month:YYYY-MM`
|
||||
3. 待办
|
||||
- `todo:list:pending`
|
||||
- `todo:list:priority:<n>`(按需)
|
||||
- `todo:detail:<id>`(按需)
|
||||
|
||||
### 5.3 策略设计(平衡型)
|
||||
|
||||
读取顺序:`memory -> persistent -> network`。
|
||||
|
||||
刷新策略:
|
||||
|
||||
1. 软过期(stale-while-revalidate)
|
||||
- 先展示缓存,后台静默刷新。
|
||||
2. 硬过期
|
||||
- 超过硬过期后必须请求网络或提示数据过旧。
|
||||
3. 最小刷新间隔
|
||||
- 避免频繁切换/回前台引发抖动请求。
|
||||
|
||||
建议默认值:
|
||||
|
||||
1. `user:profile`:软过期 30min,硬过期 24h。
|
||||
2. `calendar:day`:软过期 2min,硬过期 30min。
|
||||
3. `calendar:month`:软过期 5min,硬过期 60min。
|
||||
4. `todo:list:pending`:软过期 2min,硬过期 30min。
|
||||
|
||||
### 5.4 个人信息缓存合并方案
|
||||
|
||||
现有 `SettingsUserCache` 并入统一缓存模块:
|
||||
|
||||
1. 新建 `UserProfileRepository`(或在现有 settings service 中引入统一缓存)。
|
||||
2. `getProfile()` 通过 hybrid cache 获取 `user:profile:me`。
|
||||
3. 更新 profile 成功后立即写回缓存并同步持久化。
|
||||
4. 登出/会话失效时统一调用 `invalidateNamespace('user')`。
|
||||
|
||||
## 6. 一致性风险与解决方案
|
||||
|
||||
平衡型缓存会存在“短暂陈旧窗口”。本设计通过以下机制将体验风险降到可接受范围。
|
||||
|
||||
### 6.1 触发刷新矩阵
|
||||
|
||||
1. 手动下拉刷新:强制网络刷新。
|
||||
2. 写操作成功:精准失效受影响 key 并触发回填。
|
||||
3. App 回前台:若超过最小刷新间隔,触发静默刷新。
|
||||
4. 网络离线 -> 在线:触发静默刷新。
|
||||
5. 进入关键详情页:按策略进行 freshness check。
|
||||
|
||||
### 6.2 写后一致性
|
||||
|
||||
1. 乐观更新:本地先更新 UI 与缓存,避免“我刚改完却没变”。
|
||||
2. 失败回滚:API 失败时恢复旧值并 Toast 提示。
|
||||
3. 精准失效:不做全局清空,只失效关联 key,兼顾一致性与性能。
|
||||
|
||||
### 6.3 并发安全
|
||||
|
||||
1. singleflight:同 key 同时只允许一个网络请求。
|
||||
2. 版本保护:缓存写入比较 `updatedAt/version`,拒绝旧响应覆盖新状态。
|
||||
3. 失败兜底:请求失败不清空旧缓存,保持可读并允许重试。
|
||||
|
||||
### 6.4 可见性保障
|
||||
|
||||
1. 页面可显示“上次同步时间”(轻提示)。
|
||||
2. 硬过期数据需可见提醒(弱提示,不阻断基础浏览)。
|
||||
3. 提供稳定手动刷新入口。
|
||||
|
||||
### 6.5 日历提醒取消动作的一致性兜底
|
||||
|
||||
1. 用户在提醒弹层点击“取消/归档”时,前端必须立即发送归档请求,要求后端立刻将事件归档/过期。
|
||||
2. “延迟归档(outbox/pending)”仅在 App 进程不可用(被杀/未启动)时生效,作为离线或冷启动兜底。
|
||||
3. App 冷启动或恢复前台后,必须优先冲刷 pending 归档请求,确保最终一致性。
|
||||
4. 对用户可见行为要求:点击取消后 UI 立即反映归档状态,网络失败时展示重试提示,并保留 pending 记录。
|
||||
|
||||
## 7. 导航与页面职责重构
|
||||
|
||||
### 7.1 路由重构
|
||||
|
||||
1. `app_router` 引入 shell 分支,不再平铺所有主页面。
|
||||
2. Dock 切换改为 branch index 切换,不再 `push` 主页面。
|
||||
3. Calendar 内部 month/day 切换改为视图切换,不新增栈层。
|
||||
4. 事件详情/编辑等保留 `push`(细节页合理叠栈)。
|
||||
|
||||
### 7.2 回主页逻辑修正
|
||||
|
||||
1. Dock Home 统一执行“切到 Home 分支/`go('/home')`”。
|
||||
2. `returnToHomePreserveState` 仅用于非 Dock 的返回策略场景。
|
||||
3. 消除 `canPop -> pop` 对主页按钮语义的影响。
|
||||
4. 二级页面(Calendar Day/Month、Todo、Settings)统一拦截系统返回和侧滑返回,目标固定为 Home。
|
||||
5. App 退出只允许在 Home 页面生效(可采用双击退出或系统默认行为)。
|
||||
|
||||
### 7.3 页面职责收敛
|
||||
|
||||
1. Calendar/Todo 页面移除路由监听触发 `load`。
|
||||
2. 页面只调用 repository:
|
||||
- `get(policy)`
|
||||
- `refresh(force: true)`
|
||||
- `mutate(...) + invalidate(...)`
|
||||
3. 页面不直接感知“缓存在哪一层”。
|
||||
|
||||
## 8. 分阶段实施计划(里程碑)
|
||||
|
||||
### M1 导航壳层与切换语义
|
||||
|
||||
1. 引入 shell + 分支保活。
|
||||
2. Dock 接口改造与主 tab 切换实现。
|
||||
3. Home 按钮语义修正。
|
||||
4. 建立分级返回约束:二级 -> Home,三级 -> 上一级,退出仅 Home。
|
||||
|
||||
### M2 统一缓存骨架
|
||||
|
||||
1. 新增 core cache 模块。
|
||||
2. 接入 user profile(替换 `SettingsUserCache`)。
|
||||
3. DI 注入 cache store 与 invalidator。
|
||||
|
||||
### M3 Calendar 接入
|
||||
|
||||
1. 引入 `CalendarRepository` 与 day/month key。
|
||||
2. 移除 route listener 自动刷新。
|
||||
3. 切换 month/day 时默认走缓存,不触发无必要请求。
|
||||
|
||||
### M4 Todo 接入
|
||||
|
||||
1. 引入 `TodoRepository` 与 list/detail key。
|
||||
2. 拖拽、完成、编辑后的精准失效。
|
||||
3. 下拉刷新走强制网络。
|
||||
|
||||
### M5 清理与验证
|
||||
|
||||
1. 清理旧缓存与重复加载逻辑。
|
||||
2. 补齐测试与性能观测。
|
||||
3. 评估参数并收敛默认策略。
|
||||
4. 验证提醒“点击取消即实时归档”与“App 关闭时延迟归档兜底”双路径。
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
### 9.1 体验验收
|
||||
|
||||
1. Home/Calendar/Todo 切换无明显重建卡顿。
|
||||
2. 日/月切换响应明显变快。
|
||||
3. 首次冷启动可先看到本地缓存内容。
|
||||
4. Dock Home 始终回主页。
|
||||
5. 二级页面侧滑返回永远回 Home,不直接退出 App。
|
||||
|
||||
### 9.2 网络验收
|
||||
|
||||
1. 切换页面时网络请求显著减少。
|
||||
2. 写操作后关联数据可及时更新。
|
||||
3. 手动刷新可强制拉取并回写缓存。
|
||||
4. 提醒取消动作触发实时归档请求,成功率可观测。
|
||||
|
||||
### 9.3 一致性验收
|
||||
|
||||
1. 不出现旧响应覆盖新数据。
|
||||
2. 离线后恢复在线可自动静默同步。
|
||||
3. 软过期/硬过期行为符合策略定义。
|
||||
4. 提醒归档在在线/离线/冷启动场景下保持最终一致。
|
||||
|
||||
## 10. 测试与验证计划
|
||||
|
||||
### 10.1 单元测试
|
||||
|
||||
1. `hybrid_cache_store`:命中链路、singleflight、软硬过期判定。
|
||||
2. `cache_invalidator`:写操作触发的 key 精准失效。
|
||||
3. repository:读缓存、后台刷新、失败兜底、版本保护。
|
||||
|
||||
### 10.2 组件/页面测试(高回归)
|
||||
|
||||
1. Dock 切换不重建分支主页面。
|
||||
2. 日/月切换不重复触发全量加载。
|
||||
3. Home 按钮行为稳定。
|
||||
4. 二级页面系统返回不会触发 App 退出。
|
||||
|
||||
### 10.3 集成回归
|
||||
|
||||
1. Calendar -> Todo -> Calendar 多轮切换请求计数。
|
||||
2. Todo 完成后列表更新与缓存一致性。
|
||||
3. profile 更新后设置页/其他依赖页可见一致。
|
||||
4. 提醒取消 -> 立即归档 -> 日历列表刷新链路。
|
||||
5. App 杀进程后触发提醒,重启后 pending 归档自动冲刷。
|
||||
|
||||
## 11. 风险与回滚
|
||||
|
||||
### 11.1 主要风险
|
||||
|
||||
1. 导航壳层改造可能引发深链与返回栈回归。
|
||||
2. 缓存策略参数不当可能造成陈旧感。
|
||||
3. 早期失效 key 设计不完整可能出现局部不刷新。
|
||||
|
||||
### 11.2 控制策略
|
||||
|
||||
1. 按里程碑逐步落地,每个里程碑可单独回滚。
|
||||
2. 默认保留手动刷新兜底。
|
||||
3. 增加请求计数与缓存命中日志(开发态)。
|
||||
|
||||
### 11.3 回滚策略
|
||||
|
||||
1. 若 M1 不稳定,可先回退 shell 改造并保留缓存模块。
|
||||
2. 若缓存接入问题集中,可按域回退(user/calendar/todo 分域开关)。
|
||||
|
||||
## 12. 最终落地参数(2026-03-20)
|
||||
|
||||
1. 导航分级
|
||||
- 一级页面唯一为 `Home`。
|
||||
- 二级页面(日/月、待办、设置)侧滑返回统一回 `Home`,不允许直接退出 App。
|
||||
- App 退出入口仅保留在 `Home`。
|
||||
2. 缓存默认策略
|
||||
- `user:profile`:软过期 30min,硬过期 24h。
|
||||
- `calendar:day`:软过期 2min,硬过期 30min。
|
||||
- `calendar:month`:软过期 5min,硬过期 60min。
|
||||
- `todo:list:pending`:软过期 2min,硬过期 30min。
|
||||
3. 生命周期刷新
|
||||
- App 回前台时启用最小间隔 5min 的静默刷新协调器。
|
||||
4. 提醒归档策略
|
||||
- App 活跃态点击取消:立即请求后端归档。
|
||||
- 延迟归档(pending/outbox)仅用于 App 不可用场景兜底。
|
||||
@@ -1,537 +0,0 @@
|
||||
# 前端导航解耦与统一缓存重构 Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 完成导航分级回退(一级唯一 Home)与统一缓存改造,实现本地优先显示、后台静默刷新、写后精准失效,并落地“提醒取消即实时归档 + App 关闭时延迟归档兜底”。
|
||||
|
||||
**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:**
|
||||
- Create: `apps/lib/core/cache/cache_entry.dart`
|
||||
- Create: `apps/lib/core/cache/cache_key.dart`
|
||||
- Create: `apps/lib/core/cache/cache_policy.dart`
|
||||
- Test: `apps/test/core/cache/cache_policy_test.dart`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/cache/cache_policy.dart';
|
||||
|
||||
void main() {
|
||||
test('soft expired should allow stale read with background refresh', () {
|
||||
final now = DateTime(2026, 3, 20, 12);
|
||||
final policy = CachePolicy(
|
||||
softTtl: const Duration(minutes: 2),
|
||||
hardTtl: const Duration(minutes: 30),
|
||||
minRefreshInterval: const Duration(minutes: 1),
|
||||
);
|
||||
|
||||
final fetchedAt = now.subtract(const Duration(minutes: 3));
|
||||
final decision = policy.evaluate(now: now, fetchedAt: fetchedAt);
|
||||
expect(decision.canUseCached, true);
|
||||
expect(decision.shouldRefreshInBackground, true);
|
||||
expect(decision.mustBlockForNetwork, false);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd apps && flutter test test/core/cache/cache_policy_test.dart`
|
||||
Expected: FAIL with missing cache policy symbols.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```dart
|
||||
class CacheDecision {
|
||||
final bool canUseCached;
|
||||
final bool shouldRefreshInBackground;
|
||||
final bool mustBlockForNetwork;
|
||||
const CacheDecision({
|
||||
required this.canUseCached,
|
||||
required this.shouldRefreshInBackground,
|
||||
required this.mustBlockForNetwork,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd apps && flutter test test/core/cache/cache_policy_test.dart`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/core/cache/cache_entry.dart apps/lib/core/cache/cache_key.dart apps/lib/core/cache/cache_policy.dart apps/test/core/cache/cache_policy_test.dart
|
||||
git commit -m "feat: add unified cache policy primitives"
|
||||
```
|
||||
|
||||
### Task 2: 实现 memory/persistent/hybrid cache store(含 singleflight)
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/lib/core/cache/cache_store.dart`
|
||||
- Create: `apps/lib/core/cache/memory_cache_store.dart`
|
||||
- Create: `apps/lib/core/cache/persistent_cache_store.dart`
|
||||
- Create: `apps/lib/core/cache/hybrid_cache_store.dart`
|
||||
- Test: `apps/test/core/cache/hybrid_cache_store_test.dart`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```dart
|
||||
test('same key concurrent load should execute loader once', () async {
|
||||
var calls = 0;
|
||||
final store = HybridCacheStore(...);
|
||||
Future<String> loader() async {
|
||||
calls += 1;
|
||||
return 'ok';
|
||||
}
|
||||
await Future.wait([
|
||||
store.getOrLoad<String>('k', loader: loader),
|
||||
store.getOrLoad<String>('k', loader: loader),
|
||||
]);
|
||||
expect(calls, 1);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd apps && flutter test test/core/cache/hybrid_cache_store_test.dart`
|
||||
Expected: FAIL with missing HybridCacheStore.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```dart
|
||||
final Map<String, Future<dynamic>> _inflight = {};
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd apps && flutter test test/core/cache/hybrid_cache_store_test.dart`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/core/cache/cache_store.dart apps/lib/core/cache/memory_cache_store.dart apps/lib/core/cache/persistent_cache_store.dart apps/lib/core/cache/hybrid_cache_store.dart apps/test/core/cache/hybrid_cache_store_test.dart
|
||||
git commit -m "feat: implement hybrid cache store with singleflight"
|
||||
```
|
||||
|
||||
### Task 3: 接入 DI 与统一失效器
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/lib/core/cache/cache_invalidator.dart`
|
||||
- Modify: `apps/lib/core/di/injection.dart`
|
||||
- Test: `apps/test/core/cache/cache_invalidator_test.dart`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```dart
|
||||
test('invalidate calendar day should also invalidate month key', () {
|
||||
final inv = CacheInvalidator(...);
|
||||
inv.invalidateCalendarDay(DateTime(2026, 3, 20));
|
||||
expect(inv.wasInvalidated('calendar:day:2026-03-20'), true);
|
||||
expect(inv.wasInvalidated('calendar:month:2026-03'), true);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd apps && flutter test test/core/cache/cache_invalidator_test.dart`
|
||||
Expected: FAIL.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```dart
|
||||
class CacheInvalidator {
|
||||
void invalidateCalendarDay(DateTime date) { /* invalidate day + month */ }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd apps && flutter test test/core/cache/cache_invalidator_test.dart`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/core/cache/cache_invalidator.dart apps/lib/core/di/injection.dart apps/test/core/cache/cache_invalidator_test.dart
|
||||
git commit -m "refactor: wire unified cache and invalidator in di"
|
||||
```
|
||||
|
||||
### Task 4: 合并个人信息缓存(替换 SettingsUserCache)
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/settings/data/services/settings_user_cache.dart`
|
||||
- Create: `apps/lib/features/settings/data/services/user_profile_cache_repository.dart`
|
||||
- Modify: `apps/lib/features/settings/ui/screens/settings_screen.dart`
|
||||
- Test: `apps/test/features/settings/data/services/settings_user_cache_test.dart`
|
||||
- Create: `apps/test/features/settings/data/services/user_profile_cache_repository_test.dart`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```dart
|
||||
test('repository should return persistent cache first then refresh in background', () async {
|
||||
// Arrange cached profile in persistent store
|
||||
// Assert immediate cached result + refresh called once
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd apps && flutter test test/features/settings/data/services/user_profile_cache_repository_test.dart`
|
||||
Expected: FAIL.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```dart
|
||||
class UserProfileCacheRepository {
|
||||
Future<UserResponse> getProfile({bool forceRefresh = false}) async { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd apps && flutter test test/features/settings/data/services/settings_user_cache_test.dart test/features/settings/data/services/user_profile_cache_repository_test.dart`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/settings/data/services/settings_user_cache.dart apps/lib/features/settings/data/services/user_profile_cache_repository.dart apps/lib/features/settings/ui/screens/settings_screen.dart apps/test/features/settings/data/services/settings_user_cache_test.dart apps/test/features/settings/data/services/user_profile_cache_repository_test.dart
|
||||
git commit -m "refactor: merge profile cache into unified cache repository"
|
||||
```
|
||||
|
||||
### Task 5: 路由改造为 StatefulShellRoute + Dock 切换分支
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/core/router/app_router.dart`
|
||||
- Modify: `apps/lib/core/router/app_routes.dart`
|
||||
- Modify: `apps/lib/features/calendar/ui/widgets/bottom_dock.dart`
|
||||
- Modify: `apps/lib/features/home/ui/navigation/home_return_policy.dart`
|
||||
- Test: `apps/test/core/router/app_routes_test.dart`
|
||||
- Modify: `apps/test/features/home/ui/navigation/home_return_policy_test.dart`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```dart
|
||||
test('dock home action should always resolve to goHome', () {
|
||||
final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false);
|
||||
expect(action, HomeReturnAction.goHomeForDock);
|
||||
});
|
||||
```
|
||||
|
||||
**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 behavior.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```dart
|
||||
enum HomeReturnAction { pop, goHome, goHomeForDock }
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd apps && flutter test test/core/router/app_routes_test.dart 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/core/router/app_routes.dart apps/lib/features/calendar/ui/widgets/bottom_dock.dart apps/lib/features/home/ui/navigation/home_return_policy.dart apps/test/core/router/app_routes_test.dart apps/test/features/home/ui/navigation/home_return_policy_test.dart
|
||||
git commit -m "feat: switch main navigation to stateful shell tabs"
|
||||
```
|
||||
|
||||
### Task 6: Calendar repository 化并移除路由监听刷新
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/lib/features/calendar/data/services/calendar_repository.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/calendar/ui/calendar_state_manager.dart`
|
||||
- Create: `apps/test/features/calendar/data/services/calendar_repository_test.dart`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```dart
|
||||
test('getDayEvents returns cache immediately and refreshes in background', () async {
|
||||
// Arrange cache day key
|
||||
// Assert cached list emitted before network completion
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd apps && flutter test test/features/calendar/data/services/calendar_repository_test.dart`
|
||||
Expected: FAIL.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```dart
|
||||
class CalendarRepository {
|
||||
Future<List<ScheduleItemModel>> getDayEvents(DateTime date, {bool forceRefresh = false}) async { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd apps && flutter test test/features/calendar/data/services/calendar_repository_test.dart`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/calendar/data/services/calendar_repository.dart apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart apps/lib/features/calendar/ui/screens/calendar_month_screen.dart apps/lib/features/calendar/ui/calendar_state_manager.dart apps/test/features/calendar/data/services/calendar_repository_test.dart
|
||||
git commit -m "refactor: decouple calendar screens from route-driven reload"
|
||||
```
|
||||
|
||||
### Task 7: Todo repository 化与写后精准失效
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/lib/features/todo/data/todo_repository.dart`
|
||||
- Modify: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart`
|
||||
- Modify: `apps/lib/features/todo/data/todo_api.dart`
|
||||
- Create: `apps/test/features/todo/todo_repository_test.dart`
|
||||
- Modify: `apps/test/features/todo/quadrant_drag_test.dart`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```dart
|
||||
test('complete todo should optimistically update and invalidate pending list key', () async {
|
||||
// assert local list updated first, invalidator called
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd apps && flutter test test/features/todo/todo_repository_test.dart`
|
||||
Expected: FAIL.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```dart
|
||||
class TodoRepository {
|
||||
Future<void> completeTodo(String id) async { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd apps && flutter test test/features/todo/todo_repository_test.dart test/features/todo/quadrant_drag_test.dart`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/todo/data/todo_repository.dart apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart apps/lib/features/todo/data/todo_api.dart apps/test/features/todo/todo_repository_test.dart apps/test/features/todo/quadrant_drag_test.dart
|
||||
git commit -m "feat: add todo cache repository and precise invalidation"
|
||||
```
|
||||
|
||||
### Task 8: App 生命周期与网络恢复刷新策略
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/lib/core/cache/cache_refresh_coordinator.dart`
|
||||
- Modify: `apps/lib/main.dart`
|
||||
- Create: `apps/test/core/cache/cache_refresh_coordinator_test.dart`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```dart
|
||||
test('resume should trigger refresh only when min interval elapsed', () {
|
||||
// Arrange last refreshed timestamp
|
||||
// Assert callback invocation count
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd apps && flutter test test/core/cache/cache_refresh_coordinator_test.dart`
|
||||
Expected: FAIL.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```dart
|
||||
class CacheRefreshCoordinator with WidgetsBindingObserver { ... }
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd apps && flutter test test/core/cache/cache_refresh_coordinator_test.dart`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/core/cache/cache_refresh_coordinator.dart apps/lib/main.dart apps/test/core/cache/cache_refresh_coordinator_test.dart
|
||||
git commit -m "feat: add app lifecycle refresh coordinator"
|
||||
```
|
||||
|
||||
### 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/*`(仅当路由/数据契约文档需更新时)
|
||||
- Modify: `docs/plans/2026-03-20-navigation-cache-decoupling-design.md`(回填最终参数)
|
||||
|
||||
**Step 1: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd apps && flutter test test/core/cache test/features/settings/data/services/settings_user_cache_test.dart test/features/calendar test/features/todo test/features/home/ui/navigation/home_return_policy_test.dart test/core/router/app_routes_test.dart
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 2: Run app-level verification**
|
||||
|
||||
Run: `cd apps && flutter test`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 3: Static checks**
|
||||
|
||||
Run: `cd apps && flutter analyze`
|
||||
Expected: No errors.
|
||||
|
||||
**Step 4: Manual verification checklist**
|
||||
|
||||
1. 冷启动先显示缓存,随后静默更新。
|
||||
2. Home/Calendar/Todo 来回切换不重建主页面。
|
||||
3. 日/月切换不触发无必要请求。
|
||||
4. Dock Home 始终回主页。
|
||||
5. 写后数据可见一致,失败可回滚提示。
|
||||
6. 二级页面侧滑返回只回 Home,不直接退出。
|
||||
7. 提醒点击取消时立刻归档;仅在 App 不可用时走 pending 兜底。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-03-20-navigation-cache-decoupling-design.md docs/protocols
|
||||
git commit -m "docs: finalize navigation decoupling and unified cache rollout"
|
||||
```
|
||||
|
||||
## 实施顺序约束
|
||||
|
||||
1. 必须先完成 Task 0-3 再改业务页面(否则会出现重复实现)。
|
||||
2. Task 0(分级返回)与 Task 5(路由壳层)要分开提交,便于单独回滚。
|
||||
3. 每个 Task 的测试必须在本 Task 完成后立即执行,避免堆积回归。
|
||||
4. 不允许在未通过 focused tests 的情况下进入全量验证。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
1. 若返回语义回归:先回滚 Task 0 提交,再评估 Task 5。
|
||||
2. 若缓存一致性异常:优先回滚 Task 6/7 的 repository 接入提交。
|
||||
3. 若生命周期刷新过于频繁:关闭 Task 8 coordinator 挂载,保留手动刷新兜底。
|
||||
4. 若提醒实时归档异常:回滚 Task 9,仅保留 outbox 兜底路径。
|
||||
|
||||
## Done 定义
|
||||
|
||||
1. 所有测试与 analyze 通过。
|
||||
2. 主页按钮行为稳定,无“返回上一页”误行为。
|
||||
3. 切换页面请求数明显下降,写后一致性符合设计预期。
|
||||
4. 统一缓存已接管用户信息、日历、待办三域。
|
||||
5. 二级页面不再可直接侧滑退出 App。
|
||||
6. 提醒归档满足“实时优先、关闭兜底”策略。
|
||||
@@ -1,221 +0,0 @@
|
||||
# Reminder Overlay 设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
重构日历提醒机制,简化前台/后台判断逻辑,将所有提醒交互统一到独立的 ReminderOverlay 组件处理。
|
||||
|
||||
## 背景
|
||||
|
||||
当前实现复杂,涉及:
|
||||
- App 启动状态判断(前台/后台)
|
||||
- 离线归档请求队列 + 指数退避重试
|
||||
- 通知权限降级(Android Timer 模拟)
|
||||
- 聚合通知批量操作
|
||||
|
||||
新方案利用 iOS/Android 原生通知分组能力,实现:
|
||||
- 每条通知独立 payload,点击哪条处理哪条
|
||||
- 统一的 ReminderOverlay 处理所有用户交互
|
||||
- 操作完成后 app 退到后台
|
||||
|
||||
## 设计决策
|
||||
|
||||
| 决策项 | 选择 |
|
||||
|--------|------|
|
||||
| 关闭 overlay 后的行为 | 回到首页,保持缓存状态 |
|
||||
| 同分钟多条通知处理 | 按点击顺序处理当前,剩余按时间排序 |
|
||||
| iOS 冷启动 payload 传递 | UserDefaults(App Groups 方案) |
|
||||
| Android 通知展示 | Full-screen intent(锁屏也弹窗) |
|
||||
| 稍后提醒时间选项 | 5 分钟、15 分钟(下拉选项) |
|
||||
| "完成"按钮行为 | 归档 + 关闭 + 退后台 |
|
||||
| "稍后提醒"按钮行为 | 弹出选项 + 延后通知 + 关闭 + 退后台 |
|
||||
| UI 组件 | 新建 ReminderOverlay(不复用现有) |
|
||||
|
||||
## 核心流程
|
||||
|
||||
```
|
||||
通知到达 → 用户点击通知 →
|
||||
├─ App 已运行 → 恢复前台 → 直接收到 payload → 打开 ReminderOverlay
|
||||
└─ App 未运行 →
|
||||
├─ iOS: 原生层写入 UserDefaults → Flutter 启动时读取
|
||||
└─ Android: full-screen intent 启动 → Flutter 收到 payload
|
||||
|
||||
ReminderOverlay 显示:
|
||||
- 日程标题
|
||||
- 当前时间
|
||||
- [稍后提醒 ▼] | [完成]
|
||||
|
||||
用户操作:
|
||||
├─ 完成 → 归档请求 → 关闭 overlay → 退后台
|
||||
└─ 稍后提醒 → 选择时间 → 取消当前通知 + 注册新通知 → 关闭 overlay → 退后台
|
||||
|
||||
处理完当前 → 检查同分钟是否有多条 →
|
||||
├─ 有 → 打开下一条的 ReminderOverlay
|
||||
└─ 无 → 保持退后台状态
|
||||
```
|
||||
|
||||
## 移除的组件
|
||||
|
||||
| 组件 | 文件路径 | 移除原因 |
|
||||
|------|----------|----------|
|
||||
| ReminderColdStartQueue | `lib/features/calendar/reminders/reminder_cold_start_queue.dart` | 不需要后台重放机制 |
|
||||
| ReminderOutboxStore | `lib/features/calendar/reminders/reminder_outbox_store.dart` | 不需要离线归档队列 |
|
||||
| ReminderForegroundPresenter | `lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart` | 不需要前台判断 |
|
||||
| ReminderPresentationCoordinator | `lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart` | 不需要防重复展示 |
|
||||
| ReminderActionDedupeStore | `lib/features/calendar/reminders/reminder_action_dedupe_store.dart` | 通知原生幂等 |
|
||||
| ReminderOverlapPolicy | `lib/features/calendar/reminders/reminder_overlap_policy.dart` | 改为原生分组 |
|
||||
| Android Timer 模拟逻辑 | `LocalNotificationService` 内 | 不需要权限降级 |
|
||||
|
||||
## 新增组件
|
||||
|
||||
### ReminderOverlay
|
||||
|
||||
独立的状态管理页面,处理提醒交互。
|
||||
|
||||
**职责**:
|
||||
- 显示日程标题和当前时间
|
||||
- 提供"稍后提醒"下拉选项(5分钟/15分钟)
|
||||
- 提供"完成"按钮(归档)
|
||||
- 处理完成后关闭 overlay
|
||||
|
||||
**文件位置**:`lib/features/calendar/reminders/ui/reminder_overlay.dart`
|
||||
|
||||
### ReminderQueueManager
|
||||
|
||||
管理同分钟多条通知的处理队列。
|
||||
|
||||
**职责**:
|
||||
- 存储同分钟的通知列表
|
||||
- 按点击顺序跟踪当前处理项
|
||||
- 处理完当前后调度下一项
|
||||
|
||||
**文件位置**:`lib/features/calendar/reminders/reminder_queue_manager.dart`
|
||||
|
||||
### IOSNotificationPayloadBridge
|
||||
|
||||
iOS 冷启动时从 UserDefaults 读取 notification payload。
|
||||
|
||||
**职责**:
|
||||
- App 启动时检查是否有待处理的通知 launch
|
||||
- 读取 payload 并打开对应的 ReminderOverlay
|
||||
- 处理完成后清理 UserDefaults
|
||||
|
||||
**文件位置**:`lib/core/notifications/ios_notification_payload_bridge.dart`
|
||||
|
||||
## 平台差异处理
|
||||
|
||||
### iOS
|
||||
|
||||
1. **通知点击启动 App**:
|
||||
- 配置 `setPluginRegistrantCallback`(已有)
|
||||
- iOS 原生层将 payload 写入 UserDefaults
|
||||
- Flutter 启动时 `IOSNotificationPayloadBridge` 读取数据
|
||||
|
||||
2. **通知分组**:
|
||||
- 使用 `threadIdentifier` 分组
|
||||
- 同一分钟的通知使用相同的 `threadIdentifier`
|
||||
|
||||
### Android
|
||||
|
||||
1. **Full-screen intent**:
|
||||
- `AndroidNotificationDetails` 设置 `fullScreenIntent: true`
|
||||
- 锁屏时直接弹出全屏 overlay
|
||||
|
||||
2. **通知分组**:
|
||||
- 使用 `groupKey` 分组
|
||||
- 同一分钟的通知使用相同的 `groupKey`
|
||||
|
||||
## API 变化
|
||||
|
||||
### 归档请求
|
||||
|
||||
仍然使用现有的 `CalendarService.archiveEvent()`,但不再需要失败重试逻辑。
|
||||
|
||||
```
|
||||
POST /api/v1/calendar/events/{eventId}/archive
|
||||
```
|
||||
|
||||
### 通知 Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"eventId": "evt_xxx",
|
||||
"title": "日程标题",
|
||||
"startAt": "2026-03-20T10:00:00Z",
|
||||
"endAt": "2026-03-20T11:00:00Z",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"mode": "single",
|
||||
"fireTimeBucket": 1774060800000
|
||||
}
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
### 通知发送流程(不变)
|
||||
|
||||
```
|
||||
CalendarService.upsertEventReminder()
|
||||
→ LocalNotificationService.upsertEventReminder()
|
||||
→ flutter_local_notifications.zonedSchedule()
|
||||
```
|
||||
|
||||
### 通知点击处理流程
|
||||
|
||||
```
|
||||
用户点击通知
|
||||
├─ App 运行中 → onDidReceiveNotificationResponse(payload)
|
||||
└─ App 未运行
|
||||
├─ iOS → 原生写入 UserDefaults → Flutter 启动 → 读取 → 打开 overlay
|
||||
└─ Android → full-screen intent → Flutter 收到 payload → 打开 overlay
|
||||
|
||||
ReminderOverlay 打开
|
||||
├─ 用户点击"完成" → archiveEvent() → 关闭 → 检查队列 → 有下一条则打开下一条
|
||||
└─ 用户点击"稍后提醒" → cancelNotification() + scheduleReminderAt() → 关闭 → 检查队列 → 有下一条则打开下一条
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 归档请求失败 | 显示 toast 提示用户,操作已完成(下次打开 app 时同步) |
|
||||
| 延后通知注册失败 | 显示 toast 提示用户,当前提醒已取消 |
|
||||
| 同分钟多条处理时其中一条失败 | 跳过该条,处理下一条 |
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
### 删除
|
||||
|
||||
- `lib/features/calendar/reminders/reminder_cold_start_queue.dart`
|
||||
- `lib/features/calendar/reminders/reminder_outbox_store.dart`
|
||||
- `lib/features/calendar/reminders/reminder_action_dedupe_store.dart`
|
||||
- `lib/features/calendar/reminders/reminder_overlap_policy.dart`
|
||||
- `lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart`
|
||||
- `lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart`
|
||||
- `lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart`
|
||||
- 相关测试文件
|
||||
|
||||
### 新增
|
||||
|
||||
- `lib/features/calendar/reminders/ui/reminder_overlay.dart`
|
||||
- `lib/features/calendar/reminders/reminder_queue_manager.dart`
|
||||
- `lib/core/notifications/ios_notification_payload_bridge.dart`
|
||||
|
||||
### 修改
|
||||
|
||||
- `lib/core/notifications/local_notification_service.dart`(移除权限降级逻辑)
|
||||
- `lib/main.dart`(集成 IOSNotificationPayloadBridge)
|
||||
- 相关测试文件
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试
|
||||
- ReminderQueueManager: 队列排序、下一条调度
|
||||
- IOSNotificationPayloadBridge: payload 读写
|
||||
|
||||
### 集成测试
|
||||
- 通知点击 → overlay 打开 → 操作 → 关闭
|
||||
- 同分钟多条通知顺序处理
|
||||
|
||||
### 手动测试
|
||||
- iOS 冷启动点击通知
|
||||
- Android 锁屏点击 full-screen intent 通知
|
||||
- 稍后提醒 5 分钟/15 分钟验证
|
||||
@@ -1,760 +0,0 @@
|
||||
# Reminder Overlay Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 简化日历提醒机制,用 ReminderOverlay 统一处理所有用户交互,移除前台/后台判断逻辑。
|
||||
|
||||
**Architecture:** 每条通知独立 payload,点击通知后打开 ReminderOverlay 处理用户操作(完成/稍后提醒)。同分钟多条通知按时间排序依次处理。操作完成后 app 退到后台。iOS 冷启动通过 UserDefaults 传递 payload,Android 使用 full-screen intent。
|
||||
|
||||
**Tech Stack:** Flutter, flutter_local_notifications, Provider/Bloc (状态管理)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 创建 ReminderQueueManager
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/lib/features/calendar/reminders/reminder_queue_manager.dart`
|
||||
- Test: `apps/test/features/calendar/reminders/reminder_queue_manager_test.dart`
|
||||
|
||||
**Step 1: 编写测试**
|
||||
|
||||
```dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart';
|
||||
import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart';
|
||||
|
||||
void main() {
|
||||
group('ReminderQueueManager', () {
|
||||
test('按点击顺序处理,第一条处理完后处理剩余的按时间排序', () {
|
||||
final manager = ReminderQueueManager();
|
||||
|
||||
final event1 = ReminderPayload(eventId: '1', title: 'Event 1', startAt: DateTime(2026, 3, 20, 10, 1), mode: ReminderPayloadMode.single);
|
||||
final event2 = ReminderPayload(eventId: '2', title: 'Event 2', startAt: DateTime(2026, 3, 20, 10, 2), mode: ReminderPayloadMode.single);
|
||||
final event3 = ReminderPayload(eventId: '3', title: 'Event 3', startAt: DateTime(2026, 3, 20, 10, 3), mode: ReminderPayloadMode.single);
|
||||
|
||||
// 用户点击 event2
|
||||
manager.enqueueFromClick(event2);
|
||||
// 剩余 event1 和 event3 按时间排序: event1 -> event3
|
||||
manager.enqueuePending([event1, event3]);
|
||||
|
||||
expect(manager.currentPayload?.eventId, '2');
|
||||
manager.dequeueCurrent();
|
||||
expect(manager.currentPayload?.eventId, '1');
|
||||
manager.dequeueCurrent();
|
||||
expect(manager.currentPayload?.eventId, '3');
|
||||
manager.dequeueCurrent();
|
||||
expect(manager.isEmpty, true);
|
||||
});
|
||||
|
||||
test('单条通知处理完直接清空', () {
|
||||
final manager = ReminderQueueManager();
|
||||
final event = ReminderPayload(eventId: '1', title: 'Event 1', startAt: DateTime.now(), mode: ReminderPayloadMode.single);
|
||||
|
||||
manager.enqueueFromClick(event);
|
||||
expect(manager.isEmpty, false);
|
||||
manager.dequeueCurrent();
|
||||
expect(manager.isEmpty, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: 运行测试验证失败**
|
||||
|
||||
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_queue_manager_test.dart`
|
||||
Expected: FAIL - ReminderQueueManager not defined
|
||||
|
||||
**Step 3: 编写最小实现**
|
||||
|
||||
```dart
|
||||
import 'reminder_payload.dart';
|
||||
|
||||
class ReminderQueueManager {
|
||||
ReminderPayload? _currentPayload;
|
||||
final List<ReminderPayload> _pending = [];
|
||||
|
||||
void enqueueFromClick(ReminderPayload payload) {
|
||||
_currentPayload = payload;
|
||||
}
|
||||
|
||||
void enqueuePending(List<ReminderPayload> payloads) {
|
||||
payloads.sort((a, b) => a.startAt.compareTo(b.startAt));
|
||||
_pending.addAll(payloads);
|
||||
}
|
||||
|
||||
ReminderPayload? get currentPayload => _currentPayload;
|
||||
|
||||
bool get isEmpty => _currentPayload == null && _pending.isEmpty;
|
||||
|
||||
void dequeueCurrent() {
|
||||
_currentPayload = null;
|
||||
if (_pending.isNotEmpty) {
|
||||
_currentPayload = _pending.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_currentPayload = null;
|
||||
_pending.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: 运行测试验证通过**
|
||||
|
||||
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_queue_manager_test.dart`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/calendar/reminders/reminder_queue_manager.dart apps/test/features/calendar/reminders/reminder_queue_manager_test.dart
|
||||
git commit -m "feat(calendar): add ReminderQueueManager for handling multiple notifications"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建 IOSNotificationPayloadBridge
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/lib/core/notifications/ios_notification_payload_bridge.dart`
|
||||
- Test: `apps/test/core/notifications/ios_notification_payload_bridge_test.dart`
|
||||
|
||||
**Step 1: 编写测试**
|
||||
|
||||
```dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:social_app/core/notifications/ios_notification_payload_bridge.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
void main() {
|
||||
group('IOSNotificationPayloadBridge', () {
|
||||
test('启动时读取待处理的 notification payload', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'pending_notification_payload': jsonEncode({
|
||||
'eventId': 'evt_123',
|
||||
'title': 'Test Event',
|
||||
'startAt': '2026-03-20T10:00:00Z',
|
||||
'mode': 'single',
|
||||
}),
|
||||
});
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final bridge = IOSNotificationPayloadBridge(prefs);
|
||||
final payload = await bridge.getPendingPayload();
|
||||
|
||||
expect(payload?.eventId, 'evt_123');
|
||||
expect(payload?.title, 'Test Event');
|
||||
});
|
||||
|
||||
test('处理完成后清理 UserDefaults', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'pending_notification_payload': jsonEncode({
|
||||
'eventId': 'evt_123',
|
||||
'title': 'Test Event',
|
||||
'startAt': '2026-03-20T10:00:00Z',
|
||||
'mode': 'single',
|
||||
}),
|
||||
});
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final bridge = IOSNotificationPayloadBridge(prefs);
|
||||
await bridge.clearPendingPayload();
|
||||
|
||||
final remaining = prefs.getString('pending_notification_payload');
|
||||
expect(remaining, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: 运行测试验证失败**
|
||||
|
||||
Run: `cd apps && flutter test test/core/notifications/ios_notification_payload_bridge_test.dart`
|
||||
Expected: FAIL - IOSNotificationPayloadBridge not defined
|
||||
|
||||
**Step 3: 编写最小实现**
|
||||
|
||||
```dart
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../features/calendar/reminders/models/reminder_payload.dart';
|
||||
|
||||
class IOSNotificationPayloadBridge {
|
||||
static const String _key = 'pending_notification_payload';
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
IOSNotificationPayloadBridge(this._prefs);
|
||||
|
||||
Future<ReminderPayload?> getPendingPayload() async {
|
||||
final raw = _prefs.getString(_key);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final json = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||
return ReminderPayload.fromJson(json);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearPendingPayload() async {
|
||||
await _prefs.remove(_key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: 运行测试验证通过**
|
||||
|
||||
Run: `cd apps && flutter test test/core/notifications/ios_notification_payload_bridge_test.dart`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/lib/core/notifications/ios_notification_payload_bridge.dart apps/test/core/notifications/ios_notification_payload_bridge_test.dart
|
||||
git commit -m "feat(notifications): add IOSNotificationPayloadBridge for cold start handling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 创建 ReminderOverlay UI 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/lib/features/calendar/reminders/ui/reminder_overlay.dart`
|
||||
- Test: `apps/test/features/calendar/reminders/reminder_overlay_test.dart`
|
||||
|
||||
**Step 1: 编写测试**
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:social_app/features/calendar/reminders/ui/reminder_overlay.dart';
|
||||
import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart';
|
||||
import 'package:social_app/core/notifications/local_notification_service.dart';
|
||||
import 'package:social_app/core/notifications/ios_notification_payload_bridge.dart';
|
||||
import 'package:social_app/features/calendar/data/services/calendar_service.dart';
|
||||
import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart';
|
||||
import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart';
|
||||
|
||||
void main() {
|
||||
group('ReminderOverlay', () {
|
||||
late ReminderQueueManager queueManager;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
queueManager = ReminderQueueManager();
|
||||
});
|
||||
|
||||
testWidgets('显示日程标题和当前时间', (tester) async {
|
||||
final payload = ReminderPayload(
|
||||
eventId: '1',
|
||||
title: 'Test Meeting',
|
||||
startAt: DateTime(2026, 3, 20, 10, 0),
|
||||
mode: ReminderPayloadMode.single,
|
||||
);
|
||||
queueManager.enqueueFromClick(payload);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ReminderOverlay(
|
||||
queueManager: queueManager,
|
||||
onComplete: () {},
|
||||
onSnooze: (minutes) {},
|
||||
onArchive: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Test Meeting'), findsOneWidget);
|
||||
// 当前时间显示在界面上
|
||||
expect(find.textContaining(':'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('点击完成按钮触发归档', (tester) async {
|
||||
bool archiveCalled = false;
|
||||
final payload = ReminderPayload(
|
||||
eventId: '1',
|
||||
title: 'Test Meeting',
|
||||
startAt: DateTime(2026, 3, 20, 10, 0),
|
||||
mode: ReminderPayloadMode.single,
|
||||
);
|
||||
queueManager.enqueueFromClick(payload);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ReminderOverlay(
|
||||
queueManager: queueManager,
|
||||
onComplete: () {},
|
||||
onSnooze: (minutes) {},
|
||||
onArchive: () => archiveCalled = true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('完成'));
|
||||
await tester.pump();
|
||||
|
||||
expect(archiveCalled, true);
|
||||
});
|
||||
|
||||
testWidgets('点击稍后提醒显示下拉选项', (tester) async {
|
||||
final payload = ReminderPayload(
|
||||
eventId: '1',
|
||||
title: 'Test Meeting',
|
||||
startAt: DateTime(2026, 3, 20, 10, 0),
|
||||
mode: ReminderPayloadMode.single,
|
||||
);
|
||||
queueManager.enqueueFromClick(payload);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ReminderOverlay(
|
||||
queueManager: queueManager,
|
||||
onComplete: () {},
|
||||
onSnooze: (minutes) {},
|
||||
onArchive: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('稍后提醒'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('5 分钟'), findsOneWidget);
|
||||
expect(find.text('15 分钟'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: 运行测试验证失败**
|
||||
|
||||
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_overlay_test.dart`
|
||||
Expected: FAIL - ReminderOverlay not defined
|
||||
|
||||
**Step 3: 编写最小实现**
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../reminders/reminder_queue_manager.dart';
|
||||
import '../../reminders/models/reminder_payload.dart';
|
||||
|
||||
class ReminderOverlay extends StatefulWidget {
|
||||
const ReminderOverlay({
|
||||
super.key,
|
||||
required this.queueManager,
|
||||
required this.onComplete,
|
||||
required this.onSnooze,
|
||||
required this.onArchive,
|
||||
});
|
||||
|
||||
final ReminderQueueManager queueManager;
|
||||
final VoidCallback onComplete;
|
||||
final void Function(int minutes) onSnooze;
|
||||
final VoidCallback onArchive;
|
||||
|
||||
@override
|
||||
State<ReminderOverlay> createState() => _ReminderOverlayState();
|
||||
}
|
||||
|
||||
class _ReminderOverlayState extends State<ReminderOverlay> {
|
||||
bool _showSnoozeOptions = false;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
OverlayEntry? _overlayEntry;
|
||||
|
||||
ReminderPayload? get _currentPayload => widget.queueManager.currentPayload;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideSnoozeOptions();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _hideSnoozeOptions() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
setState(() {
|
||||
_showSnoozeOptions = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _showSnoozeDropdown() {
|
||||
_hideSnoozeOptions();
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
width: 120,
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_SnoozeOption(
|
||||
label: '5 分钟',
|
||||
onTap: () {
|
||||
_hideSnoozeOptions();
|
||||
widget.onSnooze(5);
|
||||
},
|
||||
),
|
||||
Divider(height: 1, color: AppColors.borderSecondary),
|
||||
_SnoozeOption(
|
||||
label: '15 分钟',
|
||||
onTap: () {
|
||||
_hideSnoozeOptions();
|
||||
widget.onSnooze(15);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
setState(() {
|
||||
_showSnoozeOptions = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleComplete() {
|
||||
widget.onArchive();
|
||||
widget.queueManager.dequeueCurrent();
|
||||
if (!widget.queueManager.isEmpty) {
|
||||
// 下一条会通过外部状态管理打开新的 overlay
|
||||
}
|
||||
widget.onComplete();
|
||||
}
|
||||
|
||||
void _handleSnooze(int minutes) {
|
||||
widget.onSnooze(minutes);
|
||||
widget.queueManager.dequeueCurrent();
|
||||
if (!widget.queueManager.isEmpty) {
|
||||
// 下一条会通过外部状态管理打开新的 overlay
|
||||
}
|
||||
widget.onComplete();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final payload = _currentPayload;
|
||||
if (payload == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: AppColors.white,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
payload.title,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: AppColors.slate900,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
DateFormat('HH:mm').format(DateTime.now()),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: AppButton(
|
||||
text: '稍后提醒',
|
||||
isOutlined: true,
|
||||
onPressed: _showSnoozeDropdown,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
text: '完成',
|
||||
onPressed: _handleComplete,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SnoozeOption extends StatelessWidget {
|
||||
const _SnoozeOption({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: 运行测试验证通过**
|
||||
|
||||
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_overlay_test.dart`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/calendar/reminders/ui/reminder_overlay.dart apps/test/features/calendar/reminders/reminder_overlay_test.dart
|
||||
git commit -m "feat(calendar): add ReminderOverlay UI component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 修改 LocalNotificationService - 移除权限降级逻辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/core/notifications/local_notification_service.dart`
|
||||
|
||||
**Step 1: 阅读现有代码确认移除范围**
|
||||
|
||||
Read: `apps/lib/core/notifications/local_notification_service.dart`
|
||||
|
||||
**Step 2: 移除以下逻辑**
|
||||
|
||||
1. 移除 `_canDeliverSystemNotification` 相关判断
|
||||
2. 移除 `_scheduleInAppFallbackRemindersFrom` 方法
|
||||
3. 移除 `_scheduleInAppFallbackPayload` 方法
|
||||
4. 移除 `_inAppFallbackTimersByEventId` 及相关方法
|
||||
5. 移除 `_trackFallback` 方法
|
||||
6. 移除 `rebuildUpcomingReminders` 中的降级分支
|
||||
|
||||
**Step 3: 验证 flutter analyze 通过**
|
||||
|
||||
Run: `cd apps && flutter analyze lib/core/notifications/local_notification_service.dart`
|
||||
Expected: 无错误
|
||||
|
||||
**Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/lib/core/notifications/local_notification_service.dart
|
||||
git commit -m "refactor(notifications): remove permission fallback logic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 修改通知发送逻辑 - 使用原生分组
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/core/notifications/local_notification_service.dart`
|
||||
|
||||
**Step 1: 添加 threadIdentifier/groupKey 到通知详情**
|
||||
|
||||
在 `_buildNotificationDetails` 方法中:
|
||||
- iOS: 添加 `threadIdentifier: _getThreadIdentifier(fireAt)`
|
||||
- Android: 添加 `groupKey: _getGroupKey(fireAt)`
|
||||
|
||||
**Step 2: 实现分组 key 方法**
|
||||
|
||||
```dart
|
||||
String _getThreadIdentifier(DateTime fireAt) {
|
||||
final bucket = fireAt.millisecondsSinceEpoch ~/ const Duration(minutes: 1).inMilliseconds;
|
||||
return 'calendar_reminder_$bucket';
|
||||
}
|
||||
|
||||
String _getGroupKey(DateTime fireAt) {
|
||||
final bucket = fireAt.millisecondsSinceEpoch ~/ const Duration(minutes: 1).inMilliseconds;
|
||||
return 'com.socialapp.calendar.$bucket';
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: 验证 flutter analyze 通过**
|
||||
|
||||
Run: `cd apps && flutter analyze lib/core/notifications/local_notification_service.dart`
|
||||
Expected: 无错误
|
||||
|
||||
**Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/lib/core/notifications/local_notification_service.dart
|
||||
git commit -m "feat(notifications): add native notification grouping by time bucket"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 修改 main.dart - 集成 iOS payload bridge
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/main.dart`
|
||||
|
||||
**Step 1: 在 main() 中添加启动时检查**
|
||||
|
||||
在 `runApp` 之前或 app 初始化时:
|
||||
1. 创建 IOSNotificationPayloadBridge 实例
|
||||
2. 调用 `getPendingPayload()`
|
||||
3. 如果有 payload,通过 ReminderQueueManager 处理
|
||||
|
||||
**Step 2: 验证 flutter analyze 通过**
|
||||
|
||||
Run: `cd apps && flutter analyze lib/main.dart`
|
||||
Expected: 无错误
|
||||
|
||||
**Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/lib/main.dart
|
||||
git commit -m "feat(ios): integrate IOSNotificationPayloadBridge for cold start handling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 删除废弃组件
|
||||
|
||||
**Files:**
|
||||
- Delete: `apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart`
|
||||
- Delete: `apps/lib/features/calendar/reminders/reminder_outbox_store.dart`
|
||||
- Delete: `apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart`
|
||||
- Delete: `apps/lib/features/calendar/reminders/reminder_overlap_policy.dart`
|
||||
- Delete: `apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart`
|
||||
- Delete: `apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart`
|
||||
- Delete: `apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart`
|
||||
|
||||
**Step 1: 删除文件**
|
||||
|
||||
```bash
|
||||
git rm apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart
|
||||
git rm apps/lib/features/calendar/reminders/reminder_outbox_store.dart
|
||||
git rm apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart
|
||||
git rm apps/lib/features/calendar/reminders/reminder_overlap_policy.dart
|
||||
git rm apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart
|
||||
git rm apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart
|
||||
git rm apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart
|
||||
```
|
||||
|
||||
**Step 2: 删除测试文件**
|
||||
|
||||
```bash
|
||||
git rm apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart
|
||||
git rm apps/test/features/calendar/reminders/reminder_outbox_store_test.dart
|
||||
git rm apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart
|
||||
git rm apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart
|
||||
git rm apps/test/features/calendar/reminders/reminder_foreground_presenter_test.dart
|
||||
git rm apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart
|
||||
git rm apps/test/features/calendar/reminders/reminder_action_sheet_test.dart
|
||||
```
|
||||
|
||||
**Step 3: 运行 flutter analyze 验证无引用错误**
|
||||
|
||||
Run: `cd apps && flutter analyze`
|
||||
Expected: 无错误(可能有 deprecated warnings 可以忽略)
|
||||
|
||||
**Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor(calendar): remove deprecated reminder components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: iOS 原生层配置 (AppDelegate.swift)
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/ios/Runner/AppDelegate.swift`
|
||||
|
||||
**Step 1: 添加 UserDefaults 写入逻辑**
|
||||
|
||||
在 `userNotificationCenter(_, didReceive, withCompletionHandler)` 中:
|
||||
1. 获取 notification 的 `userInfo`
|
||||
2. 将 payload 写入 `UserDefaults.standard`
|
||||
3. Key: `pending_notification_payload`
|
||||
|
||||
**Step 2: 验证 Xcode build**
|
||||
|
||||
Run: `cd apps && flutter build ios --simulator --no-codesign 2>&1 | head -50`
|
||||
Expected: Build 成功
|
||||
|
||||
**Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/ios/Runner/AppDelegate.swift
|
||||
git commit -m "feat(ios): write notification payload to UserDefaults on cold start"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
完成所有任务后,运行以下验证:
|
||||
|
||||
```bash
|
||||
# 1. Flutter analyze
|
||||
cd apps && flutter analyze
|
||||
|
||||
# 2. 测试
|
||||
cd apps && flutter test test/features/calendar/reminders/
|
||||
|
||||
# 3. iOS build
|
||||
cd apps && flutter build ios --simulator --no-codesign
|
||||
|
||||
# 4. Android build
|
||||
cd apps && flutter build apk --debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plan Complete
|
||||
|
||||
**Plan saved to:** `docs/plans/2026-03-20-reminder-overlay-implementation-plan.md`
|
||||
|
||||
**Two execution options:**
|
||||
|
||||
**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
|
||||
|
||||
**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
|
||||
|
||||
**Which approach?**
|
||||
@@ -0,0 +1,151 @@
|
||||
# 可见性掩码重构方案
|
||||
|
||||
> 日期:2026-03-22
|
||||
> 状态:待执行
|
||||
|
||||
## 背景
|
||||
|
||||
现有可见性系统存在以下问题:
|
||||
- `UI_REALTIME` 定义但从未使用
|
||||
- `visibility_consumer_bit` 语义模糊,用于 context 过滤但无法正确区分 chat/automation
|
||||
- stage bits (16/17/18) 在 chat/automation 永不共享 thread 的设计下无意义
|
||||
- 无法正确实现:automation user_message 不进 /history、不进 context,automation agent_reply 进 /history 但不进 context
|
||||
|
||||
## 设计目标
|
||||
|
||||
| runtime_mode | 消息 | /history 可见 | context_messages 组装 |
|
||||
|-------------|------|:-------------:|:-------------------:|
|
||||
| chat | user_message | ✅ | ✅ |
|
||||
| chat | agent_reply | ✅ | ✅ |
|
||||
| automation | user_message | ❌ | ❌ |
|
||||
| automation | agent_reply | ✅ | ❌ |
|
||||
|
||||
## 前提条件
|
||||
|
||||
- chat 和 automation **永不共享 thread_id**(已确认的设计约束)
|
||||
- memory == automation,无需单独处理
|
||||
|
||||
---
|
||||
|
||||
## Bit 定义
|
||||
|
||||
```
|
||||
BIT 0 → UI_HISTORY → /history API 可见
|
||||
BIT 1 → CONTEXT_ASSEMBLY → 组装进 context_messages
|
||||
```
|
||||
|
||||
> `UI_REALTIME` 废弃,删除。
|
||||
> `visibility_consumer_bit` 废弃,删除。
|
||||
> Stage bits (16/17/18) 废弃,删除。
|
||||
|
||||
---
|
||||
|
||||
## 消息 Mask 矩阵
|
||||
|
||||
| 消息 | runtime_mode | UI_HISTORY | CONTEXT_ASSEMBLY | Mask |
|
||||
|------|-------------|:----------:|:---------------:|:----:|
|
||||
| user_message | chat | 1 | 1 | **3** |
|
||||
| user_message | automation | 0 | 0 | **0** |
|
||||
| agent_reply | chat | 1 | 1 | **3** |
|
||||
| agent_reply | automation | 1 | 0 | **1** |
|
||||
|
||||
---
|
||||
|
||||
## 查询设计
|
||||
|
||||
| 查询 | Mask | 匹配规则 |
|
||||
|------|------|---------|
|
||||
| `/history` | `UI_HISTORY = 1` | `(message_mask & 1) != 0` |
|
||||
| `context_messages` | `CONTEXT_ASSEMBLY = 2` | `(message_mask & 2) != 0` |
|
||||
|
||||
---
|
||||
|
||||
## 查询结果验证
|
||||
|
||||
| 消息 | Mask | `/history & 1` | `/history` | `context & 2` | `context` |
|
||||
|------|------|:-------------:|:----------:|:-------------:|:---------:|
|
||||
| chat user_message | 3 | 1 ✅ | ✅ | 1 ✅ | ✅ |
|
||||
| chat agent_reply | 3 | 1 ✅ | ✅ | 1 ✅ | ✅ |
|
||||
| automation user_message | 0 | 0 ❌ | ❌ | 0 ❌ | ❌ |
|
||||
| automation agent_reply | 1 | 1 ✅ | ✅ | 0 ❌ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 变更清单
|
||||
|
||||
### 1. `schemas/agent/visibility.py`
|
||||
|
||||
- [ ] 删除 `UI_REALTIME = 1` 从 `SystemVisibilityBit`
|
||||
- [ ] 删除 `VisibilityBitRef` 类
|
||||
- [ ] 保留 `bit_mask()` 函数
|
||||
- [ ] 保留 `VisibilityMask` 类(其他模块可能使用)
|
||||
|
||||
### 2. `schemas/agent/system_agent.py`
|
||||
|
||||
- [ ] 删除 `SystemAgentLLMConfig.visibility_consumer_bit` 字段
|
||||
|
||||
### 3. `core/config/static/database/system_agents.yaml`
|
||||
|
||||
- [ ] 删除 `router.visibility_consumer_bit`
|
||||
- [ ] 删除 `worker.visibility_consumer_bit`
|
||||
|
||||
### 4. `v1/agent/service.py`
|
||||
|
||||
- [ ] 重写 `_resolve_user_message_visibility_mask`:
|
||||
```python
|
||||
async def _resolve_user_message_visibility_mask(
|
||||
self, *, runtime_mode: RuntimeMode
|
||||
) -> int:
|
||||
if runtime_mode == RuntimeMode.CHAT:
|
||||
return UI_HISTORY | CONTEXT_ASSEMBLY # = 3
|
||||
return 0 # automation user_message
|
||||
```
|
||||
|
||||
### 5. `core/agentscope/events/store.py`
|
||||
|
||||
- [ ] 重写 `_resolve_stage_visibility_mask`:
|
||||
- chat stage (router/worker) → `UI_HISTORY | CONTEXT_ASSEMBLY` = 3
|
||||
- automation stage (memory) → `UI_HISTORY` = 1
|
||||
- [ ] 删除 `_load_stage_visibility_bit_map` 中对 `visibility_consumer_bit` 的依赖
|
||||
- [ ] 删除 `system_agents.yaml` 配置加载逻辑
|
||||
|
||||
### 6. `core/agentscope/runtime/context_service.py`
|
||||
|
||||
- [ ] `load_context_messages` 查询 mask 改为 `CONTEXT_ASSEMBLY = 2`
|
||||
```python
|
||||
visibility_mask = bit_mask(bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY))
|
||||
```
|
||||
|
||||
### 7. `core/agentscope/runtime/tasks.py`
|
||||
|
||||
- [ ] 删除 `_build_recent_context_messages` 中 memory job 的特殊处理
|
||||
- [ ] memory mode 改用 `runtime_mode=automation` 语义
|
||||
|
||||
### 8. `core/agentscope/runtime/runner.py`
|
||||
|
||||
- [ ] 删除硬编码 `visibility_consumer_bit=18` 的 `SystemAgentLLMConfig`
|
||||
- [ ] memory agent 配置改用 automation 语义
|
||||
|
||||
### 9. 清理迁移
|
||||
|
||||
- [ ] 更新 `schemas/agent/__init__.py` 导出(删除 `visibility_consumer_bit` 相关)
|
||||
- [ ] 更新所有引用 `visibility_consumer_bit` 的文件
|
||||
- [ ] 运行测试验证 /history 和 context 组装行为
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 新增 `CONTEXT_ASSEMBLY = 1` bit,更新 `service.py`
|
||||
2. 更新 `events/store.py` 可见性逻辑
|
||||
3. 更新 `context_service.py` 查询 mask
|
||||
4. 清理废弃配置和字段
|
||||
5. 运行测试验证
|
||||
|
||||
---
|
||||
|
||||
## 风险
|
||||
|
||||
- `VisibilityBitRef` 可能在其他未知位置使用(需全面搜索)
|
||||
- `visibility_consumer_bit` 被 `runner.py` 硬编码,修改可能影响 memory pipeline
|
||||
- 测试覆盖不足可能导致 regression
|
||||
Reference in New Issue
Block a user