在这里插入图片描述

家庭分组功能可以让用户按照不同的关系或类别来管理家庭成员,比如父母、子女、祖辈等。这样分类之后,查看和管理起来会方便很多。今天我们就来实现这个功能。

整体思路

分组页面其实就是一个列表,每个分组显示组名和成员数量。用户点击某个分组,就能看到这个组里的所有成员。另外还要支持添加新分组和编辑现有分组。

创建分组页面

先来搭建页面的基本框架:

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import '../providers/family_provider.dart';
import '../models/family_group.dart';
import 'group_members_screen.dart';

class FamilyGroupScreen extends StatelessWidget {
  const FamilyGroupScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('家庭分组'),
        elevation: 0,
      ),
      body: Consumer<FamilyProvider>(
        builder: (context, provider, _) {
          final groups = provider.getFamilyGroups();
          
          if (groups.isEmpty) {
            return _buildEmptyState();
          }
          
          return ListView.builder(
            padding: EdgeInsets.all(16.w),
            itemCount: groups.length,
            itemBuilder: (context, index) {
              final group = groups[index];
              return _buildGroupCard(context, group, provider);
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddGroupDialog(context),
        backgroundColor: const Color(0xFFE91E63),
        child: const Icon(Icons.add),
      ),
    );
  }
}

这里用Consumer来监听FamilyProvider的变化,当分组数据更新时页面会自动刷新。如果还没有分组,就显示一个空状态提示。ListView.builder用来展示分组列表,这样可以高效地渲染大量数据。

空状态提示

当用户还没创建任何分组时,显示一个友好的提示:

Widget _buildEmptyState() {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(
          Icons.group_outlined,
          size: 80.sp,
          color: Colors.grey[300],
        ),
        SizedBox(height: 20.h),
        Text(
          '还没有分组',
          style: TextStyle(
            fontSize: 18.sp,
            color: Colors.grey[600],
            fontWeight: FontWeight.w500,
          ),
        ),
        SizedBox(height: 8.h),
        Text(
          '点击右下角按钮创建第一个分组',
          style: TextStyle(
            fontSize: 14.sp,
            color: Colors.grey[400],
          ),
        ),
      ],
    ),
  );
}

空状态用了一个大图标加上两行文字,这样看起来不会太单调。第一行文字稍微大一点,第二行是提示操作的小字。颜色用灰色系,不会太突兀。

分组卡片设计

每个分组用一个卡片来展示,包含图标、名称、成员数量:

Widget _buildGroupCard(BuildContext context, FamilyGroup group, FamilyProvider provider) {
  final members = provider.getMembersByIds(group.memberIds);
  final color = _getColorForGroup(group.name);
  
  return Card(
    margin: EdgeInsets.only(bottom: 12.h),
    elevation: 2,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(12.r),
    ),
    child: InkWell(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (_) => GroupMembersScreen(group: group),
          ),
        );
      },
      borderRadius: BorderRadius.circular(12.r),
      child: Padding(
        padding: EdgeInsets.all(16.w),
        child: Row(
          children: [
            Container(
              width: 56.w,
              height: 56.w,
              decoration: BoxDecoration(
                color: color.withOpacity(0.15),
                borderRadius: BorderRadius.circular(12.r),
              ),
              child: Icon(
                _getIconForGroup(group.name),
                color: color,
                size: 28.sp,
              ),
            ),
            SizedBox(width: 16.w),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    group.name,
                    style: TextStyle(
                      fontSize: 17.sp,
                      fontWeight: FontWeight.w600,
                      color: Colors.black87,
                    ),
                  ),
                  SizedBox(height: 4.h),
                  Text(
                    '${members.length} 位成员',
                    style: TextStyle(
                      fontSize: 13.sp,
                      color: Colors.grey[600],
                    ),
                  ),
                ],
              ),
            ),
            Icon(
              Icons.chevron_right,
              color: Colors.grey[400],
              size: 24.sp,
            ),
          ],
        ),
      ),
    ),
  );
}

卡片左边是一个圆角方形的图标容器,用浅色背景配上深色图标。中间是分组名称和成员数量,右边是一个箭头表示可以点击进入。InkWell提供了点击水波纹效果,用户体验会更好。

图标和颜色处理

根据分组名称返回对应的图标和颜色:

IconData _getIconForGroup(String name) {
  if (name.contains('父母') || name.contains('爸妈')) {
    return Icons.people;
  } else if (name.contains('子女') || name.contains('孩子')) {
    return Icons.child_care;
  } else if (name.contains('祖') || name.contains('爷爷') || name.contains('奶奶')) {
    return Icons.elderly;
  } else if (name.contains('兄弟姐妹')) {
    return Icons.group;
  }
  return Icons.folder_shared;
}

