在这里插入图片描述

添加家庭成员是建立个性化健康管理的第一步。每个成员都有独特的健康信息,包括过敏史、慢性病、血型等,这些信息对于安全用药至关重要。

表单设计思路

添加成员页面采用分组表单设计,包含基本信息、身体信息、过敏信息、慢性病和其他信息五个部分。使用标签输入组件管理过敏源和慢性病列表,提供灵活的数据录入方式。

状态管理

页面状态包含多个控制器和变量:

class _AddFamilyMemberScreenState extends State<AddFamilyMemberScreen> {
  final _formKey = GlobalKey<FormState>();
  final _nameController = TextEditingController();
  final _heightController = TextEditingController();
  final _weightController = TextEditingController();
  final _emergencyContactController = TextEditingController();
  final _notesController = TextEditingController();

  String _relationship = '本人';
  String _gender = '男';
  String? _bloodType;
  DateTime _birthday = DateTime(1990, 1, 1);
  List<String> _allergies = [];
  List<String> _chronicDiseases = [];

  final _allergyController = TextEditingController();
  final _diseaseController = TextEditingController();

  final List<String> _relationships = ['本人', '配偶', '子女', '父亲', '母亲', '祖父', '祖母', '其他'];
  final List<String> _bloodTypes = ['A型', 'B型', 'AB型', 'O型'];
}

使用多个TextEditingController管理文本输入,String变量管理下拉选择,DateTime管理日期,List管理标签列表。预定义关系和血型选项,方便用户选择。

性别选择器

性别使用单选按钮组:

Widget _buildGenderSelector() {
  return Container(
    padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
    decoration: BoxDecoration(
      border: Border.all(color: Colors.grey),
      borderRadius: BorderRadius.circular(8.r),
    ),
    child: Row(
      children: [
        Text('性别: ', style: TextStyle(fontSize: 14.sp)),
        Expanded(
          child: Row(
            children: [
              Radio<String>(
                value: '男',
                groupValue: _gender,
                onChanged: (v) => setState(() => _gender = v!),
              ),
              const Text('男'),
              Radio<String>(
                value: '女',
                groupValue: _gender,
                onChanged: (v) => setState(() => _gender = v!),
              ),
              const Text('女'),
            ],
          ),
        ),
      ],
    ),
  );
}

使用Radio组件创建单选按钮,groupValue设置当前选中值,onChanged回调更新状态。容器样式模仿输入框,保持视觉统一。

标签输入组件

过敏和慢性病使用标签输入:

Widget _buildTagInput(TextEditingController controller, List<String> tags, String hint) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Row(
        children: [
          Expanded(
            child: TextField(
              controller: controller,
              decoration: InputDecoration(
                hintText: hint,
                border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
                contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
              ),
            ),
          ),
          SizedBox(width: 8.w),
          IconButton(
            onPressed: () {
              if (controller.text.isNotEmpty) {
                setState(() {
                  tags.add(controller.text);
                  controller.clear();
                });
              }
            },
            icon: Container(
              padding: EdgeInsets.all(8.w),
              decoration: BoxDecoration(
                color: const Color(0xFF00897B),
                borderRadius: BorderRadius.circular(8.r),
              ),
              child: const Icon(Icons.add, color: Colors.white),
            ),
          ),
        ],
      ),
      if (tags.isNotEmpty) ...[
        SizedBox(height: 8.h),
        Wrap(
          spacing: 8.w,
          runSpacing: 8.h,
          children: tags.map((tag) {
            return Container(
              padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
              decoration: BoxDecoration(
                color: Colors.teal.withOpacity(0.1),
                borderRadius: BorderRadius.circular(20.r),
              ),
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(tag, style: TextStyle(color: Colors.teal, fontSize: 12.sp)),
                  SizedBox(width: 4.w),
                  GestureDetector(
                    onTap: () => setState(() => tags.remove(tag)),
                    child: Icon(Icons.close, size: 16.sp, color: Colors.teal),
                  ),
                ],
              ),
            );
          }).toList(),
        ),
      ],
    ],
  );
}

输入框和添加按钮在同一行,用户输入后点击添加按钮将内容加入标签列表。已添加的标签显示在下方,每个标签右侧有删除按钮。使用Wrap组件自动换行,标签过多时不会超出屏幕。

身高体重布局

身高和体重采用横向布局:

Row(
  children: [
    Expanded(child: _buildTextField(_heightController, '身高(cm)', keyboardType: TextInputType.number)),
    SizedBox(width: 12.w),
    Expanded(child: _buildTextField(_weightController, '体重(kg)', keyboardType: TextInputType.number)),
  ],
)

