Files
social-app/docs/plans/2026-03-29-calendar-share-redesign.md
T

16 KiB

Calendar Share Redesign - Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 重写日历分享功能,支持选择多个好友/用户并单独设置权限,同时后端新增批量分享接口。

Architecture:

  • 后端: 新增 POST /api/v1/schedule-items/{id}/share/batch 批量分享接口,原有单用户接口保留。
  • 前端: 重构 CalendarEventShareScreenCalendarShareDialog,移除硬编码手机号输入,改为用户搜索 + 多选 + 独立权限设置。

Tech Stack: Flutter (Riverpod/BLoC), FastAPI (Pydantic), PostgreSQL, 现有 API 契约


背景与现状

现有流程

用户 → CalendarEventShareScreen → CalendarShareDialog
                                    ├── 手机号输入 + 前缀选择
                                    ├── 权限开关 (View/Edit/Invite)
                                    └── POST /api/v1/schedule-items/{id}/share

问题

  1. 仅支持单用户分享
  2. 需要手动输入手机号,不支持好友选择
  3. 权限是全局的,无法为不同用户设置不同权限

目标流程

用户 → CalendarEventShareScreen → CalendarShareDialog
                                    ├── 搜索框 (好友/用户名/手机号搜索)
                                    ├── 用户列表 (可多选)
                                    │       └── 每行: 头像 + 名字 + 权限开关 (选中后显示)
                                    ├── 已选用户 Chips
                                    └── POST /api/v1/schedule-items/{id}/share/batch

Task 1: 后端 - 新增批量分享接口

