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

16 KiB
Raw Blame History

日历邀请弹窗优化 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,在 UserRepositorySQLAlchemyUserRepository 中已有 get_by_user_id 方法,确认存在。

Step 2: 添加 service 方法

修改 backend/src/v1/users/service.py,添加:

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,添加:

@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

cd backend && uv run ruff check src/v1/users/ && uv run basedpyright src/v1/users/

Step 5: 提交

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

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,添加:

sl.registerLazySingleton(() => UsersApi(sl<IApiClient>()));

Step 3: 运行 flutter analyze

cd apps && flutter analyze lib/features/users/

Step 4: 提交

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

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

cd apps && flutter analyze lib/features/messages/ui/widgets/message_action_sheet.dart

Step 3: 提交

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: 添加依赖和字段

在文件顶部添加:

import '../../../users/data/users_api.dart';
import '../widgets/message_action_sheet.dart';

_MessageInviteListScreenState 中添加:

late final UsersApi _usersApi;

initState 中添加:

_usersApi = sl<UsersApi>();

Step 2: 添加获取日历邀请信息方法

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 方法,替换为:

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: 添加已读日历邀请弹窗方法

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 方法,替换为:

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 方法

修改为调用新的统一方法:

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

cd apps && flutter analyze lib/features/messages/ui/screens/message_invite_list_screen.dart

Step 9: 提交

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

cd apps && flutter analyze lib/features/messages/ui/widgets/calendar_message_card.dart

Step 3: 提交

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: 运行完整测试

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?