两个输入框平均分配空间,都使用数字键盘。这种布局节省垂直空间,让表单更加紧凑。

血型下拉框

血型允许为空:

Widget _buildDropdown(
  String label,
  String? value,
  List<String> items,
  ValueChanged<String?> onChanged, {
  bool allowNull = false,
}) {
  return DropdownButtonFormField<String>(
    value: value,
    decoration: InputDecoration(
      labelText: label,
      border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
      contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
    ),
    items: [
      if (allowNull) const DropdownMenuItem(value: null, child: Text('未知')),
      ...items.map((i) => DropdownMenuItem(value: i, child: Text(i))),
    ],
    onChanged: onChanged,
  );
}

allowNull参数控制是否允许空值。血型不是必填项,用户可以选择"未知"。这种设计提供了更大的灵活性。

保存成员逻辑

表单验证通过后创建成员对象:

void _saveMember() {
  if (_formKey.currentState!.validate()) {
    final member = FamilyMember(
      id: const Uuid().v4(),
      name: _nameController.text,
      relationship: _relationship,
      birthday: _birthday,
      gender: _gender,
      bloodType: _bloodType,
      height: double.tryParse(_heightController.text),
      weight: double.tryParse(_weightController.text),
      allergies: _allergies,
      chronicDiseases: _chronicDiseases,
      emergencyContact: _emergencyContactController.text.isEmpty ? null : _emergencyContactController.text,
      notes: _notesController.text.isEmpty ? null : _notesController.text,
    );

    context.read<FamilyProvider>().addMember(member);
    Get.back();
    Get.snackbar('成功', '家庭成员已添加', snackPosition: SnackPosition.BOTTOM);
  }
}

使用double.tryParse转换身高体重,转换失败返回null。空字符串字段设置为null而非空字符串,保持数据的一致性。调用Provider的addMember方法保存数据,返回上一页并显示成功提示。

日期选择器

出生日期选择器限制日期范围:

Widget _buildDatePicker(String label, DateTime date, ValueChanged<DateTime> onChanged) {
  return GestureDetector(
    onTap: () async {
      final picked = await showDatePicker(
        context: context,
        initialDate: date,
        firstDate: DateTime(1900),
        lastDate: DateTime.now(),
      );
      if (picked != null) onChanged(picked);
    },
    child: Container(
      padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 16.h),
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey),
        borderRadius: BorderRadius.circular(8.r),
      ),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text('$label: ${DateFormat('yyyy-MM-dd').format(date)}'),
          const Icon(Icons.calendar_today, size: 20),
        ],
      ),
    ),
  );
}

firstDate设置为1900年,lastDate设置为当前日期,确保出生日期在合理范围内。这种限制避免了用户输入错误的日期。

分组标题

每个信息组使用标题分隔:

