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:
zl-q
2026-03-22 20:35:55 +08:00
parent 20b9e70e84
commit 80ad5141a6
37 changed files with 628 additions and 2428 deletions
@@ -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 ListQuadrants
- 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 传递 | UserDefaultsApp 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 传递 payloadAndroid 使用 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?**
+151
View File
@@ -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、不进 contextautomation 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
+4 -5
View File
@@ -27,8 +27,7 @@ Base URL: `/api/v1/agent`
- Body: `RunAgentInput`
- 详细结构见 `docs/protocols/agent/run-agent-input.md`
- `forwardedProps.agent_type` 必填,由调用方透传,task 不做默认赋值
- `agent_type=memory` 仅用于自动化调度内部触发,API 入口返回 422
- `forwardedProps.runtime_mode` 必填,值为 `"chat"``"automation"`
### Response
@@ -83,9 +82,9 @@ Base URL: `/api/v1/agent`
当前阶段执行说明:
- `worker` 模式采用两阶段:`router` -> `worker`
- `memory` 模式保持单阶段:`memory`
- 因此阶段事件可能出现 `router` / `worker` / `memory`
- `chat` 模式采用两阶段:`router` -> `worker`
- `automation` 模式由后端业务逻辑决定具体 Agent 类型
- 因此阶段事件可能出现 `router` / `worker`
### 错误码
+17 -11
View File
@@ -185,13 +185,13 @@ interface Context {
---
## forwardedProps Schema(支持 agent_type + client_time
## forwardedProps Schema(支持 runtime_mode + client_time
`RunAgentInput.forwardedProps` 支持透传运行模式与客户端时间上下文。日历相关能力必须使用以下结构:
```typescript
interface ForwardedProps {
agent_type: string; // 必填,运行模式(如 "worker" / "memory"
runtime_mode: "chat" | "automation"; // 必填,运行模式
client_time?: {
device_timezone: string; // IANA 时区,例如 "America/Los_Angeles"
client_now_iso: string; // RFC3339 带偏移时间,例如 "2026-03-16T09:12:33-07:00"
@@ -200,6 +200,13 @@ interface ForwardedProps {
}
```
### 运行模式说明
| runtime_mode | 说明 | 后端 Pipeline |
|--------------|------|---------------|
| `chat` | 标准对话模式 | `router` -> `worker` |
| `automation` | 自动化任务模式 | 由后端业务逻辑决定具体 Agent 类型 |
### 时间来源优先级(固定)
后端在运行时按以下顺序解析事件时区:
@@ -214,9 +221,8 @@ interface ForwardedProps {
- `device_timezone` 必须是有效 IANA 时区。
- `client_now_iso` 必须是 RFC3339 且包含时区偏移。
- `client_epoch_ms` 必须是整数毫秒时间戳。
- `forwardedProps.agent_type` 必填,必须匹配后端已注册的 agent type
- `agent_type=memory` 为自动化任务内部模式,HTTP `/agent/runs` 入口不接受该值
- `forwardedProps` 仅允许 `agent_type``client_time`,额外字段会触发 `422 invalid RunAgentInput.forwardedProps`
- `forwardedProps.runtime_mode` 必填,必须`"chat"``"automation"`
- `forwardedProps` 仅允许 `runtime_mode` `client_time`,额外字段会触发 `422 invalid RunAgentInput.forwardedProps`
- 业务代码不得使用服务器本地时区作为事件语义时区。
### 说明
@@ -238,7 +244,7 @@ Backend 实现了以下验证规则:
| runId 最大 128 字符 | `runId exceeds length limit` |
| messages ≤ 200 条 | `RunAgentInput.messages exceeds limit` |
| user text ≤ 10,000 字符 | `RunAgentInput user message text exceeds limit` |
| forwardedProps.agent_type 必填 | `invalid RunAgentInput.forwardedProps` |
| forwardedProps.runtime_mode 必填 | `invalid RunAgentInput.forwardedProps` |
| **恰好 1 条 user message** | `RunAgentInput.messages must contain exactly one user message` |
| user message 必须在第一条 | `RunAgentInput.messages[0].role must be user` |
| binary 必须是 image/* | `binary content requires image mimeType` |
@@ -277,7 +283,7 @@ Backend 实现了以下验证规则:
"tools": [],
"context": [],
"forwardedProps": {
"agent_type": "worker"
"runtime_mode": "chat"
}
}
```
@@ -309,7 +315,7 @@ Backend 实现了以下验证规则:
"tools": [],
"context": [],
"forwardedProps": {
"agent_type": "worker"
"runtime_mode": "chat"
}
}
```
@@ -346,7 +352,7 @@ Backend 实现了以下验证规则:
],
"context": [],
"forwardedProps": {
"agent_type": "worker"
"runtime_mode": "chat"
}
}
```
@@ -368,7 +374,7 @@ Backend 实现了以下验证规则:
"tools": [],
"context": [],
"forwardedProps": {
"agent_type": "worker",
"runtime_mode": "chat",
"client_time": {
"device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16T09:12:33-07:00",
@@ -531,5 +537,5 @@ interface UiSchemaRenderer {
- `tools` 是前端工具通道字段;当前后端运行时不基于该字段构造后端工具 prompt
- `RunAgentInput` 同时接受 camelCase 与 snake_case 别名输入(推荐统一使用 camelCase)
- 日历能力依赖 `forwardedProps.client_time` 透传设备时间上下文;缺失时回退用户 profile 时区
- `forwardedProps.agent_type` 是受控路由字段,必须由调用方显式传入;后端 task 不做默认赋值
- `forwardedProps.runtime_mode` 是受控路由字段,必须由调用方显式传入;后端 task 不做默认赋值
- tool 消息在存储层用于运行时上下文续接,不在 `/history` 对外返回