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

501 lines
13 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
- 日历邀请通过 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<UserBasicInfo> 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<String, dynamic> 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<UsersApi>();
```
**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<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 方法**
修改 `_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: 修改 _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?