# 日历邀请弹窗优化 Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** 优化日历邀请消息弹窗,显示完整信息(发送者名称 + 日历标题),复用公共弹窗组件 **Architecture:** - 后端新增用户信息查询接口 - 前端创建公共弹窗组件 MessageActionSheet - 日历邀请通过 scheduleItemId 获取标题,通过 senderId 获取发送者名称 **Tech Stack:** Flutter (Dart), FastAPI (Python) --- ### Task 1: 后端添加用户信息查询接口 **Files:** - Modify: `backend/src/v1/users/router.py` - Modify: `backend/src/v1/users/service.py` - Modify: `backend/src/v1/users/repository.py` **Step 1: 添加 repository 方法** 修改 `backend/src/v1/users/repository.py`,在 `UserRepository` 和 `SQLAlchemyUserRepository` 中添加: ```python async def get_by_user_id(self, user_id: UUID) -> Profile | None: ... ``` **Step 2: 添加 service 方法** 修改 `backend/src/v1/users/service.py`,添加: ```python async def get_user_by_id(self, user_id: UUID) -> UserBasicInfo: profile = await self._repository.get_by_user_id(user_id) if not profile: raise HTTPException(status_code=404, detail="User not found") return UserBasicInfo( id=str(profile.user_id), username=profile.username, avatar_url=profile.avatar_url, ) ``` **Step 3: 添加 router 接口** 修改 `backend/src/v1/users/router.py`,添加: ```python @router.get("/{user_id}", response_model=UserBasicInfo) async def get_user( user_id: UUID, service: Annotated[UserService, Depends(get_user_service)], ): return await service.get_user_by_id(user_id) ``` **Step 4: 运行 lint 和 typecheck** ```bash cd backend && uv run ruff check src/v1/users/ && uv run basedpyright src/v1/users/ ``` **Step 5: 提交** ```bash git add backend/src/v1/users/ && git commit -m "feat(users): add get user by id endpoint" ``` --- ### Task 2: 前端添加用户 API 接口 **Files:** - Modify: `apps/lib/features/users/data/users_api.dart` **Step 1: 添加 getById 方法** 修改 `apps/lib/features/users/data/users_api.dart`,添加: ```dart class UsersApi { // ... existing code Future getById(String userId) async { final response = await _client.get('$_prefix/$userId'); return UserBasicInfo.fromJson(response.data); } } class UserBasicInfo { final String id; final String username; final String? avatarUrl; factory UserBasicInfo.fromJson(Map json) { return UserBasicInfo( id: json['id'] as String, username: json['username'] as String, avatarUrl: json['avatar_url'] as String?, ); } } ``` **Step 2: 注册到 DI** 修改 `apps/lib/core/di/injection.dart`,添加: ```dart sl(); ``` **Step 3: 运行 flutter analyze** ```bash cd apps && flutter analyze lib/features/users/ ``` **Step 4: 提交** ```bash git add apps/lib/features/users/ apps/lib/core/di/injection.dart && git commit -m "feat(users): add getById API method" ``` --- ### Task 3: 创建公共弹窗组件 MessageActionSheet **Files:** - Create: `apps/lib/features/messages/ui/widgets/message_action_sheet.dart` - Modify: `apps/lib/features/messages/ui/screens/message_invite_list_screen.dart` **Step 1: 创建弹窗组件** 创建 `apps/lib/features/messages/ui/widgets/message_action_sheet.dart`: ```dart import 'package:flutter/material.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; class MessageActionSheet extends StatelessWidget { final String title; final String? description; final String? statusText; final bool isReadOnly; final VoidCallback? onAccept; final VoidCallback? onDecline; final IconData? icon; final Color? iconColor; const MessageActionSheet({ super.key, required this.title, this.description, this.statusText, this.isReadOnly = false, this.onAccept, this.onDecline, this.icon, this.iconColor, }); @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(24, 20, 24, 0), decoration: const BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 40, height: 4, decoration: BoxDecoration( color: AppColors.slate300, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 20), if (icon != null) ...[ Container( width: 72, height: 72, decoration: BoxDecoration( color: (iconColor ?? AppColors.blue500).withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon(icon, size: 32, color: iconColor ?? AppColors.blue500), ), const SizedBox(height: 16), ], Text( title, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.slate900, ), textAlign: TextAlign.center, ), if (description != null && description!.isNotEmpty) ...[ const SizedBox(height: 8), Text( description!, style: const TextStyle(fontSize: 14, color: AppColors.slate500), textAlign: TextAlign.center, ), ], if (statusText != null) ...[ const SizedBox(height: 16), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: AppColors.slate100, borderRadius: BorderRadius.circular(16), ), child: Text( statusText!, style: const TextStyle(fontSize: 14, color: AppColors.slate600), ), ), ], if (isReadOnly) ...[ const SizedBox(height: 24), ] else ...[ const SizedBox(height: 24), Row( children: [ Expanded( child: AppButton( text: '拒绝', isOutlined: true, onPressed: () { Navigator.pop(context); onDecline?.call(); }, ), ), const SizedBox(width: AppSpacing.md), Expanded( child: AppButton( text: '接受', onPressed: () { Navigator.pop(context); onAccept?.call(); }, ), ), ], ), ], const SizedBox(height: AppSpacing.xl), ], ), ); } } ``` **Step 2: 运行 flutter analyze** ```bash cd apps && flutter analyze lib/features/messages/ui/widgets/message_action_sheet.dart ``` **Step 3: 提交** ```bash git add apps/lib/features/messages/ui/widgets/message_action_sheet.dart && git commit -m "feat(messages): add MessageActionSheet component" ``` --- ### Task 4: 重构日历邀请弹窗使用公共组件 **Files:** - Modify: `apps/lib/features/messages/ui/screens/message_invite_list_screen.dart` - Modify: `apps/lib/features/messages/ui/widgets/calendar_message_card.dart` **Step 1: 添加依赖注入** 修改 `message_invite_list_screen.dart`,添加: ```dart import '../../../users/data/users_api.dart'; import '../widgets/message_action_sheet.dart'; ``` 在 `_MessageInviteListScreenState` 中添加: ```dart late final UsersApi _usersApi; ``` 在 `initState` 中添加: ```dart _usersApi = sl(); ``` **Step 2: 添加获取信息方法** 在类中添加: ```dart Future<(String calendarTitle, String senderName)?> _getCalendarInviteInfo( InboxMessageResponse message, ) async { if (message.scheduleItemId == null || message.senderId == null) { return null; } try { final calendar = await _calendarApi.getById(message.scheduleItemId!); final sender = await _usersApi.getById(message.senderId!); return (calendar.title, sender.username); } catch (e) { return null; } } ``` **Step 3: 修改 _showCalendarInviteSheet 方法** 修改 `_showCalendarInviteSheet`,使用公共组件: ```dart Future _showCalendarInviteSheet(InboxMessageResponse message) async { final itemId = message.scheduleItemId; if (itemId == null) return; final info = await _getCalendarInviteInfo(message); final title = info != null ? '${info.$2} 邀请你加入日历' : '日历邀请'; final description = info?.$1; if (!mounted) return; showModalBottomSheet( context: context, backgroundColor: Colors.transparent, builder: (ctx) => MessageActionSheet( title: title, description: description, icon: Icons.calendar_today, iconColor: AppColors.blue500, onAccept: () async { try { await _calendarApi.acceptSubscription(itemId); await _inboxApi.markAsRead(message.id); if (mounted) { Toast.show(context, '已接受', type: ToastType.success); _loadMessages(); } } catch (e) { if (mounted) { Toast.show(context, '操作失败', type: ToastType.error); } } }, onDecline: () async { try { await _calendarApi.rejectSubscription(itemId); await _inboxApi.markAsRead(message.id); if (mounted) { Toast.show(context, '已拒绝', type: ToastType.success); _loadMessages(); } } catch (e) { if (mounted) { Toast.show(context, '操作失败', type: ToastType.error); } } }, ), ); } ``` **Step 4: 添加已读日历邀请弹窗方法** 在类中添加: ```dart Future _showCalendarInviteReadOnlySheet(InboxMessageResponse message) async { final itemId = message.scheduleItemId; if (itemId == null) return; final info = await _getCalendarInviteInfo(message); final title = info != null ? '${info.$2} 邀请你加入日历' : '日历邀请'; final description = info?.$1; final statusText = message.status.value == 'accepted' ? '已接受' : '已拒绝'; if (!mounted) return; showModalBottomSheet( context: context, backgroundColor: Colors.transparent, builder: (ctx) => MessageActionSheet( title: title, description: description, statusText: statusText, isReadOnly: true, icon: Icons.calendar_today, iconColor: AppColors.blue500, ), ); } ``` **Step 5: 修改 _handleMessageTap 方法** 修改日历邀请部分的处理逻辑: ```dart case InboxMessageType.calendar: final content = _parseCalendarContent(message.content); if (content == null) return; final type = content['type'] as String?; if (type == 'invite') { if (message.status.value == 'pending') { await _showCalendarInviteSheet(message); } else { // 已读:显示弹窗,点击跳转日历 await _showCalendarInviteReadOnlySheet(message); if (message.scheduleItemId != null && context.mounted) { context.push('/calendar/events/${message.scheduleItemId}'); } } } else if (type == 'update') { if (message.scheduleItemId != null) { context.push('/calendar/events/${message.scheduleItemId}'); } } return; ``` **Step 6: 运行 flutter analyze** ```bash cd apps && flutter analyze lib/features/messages/ui/screens/message_invite_list_screen.dart ``` **Step 7: 提交** ```bash git add apps/lib/features/messages/ && git commit -m "refactor(messages): use MessageActionSheet for calendar invites" ``` --- ### Task 5: 验证和测试 **Step 1: 运行完整测试** ```bash cd apps && flutter test test/features/messages/ cd backend && uv run pytest tests/unit/v1/users/ -v ``` **Step 2: 手动测试场景** 1. 用户 A 发送日历邀请给用户 B 2. 用户 B 打开未读消息,点击日历邀请 3. 弹窗显示:"XXX 邀请你加入 [日历标题]"(发送者名称 + 日历标题) 4. 点击接受/拒绝 5. 用户 B 打开已读消息,点击日历邀请 6. 弹窗显示状态标签,点击弹窗外部跳转到日历详情页 --- ## Summary | Task | Description | |------|-------------| | 1 | 后端添加用户信息查询接口 `/api/v1/users/{user_id}` | | 2 | 前端添加 UsersApi.getById 方法 | | 3 | 创建公共弹窗组件 MessageActionSheet | | 4 | 重构日历邀请弹窗使用公共组件,获取发送者名称和日历标题 | | 5 | 验证测试 | **Plan complete and saved to `docs/plans/2026-03-11-calendar-invite-sheet.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?