Color _getColorForGroup(String name) {
  final colors = [
    const Color(0xFFE91E63), // 粉红
    const Color(0xFF9C27B0), // 紫色
    const Color(0xFF3F51B5), // 靛蓝
    const Color(0xFF2196F3), // 蓝色
    const Color(0xFF009688), // 青色
    const Color(0xFF4CAF50), // 绿色
    const Color(0xFFFF9800), // 橙色
    const Color(0xFFFF5722), // 深橙
  ];
  
  return colors[name.hashCode.abs() % colors.length];
}

这里用了一些简单的关键词匹配来选择图标,比如包含"父母"就用people图标。颜色则是用名称的哈希值来选择,这样同一个分组每次显示的颜色都是一样的。

添加分组对话框

点击右下角的加号按钮,弹出对话框让用户输入分组名称:

void _showAddGroupDialog(BuildContext context) {
  final nameController = TextEditingController();
  
  showDialog(
    context: context,
    builder: (dialogContext) => AlertDialog(
      title: const Text('创建分组'),
      content: TextField(
        controller: nameController,
        decoration: InputDecoration(
          hintText: '请输入分组名称',
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(8.r),
          ),
          contentPadding: EdgeInsets.symmetric(
            horizontal: 12.w,
            vertical: 12.h,
          ),
        ),
        autofocus: true,
        textInputAction: TextInputAction.done,
        onSubmitted: (value) {
          if (value.trim().isNotEmpty) {
            _createGroup(context, value.trim());
            Navigator.pop(dialogContext);
          }
        },
      ),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12.r),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(dialogContext),
          child: Text(
            '取消',
            style: TextStyle(
              color: Colors.grey[600],
              fontSize: 15.sp,
            ),
          ),
        ),
        TextButton(
          onPressed: () {
            final name = nameController.text.trim();
            if (name.isNotEmpty) {
              _createGroup(context, name);
              Navigator.pop(dialogContext);
            }
          },
          child: Text(
            '创建',
            style: TextStyle(
              color: const Color(0xFFE91E63),
              fontSize: 15.sp,
              fontWeight: FontWeight.w600,
            ),
          ),
        ),
      ],
    ),
  );
}

对话框里就一个输入框,用户输入分组名称后点击创建按钮。autofocus设为true,对话框一弹出就自动聚焦到输入框,用户可以直接输入。onSubmitted处理回车键,这样用户输入完直接按回车就能创建。

创建分组逻辑

把新分组添加到Provider中:

void _createGroup(BuildContext context, String name) {
  final group = FamilyGroup(
    id: DateTime.now().millisecondsSinceEpoch.toString(),
    name: name,
    memberIds: [],
    createdAt: DateTime.now(),
  );
  
  context.read<FamilyProvider>().addGroup(group);
  
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text('分组"$name"创建成功'),
      duration: const Duration(seconds: 2),
      behavior: SnackBarBehavior.floating,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(8.r),
      ),
      margin: EdgeInsets.all(16.w),
    ),
  );
}

创建分组时用当前时间戳作为ID,这样可以保证唯一性。memberIds初始化为空数组,用户后面可以往里面添加成员。创建成功后显示一个SnackBar提示,用户能清楚地知道操作成功了。

长按编辑功能

给卡片添加长按菜单,支持重命名和删除:

Widget _buildGroupCard(BuildContext context, FamilyGroup group, FamilyProvider provider) {
  final members = provider.getMembersByIds(group.memberIds);
  final color = _getColorForGroup(group.name);
  
  return Card(
    margin: EdgeInsets.only(bottom: 12.h),
    elevation: 2,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(12.r),
    ),
    child: InkWell(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (_) => GroupMembersScreen(group: group),
          ),
        );
      },
      onLongPress: () => _showGroupOptions(context, group, provider),
      borderRadius: BorderRadius.circular(12.r),
      child: Padding(
        padding: EdgeInsets.all(16.w),
        child: Row(
          children: [
            Container(
              width: 56.w,
              height: 56.w,
              decoration: BoxDecoration(
                color: color.withOpacity(0.15),
                borderRadius: BorderRadius.circular(12.r),
              ),
              child: Icon(
                _getIconForGroup(group.name),
                color: color,
                size: 28.sp,
              ),
            ),
            SizedBox(width: 16.w),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    group.name,
                    style: TextStyle(
                      fontSize: 17.sp,
                      fontWeight: FontWeight.w600,
                      color: Colors.black87,
                    ),
                  ),
                  SizedBox(height: 4.h),
                  Text(
                    '${members.length} 位成员',
                    style: TextStyle(
                      fontSize: 13.sp,
                      color: Colors.grey[600],
                    ),
                  ),
                ],
              ),
            ),
            Icon(
              Icons.chevron_right,
              color: Colors.grey[400],
              size: 24.sp,
            ),
          ],
        ),
      ),
    ),
  );
}