Widget _buildSectionTitle(String title) {
  return Padding(
    padding: EdgeInsets.only(bottom: 12.h),
    child: Text(title, style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
  );
}

标题使用加粗字体,底部添加间距。这种分组让长表单的结构更加清晰,用户能够快速定位到需要填写的部分。

资源释放

在dispose中释放所有控制器:


void dispose() {
  _nameController.dispose();
  _heightController.dispose();
  _weightController.dispose();
  _emergencyContactController.dispose();
  _notesController.dispose();
  _allergyController.dispose();
  _diseaseController.dispose();
  super.dispose();
}

每个TextEditingController都需要手动释放,这是Flutter的内存管理要求。

标签输入的优势

标签输入组件让用户可以添加任意数量的过敏源和慢性病。相比固定的输入框,这种方式更加灵活。用户可以根据实际情况添加多个标签,也可以随时删除不需要的标签。

总结

添加成员页面通过分组表单和标签输入,提供了灵活的数据录入方式。性别单选按钮、血型下拉框和日期选择器等组件,让表单填写更加便捷。Provider管理成员数据,确保数据的持久化和响应式更新。

表单验证增强

为表单添加更完善的验证逻辑:

String? _validateName(String? value) {
  if (value == null || value.isEmpty) {
    return '请输入姓名';
  }
  if (value.length < 2) {
    return '姓名至少2个字符';
  }
  if (value.length > 20) {
    return '姓名不能超过20个字符';
  }
  return null;
}

String? _validateHeight(String? value) {
  if (value == null || value.isEmpty) return null;
  final height = double.tryParse(value);
  if (height == null) return '请输入有效的数字';
  if (height < 30 || height > 250) return '身高范围应在30-250cm之间';
  return null;
}

String? _validateWeight(String? value) {
  if (value == null || value.isEmpty) return null;
  final weight = double.tryParse(value);
  if (weight == null) return '请输入有效的数字';
  if (weight < 1 || weight > 500) return '体重范围应在1-500kg之间';
  return null;
}

验证逻辑检查姓名长度、身高体重的合理范围。这些验证确保了数据的有效性,避免录入错误数据。

常用过敏源快速添加

提供常用过敏源的快速选择:

Widget _buildCommonAllergies() {
  final commonAllergies = ['青霉素', '头孢', '磺胺', '阿司匹林', '花粉', '海鲜', '牛奶', '鸡蛋'];
  
  return Wrap(
    spacing: 8.w,
    runSpacing: 8.h,
    children: commonAllergies.map((allergy) {
      final isSelected = _allergies.contains(allergy);
      return GestureDetector(
        onTap: () {
          setState(() {
            if (isSelected) {
              _allergies.remove(allergy);
            } else {
              _allergies.add(allergy);
            }
          });
        },
        child: Container(
          padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
          decoration: BoxDecoration(
            color: isSelected ? Colors.red.withOpacity(0.1) : Colors.grey[200],
            borderRadius: BorderRadius.circular(20.r),
            border: isSelected ? Border.all(color: Colors.red) : null,
          ),
          child: Text(
            allergy,
            style: TextStyle(
              color: isSelected ? Colors.red : Colors.grey[700],
              fontSize: 12.sp,
            ),
          ),
        ),
      );
    }).toList(),
  );
}

常用过敏源以标签形式展示,用户点击即可添加或移除。这种设计比手动输入更加便捷,也减少了输入错误。

常用慢性病快速添加

同样提供常用慢性病的快速选择:

Widget _buildCommonDiseases() {
  final commonDiseases = ['高血压', '糖尿病', '冠心病', '哮喘', '关节炎', '甲状腺疾病', '胃病', '肝病'];
  
  return Wrap(
    spacing: 8.w,
    runSpacing: 8.h,
    children: commonDiseases.map((disease) {
      final isSelected = _chronicDiseases.contains(disease);
      return GestureDetector(
        onTap: () {
          setState(() {
            if (isSelected) {
              _chronicDiseases.remove(disease);
            } else {
              _chronicDiseases.add(disease);
            }
          });
        },
        child: Container(
          padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
          decoration: BoxDecoration(
            color: isSelected ? Colors.orange.withOpacity(0.1) : Colors.grey[200],
            borderRadius: BorderRadius.circular(20.r),
            border: isSelected ? Border.all(color: Colors.orange) : null,
          ),
          child: Text(
            disease,
            style: TextStyle(
              color: isSelected ? Colors.orange : Colors.grey[700],
              fontSize: 12.sp,
            ),
          ),
        ),
      );
    }).toList(),
  );
}

慢性病标签使用橙色,与过敏源的红色形成区分。用户可以快速选择常见的慢性病,也可以通过输入框添加其他疾病。

头像选择功能

支持为成员设置头像:

String? _avatarPath;

Widget _buildAvatarPicker() {
  return GestureDetector(
    onTap: _pickAvatar,
    child: Center(
      child: Stack(
        children: [
          CircleAvatar(
            radius: 50.r,
            backgroundColor: Colors.teal.withOpacity(0.1),
            backgroundImage: _avatarPath != null ? FileImage(File(_avatarPath!)) : null,
            child: _avatarPath == null
                ? Icon(Icons.person, size: 50.sp, color: Colors.teal)
                : null,
          ),
          Positioned(
            bottom: 0,
            right: 0,
            child: Container(
              padding: EdgeInsets.all(4.w),
              decoration: const BoxDecoration(
                color: Color(0xFF00897B),
                shape: BoxShape.circle,
              ),
              child: Icon(Icons.camera_alt, size: 16.sp, color: Colors.white),
            ),
          ),
        ],
      ),
    ),
  );
}

Future<void> _pickAvatar() async {
  final picker = ImagePicker();
  final image = await picker.pickImage(source: ImageSource.gallery);
  if (image != null) {
    setState(() => _avatarPath = image.path);
  }
}

头像选择器显示当前头像或默认图标,右下角有相机图标提示可以更换。点击后打开相册选择图片。

紧急联系人验证

验证紧急联系人电话格式:

String? _validateEmergencyContact(String? value) {
  if (value == null || value.isEmpty) return null;
  final phoneRegex = RegExp(r'^1[3-9]\d{9}$');
  if (!phoneRegex.hasMatch(value)) {
    return '请输入有效的手机号码';
  }
  return null;
}

紧急联系人电话使用正则表达式验证格式,确保输入的是有效的手机号码。


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

Logo

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

更多推荐