Files:

  • Create: backend/src/v1/schedule_items/schemas.py (新增 ScheduleItemShareBatchRequest)

  • Modify: backend/src/v1/schedule_items/router.py (新增 /share/batch 端点)

  • Modify: backend/src/v1/schedule_items/service.py (新增 share_batch 方法)

  • Verify: backend/tests/**

  • Step 1: 创建批量分享请求 Schema

# backend/src/v1/schedule_items/schemas.py

class ScheduleItemShareBatchRequest(BaseModel):
    shares: List[ShareTarget] = Field(..., description="List of users to share with")
    permission_view: bool = Field(True, description="Default view permission")
    permission_edit: bool = Field(False, description="Default edit permission")
    permission_invite: bool = Field(False, description="Default invite permission")

class ShareTarget(BaseModel):
    phone: str = Field(..., pattern=r"^\+861[3-9]\d{9}$")
    permission_view: Optional[bool] = None
    permission_edit: Optional[bool] = None
    permission_invite: Optional[bool] = None

class ScheduleItemShareBatchResponse(BaseModel):
    message: str
    success_count: int
    failure_count: int
    failures: List[ShareFailure] = []

class ShareFailure(BaseModel):
    phone: str
    reason: str
  • Step 2: 实现批量分享服务方法
# backend/src/v1/schedule_items/service.py

async def share_batch(
    db: AsyncSession,
    current_user: User,
    schedule_item_id: UUID,
    shares: List[ShareTarget],
    default_permission_view: bool = True,
    default_permission_edit: bool = False,
    default_permission_invite: bool = False,
) -> Tuple[int, int, List[Dict]]:
    """批量分享日程给多个用户"""
    # 1. 验证当前用户是否有邀请权限
    # 2. 对每个 share 调用现有的 share_single 逻辑
    # 3. 返回 (成功数, 失败数, 失败详情列表)
  • Step 3: 新增批量分享路由
# backend/src/v1/schedule_items/router.py

@router.post("/{item_id}/share/batch", response_model=ScheduleItemShareBatchResponse)
async def share_batch(
    item_id: UUID,
    request: ScheduleItemShareBatchRequest,
    current_user: User = Depends(get_current_user),
    service: ScheduleItemService = Depends(),
):
    """批量分享日程给多个用户"""
    success_count, failure_count, failures = await service.share_batch(
        db=db,
        current_user=current_user,
        schedule_item_id=item_id,
        shares=request.shares,
        default_permission_view=request.permission_view,
        default_permission_edit=request.permission_edit,
        default_permission_invite=request.permission_invite,
    )
    return ScheduleItemShareBatchResponse(
        message="Batch share completed",
        success_count=success_count,
        failure_count=failure_count,
        failures=[ShareFailure(**f) for f in failures],
    )
  • Step 4: 编写测试
# backend/tests/v1/test_schedule_items.py

@pytest.mark.asyncio
async def test_share_batch_success():
    """测试批量分享成功"""
    # Arrange
    # Act
    # Assert

@pytest.mark.asyncio
async def test_share_batch_partial_failure():
    """测试批量分享部分失败(非好友用户应被拒绝)"""
    # Arrange
    # Act
    # Assert
  • Step 5: 运行测试验证
cd backend && uv run pytest tests/v1/test_schedule_items.py -v -k "share_batch"

Task 2: 前端 - 新增批量分享 API 方法

Files:

  • Modify: apps/lib/features/calendar/data/apis/calendar_api.dart

  • Step 1: 新增批量分享 API 方法

// apps/lib/features/calendar/data/apis/calendar_api.dart

class CalendarApi {
  // 现有单用户分享保留
  Future<void> share(...) { ... }
  
  // 新增批量分享
  Future<ShareBatchResponse> shareBatch({
    required String eventId,
    required List<ShareTarget> shares,
    bool permissionView = true,
    bool permissionEdit = false,
    bool permissionInvite = false,
  }) async {
    final response = await _dio.post(
      '/api/v1/schedule-items/$eventId/share/batch',
      data: {
        'shares': shares.map((s) => s.toJson()).toList(),
        'permission_view': permissionView,
        'permission_edit': permissionEdit,
        'permission_invite': permissionInvite,
      },
    );
    return ShareBatchResponse.fromJson(response.data);
  }
}

class ShareTarget {
  final String phone;
  final bool? permissionView;
  final bool? permissionEdit;
  final bool? permissionInvite;
  
  Map<String, dynamic> toJson() => {
    'phone': phone,
    if (permissionView != null) 'permission_view': permissionView,
    if (permissionEdit != null) 'permission_edit': permissionEdit,
    if (permissionInvite != null) 'permission_invite': permissionInvite,
  };
}

class ShareBatchResponse {
  final String message;
  final int successCount;
  final int failureCount;
  final List<ShareFailure> failures;
}
  • Step 2: 验证 API 编译
cd apps && flutter analyze lib/features/calendar/data/apis/calendar_api.dart

Task 3: 前端 - 创建分享页面状态管理

Files:

  • Create: apps/lib/features/calendar/presentation/cubits/calendar_share_cubit.dart

  • Create: apps/lib/features/calendar/presentation/cubits/calendar_share_state.dart

  • Step 1: 创建 State

// apps/lib/features/calendar/presentation/cubits/calendar_share_state.dart

enum CalendarShareStatus { initial, loading, success, failure }

class SelectedUser {
  final String id;
  final String username;
  final String? avatarUrl;
  final String phone;
  bool permissionView;
  bool permissionEdit;
  bool permissionInvite;
}

class CalendarShareState {
  final CalendarShareStatus status;
  final List<FriendResponse> friends;
  final List<UserProfile> searchResults;
  final List<SelectedUser> selectedUsers;
  final String searchQuery;
  final String? errorMessage;
  
  // 权限默认值(应用于新选中的用户)
  bool defaultPermissionView;
  bool defaultPermissionEdit;
  bool defaultPermissionInvite;
}
  • Step 2: 创建 Cubit
// apps/lib/features/calendar/presentation/cubits/calendar_share_cubit.dart

class CalendarShareCubit extends Cubit<CalendarShareState> {
  final FriendsApi _friendsApi;
  final UsersApi _usersApi;
  final CalendarApi _calendarApi;
  
  CalendarShareCubit({
    required FriendsApi friendsApi,
    required UsersApi usersApi,
    required CalendarApi calendarApi,
  }) : super(CalendarShareState.initial());
  
  // 加载好友列表
  Future<void> loadFriends() async { ... }
  
  // 搜索用户
  Future<void> searchUsers(String query) async { ... }
  
  // 切换用户选中状态
  void toggleUser(String userId) { ... }
  
  // 更新单个用户的权限
  void updateUserPermission(String userId, {bool? view, bool? edit, bool? invite}) { ... }
  
  // 移除已选用户
  void removeUser(String userId) { ... }
  
  // 设置默认权限(对新选用户生效)
  void setDefaultPermissions({bool? view, bool? edit, bool? invite}) { ... }
  
  // 发送批量分享
  Future<void> share(String eventId) async { ... }
}
  • Step 3: 验证编译
cd apps && flutter analyze lib/features/calendar/presentation/cubits/calendar_share_cubit.dart

Task 4: 前端 - 重构分享对话框 UI

Files:

  • Create: apps/lib/features/calendar/presentation/widgets/user_select_list.dart
  • Create: apps/lib/features/calendar/presentation/widgets/user_select_item.dart
  • Modify: apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart
  • Modify: apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart

4.1 用户选择列表组件

  • Step 1: 创建 UserSelectItem
// apps/lib/features/calendar/presentation/widgets/user_select_item.dart

class UserSelectItem extends StatelessWidget {
  final UserBasicInfo user;
  final bool isSelected;
  final bool showPermissions;  // 选中后才显示权限开关
  final SelectedUser? selectedUser;  // 如果选中,包含权限状态
  final ValueChanged<bool> onToggle;
  final Function(PermissionType, bool) onPermissionChanged;
  
  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: Avatar(url: user.avatarUrl, name: user.username),
      title: Text(user.username),
      subtitle: showPermissions && isSelected 
        ? Row(
            children: [
              _PermissionChip('V', selectedUser?.permissionView ?? true, (v) => onPermissionChanged(PermissionType.view, v)),
              _PermissionChip('E', selectedUser?.permissionEdit ?? false, (v) => onPermissionChanged(PermissionType.edit, v)),
              _PermissionChip('I', selectedUser?.permissionInvite ?? false, (v) => onPermissionChanged(PermissionType.invite, v)),
            ],
          )
        : null,
      trailing: Checkbox(value: isSelected, onChanged: (v) => onToggle(v ?? false)),
    );
  }
}

4.2 重构 CalendarShareDialog

  • Step 2: 重构对话框
// apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart

class CalendarShareDialog extends StatefulWidget {
  // ... existing props
  
  @override
  State<CalendarShareDialog> createState() => _CalendarShareDialogState();
}

class _CalendarShareDialogState extends State<CalendarShareDialog> {
  late final CalendarShareCubit _cubit;
  final _searchController = TextEditingController();
  
  @override
  void initState() {
    super.initState();
    _cubit = context.read<CalendarShareCubit>();
    _cubit.loadFriends();
  }
  
  @override
  Widget build(BuildContext context) {
    return BlocProvider.value(
      value: _cubit,
      child: BlocBuilder<CalendarShareCubit, CalendarShareState>(
        builder: (context, state) {
          return Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              // Header
              _buildHeader(context, state),
              
              // 搜索框
              TextField(
                controller: _searchController,
                decoration: InputDecoration(
                  hintText: '搜索好友或输入手机号...',
                  prefixIcon: Icon(Icons.search),
                ),
                onChanged: (value) => _cubit.searchUsers(value),
              ),
              
              // 已选用户 Chips
              if (state.selectedUsers.isNotEmpty) ...[
                Wrap(
                  spacing: 8,
                  children: state.selectedUsers.map((user) {
                    return Chip(
                      label: Text('${user.username} (${_getPermissionSummary(user)})'),
                      onDeleted: () => _cubit.removeUser(user.id),
                    );
                  }).toList(),
                ),
              ],
              
              // 用户列表
              Flexible(
                child: _buildUserList(state),
              ),
              
              // 发送按钮
              AppButton(
                text: '发送邀请 (${state.selectedUsers.length}人)',
                onPressed: state.selectedUsers.isEmpty 
                  ? null 
                  : () => _handleShare(context, state),
              ),
            ],
          );
        },
      ),
    );
  }
  
  Widget _buildUserList(CalendarShareState state) {
    // 如果有搜索结果,显示搜索结果;否则显示好友列表
    final users = state.searchQuery.isNotEmpty 
      ? state.searchResults 
      : state.friends.map((f) => f.friend).toList();
    
    return ListView.builder(
      shrinkWrap: true,
      itemCount: users.length,
      itemBuilder: (context, index) {
        final user = users[index];
        final selectedUser = state.selectedUsers.cast<SelectedUser?>().firstWhere(
          (s) => s?.id == user.id,
          orElse: () => null,
        );
        final isSelected = selectedUser != null;
        
        return UserSelectItem(
          user: user,
          isSelected: isSelected,
          showPermissions: isSelected,
          selectedUser: selectedUser,
          onToggle: (selected) {
            if (selected) {
              _cubit.addUser(user, state.defaultPermissionView, state.defaultPermissionEdit, state.defaultPermissionInvite);
            } else {
              _cubit.removeUser(user.id);
            }
          },
          onPermissionChanged: (type, value) {
            _cubit.updateUserPermission(user.id, type: type, value: value);
          },
        );
      },
    );
  }
}
  • Step 3: 移除顶部栏,简化 CalendarEventShareScreen
// apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart

class CalendarEventShareScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            // 直接显示分享对话框,不需要额外顶部栏
            Expanded(
              child: CalendarShareDialog(
                eventId: eventId,
                eventTitle: event.title,
                canInvite: event.canInvite,
                canEdit: event.canEdit,
              ),
            ),
          ],
        ),
      ),
    );
  }
}
  • Step 4: 验证编译
cd apps && flutter analyze lib/features/calendar/presentation/widgets/calendar_share_dialog.dart

Task 5: 集成测试

Files:

  • Create: apps/test/features/calendar/presentation/widgets/calendar_share_dialog_test.dart

  • Step 1: 编写 Widget 测试

void main() {
  testWidgets('选择用户后显示权限开关', (tester) async {
    // Arrange
    // Act
    // Assert
  });
  
  testWidgets('已选用户显示在 Chips 中', (tester) async {
    // Arrange
    // Act
    // Assert
  });
}
  • Step 2: 运行测试
cd apps && flutter test test/features/calendar/presentation/widgets/calendar_share_dialog_test.dart

依赖关系

Task 1 (后端批量接口)
    ↓
Task 2 (前端批量分享 API)
    ↓
Task 3 (分享状态管理 Cubit)
    ↓
Task 4 (分享对话框 UI)
    ↓
Task 5 (集成测试)

风险与注意事项

  1. 后端兼容性: 批量接口上线前,前端使用原有单用户接口
  2. 搜索防抖: 搜索用户时需要 debounce 避免频繁请求
  3. 好友列表分页: 如果好友数量大,需要分页加载
  4. 权限验证: 前端权限不能超过当前用户的权限(由后端控制)