Files
social-app/docs/plans/2026-03-11-calendar-invite-sheet.md
T

584 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 日历邀请弹窗优化 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 优化日历邀请消息弹窗,显示完整信息(发送者名称 + 日历标题),使用公共弹窗组件替代所有旧弹窗代码
**Architecture:**
- 后端新增用户信息查询接口
- 前端创建公共弹窗组件 MessageActionSheet
- 删除所有旧的弹窗代码(好友请求、日历邀请),统一使用公共组件
**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` 中已有 `get_by_user_id` 方法,确认存在。
**Step 2: 添加 service 方法**
修改 `backend/src/v1/users/service.py`,添加:
```python
async def get_user_by_id(self, user_id: UUID) -> UserBasicInfo:
from v1.friendships.schemas import 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`
- Modify: `apps/lib/core/di/injection.dart`
**Step 1: 添加 UserBasicInfo 类和 getById 方法**
修改 `apps/lib/features/users/data/users_api.dart`
```dart
class UserBasicInfo {
final String id;
final String username;
final String? avatarUrl;
UserBasicInfo({
required this.id,
required this.username,
this.avatarUrl,
});
factory UserBasicInfo.fromJson(Map<String, dynamic> json) {
return UserBasicInfo(
id: json['id'] as String,
username: json['username'] as String,
avatarUrl: json['avatar_url'] as String?,
);
}
}
class UsersApi {
final IApiClient _client;
static const _prefix = '/api/v1/users';
UsersApi(this._client);
// ... existing methods
Future<UserBasicInfo> getById(String userId) async {
final response = await _client.get('$_prefix/$userId');
return UserBasicInfo.fromJson(response.data);
}
}
```
**Step 2: 注册到 DI**
修改 `apps/lib/core/di/injection.dart`,添加:
```dart
sl.registerLazySingleton(() => UsersApi(sl<IApiClient>()));
```
**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 and UserBasicInfo"
```
---
### Task 3: 创建公共弹窗组件 MessageActionSheet
**Files:**
- Create: `apps/lib/features/messages/ui/widgets/message_action_sheet.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),
),
),
],
const SizedBox(height: 24),
if (!isReadOnly) ...[
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();
},
),
),
],
),
],
SizedBox(height: MediaQuery.of(context).padding.bottom + 12),
],
),
);
}
}
```
**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`
**Step 1: 添加依赖和字段**
在文件顶部添加:
```dart
import '../../../users/data/users_api.dart';
import '../widgets/message_action_sheet.dart';
```
`_MessageInviteListScreenState` 中添加:
```dart
late final UsersApi _usersApi;
```
`initState` 中添加:
```dart
_usersApi = sl<UsersApi>();
```
**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` 方法,替换为:
```dart
Future<void> _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<void>(
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<void> _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<void>(
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: 替换好友请求弹窗方法**
删除旧的 `_showFriendRequestReadOnlySheet``_showFriendRequestActionSheet` 方法,替换为:
```dart
void _showFriendRequestSheet(MessageWithFriend item, {bool isReadOnly = false}) {
final message = item.message;
final friendRequest = item.friendRequest;
if (friendRequest == null) return;
final title = '${friendRequest.sender.username} 请求添加您为好友';
final description = message.content;
final statusText = isReadOnly
? (friendRequest.status == 'accepted'
? '已接受'
: friendRequest.status == 'rejected'
? '已拒绝'
: '已处理')
: null;
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) => MessageActionSheet(
title: title,
description: description,
statusText: statusText,
isReadOnly: isReadOnly,
icon: Icons.person_add_outlined,
iconColor: AppColors.emerald500,
onAccept: isReadOnly
? null
: () async {
await _processFriendRequest(item, accept: true);
},
onDecline: isReadOnly
? null
: () async {
await _processFriendRequest(item, accept: false);
},
),
);
}
```
**Step 6: 修改 _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;
case InboxMessageType.friendRequest:
if (item.friendRequest == null) {
Toast.show(context, '发送者信息加载失败,请下拉重试', type: ToastType.error);
return;
}
_showFriendRequestSheet(item, isReadOnly: message.isRead);
return;
```
**Step 7: 删除旧的 _FriendRequestSheet 类**
删除文件末尾的整个 `_FriendRequestSheet` 类(约605-749行)。
**Step 8: 运行 flutter analyze**
```bash
cd apps && flutter analyze lib/features/messages/ui/screens/message_invite_list_screen.dart
```
**Step 9: 提交**
```bash
git add apps/lib/features/messages/ && git commit -m "refactor(messages): use MessageActionSheet for all message types"
```
---
### Task 5: 删除日历消息卡片中的旧弹窗代码
**Files:**
- Modify: `apps/lib/features/messages/ui/widgets/calendar_message_card.dart`
**Step 1: 修改 CalendarInviteCard**
CalendarInviteCard 是用于列表展示的卡片,不需要显示弹窗。检查是否有不必要的硬编码,如果有则清理。
**Step 2: 运行 flutter analyze**
```bash
cd apps && flutter analyze lib/features/messages/ui/widgets/calendar_message_card.dart
```
**Step 3: 提交**
```bash
git add apps/lib/features/calendar_message_card.dart && git commit/messages/ui/widgets -f "chore(messages): clean up calendar message card"
```
---
### Task 6: 验证和测试
**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. 弹窗显示状态标签
7. 好友请求未读/已读都使用相同弹窗组件
---
## Summary
| Task | Description |
|------|-------------|
| 1 | 后端添加用户信息查询接口 `/api/v1/users/{user_id}` |
| 2 | 前端添加 UsersApi.getById 方法 |
| 3 | 创建公共弹窗组件 MessageActionSheet |
| 4 | 重构消息列表页面,删除旧弹窗代码,统一使用 MessageActionSheet |
| 5 | 清理日历消息卡片旧代码 |
| 6 | 验证测试 |
**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?