501 lines
13 KiB
Markdown
501 lines
13 KiB
Markdown
# 日历邀请弹窗优化 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?
|