onLongPress回调会在用户长按卡片时触发,弹出一个底部菜单显示可用的操作选项。

分组操作菜单

显示重命名和删除选项:

void _showGroupOptions(BuildContext context, FamilyGroup group, FamilyProvider provider) {
  showModalBottomSheet(
    context: context,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
    ),
    builder: (sheetContext) => SafeArea(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          SizedBox(height: 8.h),
          Container(
            width: 40.w,
            height: 4.h,
            decoration: BoxDecoration(
              color: Colors.grey[300],
              borderRadius: BorderRadius.circular(2.r),
            ),
          ),
          SizedBox(height: 16.h),
          ListTile(
            leading: const Icon(Icons.edit, color: Color(0xFF2196F3)),
            title: const Text('重命名'),
            onTap: () {
              Navigator.pop(sheetContext);
              _showRenameDialog(context, group);
            },
          ),
          ListTile(
            leading: const Icon(Icons.delete, color: Color(0xFFF44336)),
            title: const Text('删除分组'),
            onTap: () {
              Navigator.pop(sheetContext);
              _showDeleteConfirmDialog(context, group, provider);
            },
          ),
          SizedBox(height: 8.h),
        ],
      ),
    ),
  );
}

底部菜单用ModalBottomSheet实现,顶部有个小横条表示可以下拉关闭。两个选项分别是重命名和删除,用不同的颜色图标来区分。

重命名对话框

让用户输入新的分组名称:

void _showRenameDialog(BuildContext context, FamilyGroup group) {
  final nameController = TextEditingController(text: group.name);
  
  showDialog(
    context: context,
    builder: (dialogContext) => AlertDialog(
      title: const Text('重命名分组'),
      content: TextField(
        controller: nameController,
        decoration: InputDecoration(
          hintText: '请输入新名称',
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(8.r),
          ),
          contentPadding: EdgeInsets.symmetric(
            horizontal: 12.w,
            vertical: 12.h,
          ),
        ),
        autofocus: true,
        textInputAction: TextInputAction.done,
      ),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12.r),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(dialogContext),
          child: Text(
            '取消',
            style: TextStyle(color: Colors.grey[600]),
          ),
        ),
        TextButton(
          onPressed: () {
            final newName = nameController.text.trim();
            if (newName.isNotEmpty && newName != group.name) {
              final updatedGroup = group.copyWith(name: newName);
              context.read<FamilyProvider>().updateGroup(updatedGroup);
              Navigator.pop(dialogContext);
              
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: const Text('重命名成功'),
                  duration: const Duration(seconds: 2),
                  behavior: SnackBarBehavior.floating,
                ),
              );
            }
          },
          child: const Text(
            '确定',
            style: TextStyle(
              color: Color(0xFFE91E63),
              fontWeight: FontWeight.w600,
            ),
          ),
        ),
      ],
    ),
  );
}

重命名对话框和创建对话框很像,只是输入框里预填了当前的名称。用户修改后点确定,就会更新分组信息。

删除确认对话框

删除是危险操作,需要二次确认:

void _showDeleteConfirmDialog(
  BuildContext context,
  FamilyGroup group,
  FamilyProvider provider,
) {
  final members = provider.getMembersByIds(group.memberIds);
  
  showDialog(
    context: context,
    builder: (dialogContext) => AlertDialog(
      title: const Text('删除分组'),
      content: Text(
        members.isEmpty
            ? '确定要删除"${group.name}"吗?'
            : '分组"${group.name}"中还有${members.length}位成员,删除后成员不会被删除,只是移出分组。确定继续吗?',
      ),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12.r),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(dialogContext),
          child: Text(
            '取消',
            style: TextStyle(color: Colors.grey[600]),
          ),
        ),
        TextButton(
          onPressed: () {
            provider.deleteGroup(group.id);
            Navigator.pop(dialogContext);
            
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text('已删除"${group.name}"'),
                duration: const Duration(seconds: 2),
                behavior: SnackBarBehavior.floating,
              ),
            );
          },
          child: const Text(
            '删除',
            style: TextStyle(
              color: Color(0xFFF44336),
              fontWeight: FontWeight.w600,
            ),
          ),
        ),
      ],
    ),
  );
}

如果分组里还有成员,对话框会特别提示一下,让用户知道删除分组不会删除成员。这样可以避免用户误操作。

总结

家庭分组功能通过卡片列表的形式展示所有分组,每个分组有独特的颜色和图标。用户可以轻松创建、重命名和删除分组,长按卡片就能看到更多操作选项。整个交互流程比较流畅,符合用户的使用习惯。

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