在这里插入图片描述

猫咪的健康是铲屎官最关心的事情。疫苗、驱虫、体检、用药,每一项都需要详细记录。今天我们来实现添加健康记录的功能,这是一个比较复杂的表单页面,涉及多种输入类型。


功能需求分析

添加健康记录页面需要支持以下功能:

  • 选择记录类型(疫苗、驱虫、体检等)
  • 输入标题和详细描述
  • 选择记录日期和下次提醒日期
  • 填写医院、医生、费用等可选信息

把这些需求理清楚,实现起来就有方向了。


依赖引入

首先导入需要的包:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:intl/intl.dart';
import '../../providers/health_provider.dart';
import '../../models/health_record.dart';

Material是Flutter的UI基础库,Provider负责状态管理。
intl包用于日期格式化,screenutil处理屏幕适配。


有状态组件定义

添加页面需要管理表单状态:

class AddHealthRecordScreen extends StatefulWidget {
  final String catId;
  final HealthRecordType? initialType;

  const AddHealthRecordScreen({super.key, required this.catId, this.initialType});

  
  State<AddHealthRecordScreen> createState() => _AddHealthRecordScreenState();
}

catId标识是给哪只猫咪添加记录。
initialType是可选参数,从疫苗或驱虫页面跳转时会传入默认类型。


状态变量声明

State类中定义各种控制器和状态:

class _AddHealthRecordScreenState extends State<AddHealthRecordScreen> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _descriptionController = TextEditingController();
  final _hospitalController = TextEditingController();
  final _doctorController = TextEditingController();
  final _costController = TextEditingController();

  late HealthRecordType _type;
  DateTime _date = DateTime.now();
  DateTime? _nextDate;

GlobalKey用于表单验证,五个TextEditingController管理不同的输入框。
_nextDate是可选的,用于设置下次提醒日期。


初始化方法

initState中设置默认类型:

  
  void initState() {
    super.initState();
    _type = widget.initialType ?? HealthRecordType.checkup;
  }

如果传入了initialType就使用它,否则默认是体检类型。
late关键字让_type可以在initState中初始化。


资源释放

dispose中释放所有控制器:

  
  void dispose() {
    _titleController.dispose();
    _descriptionController.dispose();
    _hospitalController.dispose();
    _doctorController.dispose();
    _costController.dispose();
    super.dispose();
  }

每个TextEditingController都需要手动释放。
这是避免内存泄漏的标准做法。


页面主体结构

build方法构建整体布局:

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('添加健康记录')),
      body: Form(
        key: _formKey,
        child: SingleChildScrollView(
          padding: EdgeInsets.all(16.w),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [

Form包裹整个表单,用于统一验证。
SingleChildScrollView让内容可以滚动,防止键盘弹出时遮挡。


记录类型选择

下拉框选择健康记录类型:

              DropdownButtonFormField<HealthRecordType>(
                value: _type,
                decoration: const InputDecoration(
                  labelText: '记录类型 *',
                  border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.category),
                ),
                items: HealthRecordType.values.map((type) {
                  return DropdownMenuItem(value: type, child: Text(_getTypeString(type)));
                }).toList(),
                onChanged: (value) => setState(() => _type = value!),
              ),
              SizedBox(height: 16.h),

DropdownButtonFormField是带表单验证的下拉框。
遍历枚举值生成所有选项。


标题输入框

必填的标题字段:

              TextFormField(
                controller: _titleController,
                decoration: const InputDecoration(
                  labelText: '标题 *',
                  hintText: '如:猫三联疫苗',
                  border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.title),
                ),
                validator: (value) => value?.isEmpty ?? true ? '请输入标题' : null,
              ),
              SizedBox(height: 16.h),

hintText给出输入示例,帮助用户理解。
validator进行非空验证,星号表示必填。


日期选择器

点击选择记录日期:

              InkWell(
                onTap: () => _selectDate(context, true),
                child: InputDecorator(
                  decoration: const InputDecoration(
                    labelText: '日期 *',
                    border: OutlineInputBorder(),
                    prefixIcon: Icon(Icons.calendar_today),
                  ),
                  child: Text(DateFormat('yyyy-MM-dd').format(_date)),
                ),
              ),
              SizedBox(height: 16.h),

InkWell包裹让整个区域可点击。
InputDecorator让显示样式与其他输入框一致。


医院信息输入

可选的医院字段:

              TextFormField(
                controller: _hospitalController,
                decoration: const InputDecoration(
                  labelText: '医院 (选填)',
                  border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.local_hospital),
                ),
              ),
              SizedBox(height: 16.h),

标签注明选填,用户知道这不是必填项。
医院图标让字段含义更直观。


医生信息输入

可选的医生字段:

              TextFormField(
                controller: _doctorController,
                decoration: const InputDecoration(
                  labelText: '医生 (选填)',
                  border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.person),
                ),
              ),
              SizedBox(height: 16.h),

记录医生信息方便下次就诊时参考。
这些可选字段让记录更加完整。


费用输入

带单位的费用输入框:

              TextFormField(
                controller: _costController,
                keyboardType: TextInputType.number,
                decoration: const InputDecoration(
                  labelText: '费用 (选填)',
                  border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.attach_money),
                  suffixText: '元',
                ),
              ),
              SizedBox(height: 16.h),

keyboardType设为number弹出数字键盘。
suffixText显示单位,用户知道要输入的是金额。


下次日期选择

设置下次提醒日期:

              InkWell(
                onTap: () => _selectDate(context, false),
                child: InputDecorator(
                  decoration: const InputDecoration(
                    labelText: '下次日期 (选填)',
                    border: OutlineInputBorder(),
                    prefixIcon: Icon(Icons.event),
                  ),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(_nextDate != null ? DateFormat('yyyy-MM-dd').format(_nextDate!) : '未设置'),
                      if (_nextDate != null)
                        IconButton(
                          icon: const Icon(Icons.clear),
                          onPressed: () => setState(() => _nextDate = null),
                        ),
                    ],
                  ),
                ),
              ),
              SizedBox(height: 16.h),

下次日期用于提醒下次疫苗或驱虫时间。
清除按钮让用户可以取消已设置的日期。


详细描述输入

多行的描述输入框:

              TextFormField(
                controller: _descriptionController,
                maxLines: 3,
                decoration: const InputDecoration(
                  labelText: '详细描述 (选填)',
                  border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.description),
                ),
              ),
              SizedBox(height: 32.h),

maxLines: 3让输入框有三行高度。
可以记录更详细的就诊情况。


保存按钮

底部的提交按钮:

              SizedBox(
                width: double.infinity,
                height: 48.h,
                child: ElevatedButton(
                  onPressed: _saveRecord,
                  child: const Text('保存'),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

width: double.infinity让按钮撑满宽度。
点击触发_saveRecord方法保存数据。


类型转换方法

枚举转中文的方法:

  String _getTypeString(HealthRecordType type) {
    switch (type) {
      case HealthRecordType.vaccination: return '疫苗接种';
      case HealthRecordType.deworming: return '驱虫';
      case HealthRecordType.checkup: return '体检';
      case HealthRecordType.surgery: return '手术';
      case HealthRecordType.medication: return '用药';
      case HealthRecordType.other: return '其他';
    }
  }

switch语句处理每种类型的显示文本。
六种类型覆盖了常见的健康记录场景。


日期选择方法

弹出日期选择器:

  Future<void> _selectDate(BuildContext context, bool isMainDate) async {
    final picked = await showDatePicker(
      context: context,
      initialDate: isMainDate ? _date : (_nextDate ?? DateTime.now().add(const Duration(days: 30))),
      firstDate: isMainDate ? DateTime(2000) : DateTime.now(),
      lastDate: DateTime(2100),
    );
    if (picked != null) {
      setState(() {
        if (isMainDate) {
          _date = picked;
        } else {
          _nextDate = picked;
        }
      });
    }
  }

isMainDate参数区分是选择记录日期还是下次日期。
下次日期默认是30天后,且不能选择过去的日期。


保存记录逻辑

验证并保存数据:

  void _saveRecord() {
    if (_formKey.currentState!.validate()) {
      final record = HealthRecord(
        catId: widget.catId,
        type: _type,
        title: _titleController.text,
        description: _descriptionController.text.isEmpty ? null : _descriptionController.text,
        date: _date,
        hospital: _hospitalController.text.isEmpty ? null : _hospitalController.text,
        doctor: _doctorController.text.isEmpty ? null : _doctorController.text,
        cost: _costController.text.isEmpty ? null : double.tryParse(_costController.text),
        nextDate: _nextDate,
      );

      context.read<HealthProvider>().addHealthRecord(record);
      Navigator.pop(context);
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('健康记录添加成功!')),
      );
    }
  }
}

validate触发表单验证,通过后才保存。
空字符串转为null,保持数据整洁。


数据模型设计

HealthRecord的结构:

class HealthRecord {
  final String id;
  final String catId;
  final HealthRecordType type;
  final String title;
  final String? description;
  final DateTime date;
  final String? hospital;
  final String? doctor;
  final double? cost;
  final DateTime? nextDate;
}

必填字段用普通类型,可选字段用问号标记。
这种设计让数据结构清晰明了。


表单验证要点

必填字段的验证:

validator: (value) => value?.isEmpty ?? true ? '请输入标题' : null,

返回字符串表示验证失败,显示错误信息。
返回null表示验证通过。

触发验证的方式:

if (_formKey.currentState!.validate()) {
  // 验证通过,执行保存
}

validate方法会触发所有字段的验证。
只有全部通过才返回true。


InputDecorator使用

让非输入组件看起来像输入框:

InputDecorator(
  decoration: const InputDecoration(
    labelText: '日期 *',
    border: OutlineInputBorder(),
    prefixIcon: Icon(Icons.calendar_today),
  ),
  child: Text(DateFormat('yyyy-MM-dd').format(_date)),
)

InputDecorator应用InputDecoration的样式。
内部可以放任何Widget,这里放的是Text。


可选字段处理

空字符串转null的技巧:

description: _descriptionController.text.isEmpty ? null : _descriptionController.text,

三元表达式判断是否为空。
空字符串存为null更合理。

费用字段的处理:

cost: _costController.text.isEmpty ? null : double.tryParse(_costController.text),

tryParse转换失败返回null,不会抛异常。
这样处理更安全。


小结

添加健康记录页面涉及的知识点比较多:

  • 多种表单组件的使用
  • 日期选择器的复用
  • 可选字段的处理
  • 表单验证和数据保存

这些技巧在其他表单页面也能用到,值得好好掌握。


欢迎加入OpenHarmony跨平台开发社区,一起交流Flutter开发经验:

https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