在这里插入图片描述

记录家庭回忆是这个应用的核心功能之一。一次聚餐、一场旅行、一个特别的日子,都值得记录下来。今天我们来实现添加回忆的表单页面,让用户可以方便地记录美好时光。

设计思路

添加回忆页面需要收集标题、内容、日期、地点和相关家人等信息。标题和内容是必填项,其他信息可选。相关家人用多选的方式,用户可以选择多个参与者。整个表单采用垂直滚动布局,每个输入项之间有适当的间距。

创建页面结构

先搭建基本框架:

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/family_provider.dart';
import '../models/memory.dart';

class AddMemoryScreen extends StatefulWidget {
  const AddMemoryScreen({super.key});

  
  State<AddMemoryScreen> createState() => _AddMemoryScreenState();
}

class _AddMemoryScreenState extends State<AddMemoryScreen> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _contentController = TextEditingController();
  final _locationController = TextEditingController();
  DateTime _selectedDate = DateTime.now();
  List<String> _selectedMemberIds = [];
  List<String> _selectedPhotoIds = [];

  
  void dispose() {
    _titleController.dispose();
    _contentController.dispose();
    _locationController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('添加回忆'),
        elevation: 0,
        actions: [
          TextButton(
            onPressed: _saveMemory,
            child: Text(
              '保存',
              style: TextStyle(
                color: Colors.white,
                fontSize: 16.sp,
                fontWeight: FontWeight.w600,
              ),
            ),
          ),
        ],
      ),
      body: Consumer<FamilyProvider>(
        builder: (context, provider, _) {
          return Form(
            key: _formKey,
            child: ListView(
              padding: EdgeInsets.all(16.w),
              children: [
                _buildTitleField(),
                SizedBox(height: 16.h),
                _buildContentField(),
                SizedBox(height: 16.h),
                _buildDateSelector(),
                SizedBox(height: 16.h),
                _buildLocationField(),
                SizedBox(height: 24.h),
                _buildMemberSelector(provider),
                SizedBox(height: 24.h),
                _buildPhotoSelector(),
                SizedBox(height: 24.h),
              ],
            ),
          );
        },
      ),
    );
  }
}

用StatefulWidget管理表单状态,每个输入框都有对应的Controller。_selectedDate默认是今天,_selectedMemberIds存储选中的家人ID列表。dispose方法里记得释放所有Controller资源。

标题输入框

标题是回忆的核心,必须填写:

Widget _buildTitleField() {
  return TextFormField(
    controller: _titleController,
    decoration: InputDecoration(
      labelText: '标题',
      hintText: '给这段回忆起个名字',
      prefixIcon: const Icon(Icons.title, color: Color(0xFFE91E63)),
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12.r),
      ),
      enabledBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12.r),
        borderSide: BorderSide(color: Colors.grey[300]!),
      ),
      focusedBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12.r),
        borderSide: const BorderSide(
          color: Color(0xFFE91E63),
          width: 2,
        ),
      ),
      filled: true,
      fillColor: Colors.grey[50],
    ),
    validator: (value) {
      if (value == null || value.isEmpty) {
        return '请输入标题';
      }
      if (value.length < 2) {
        return '标题至少2个字符';
      }
      return null;
    },
    textInputAction: TextInputAction.next,
  );
}

输入框用圆角边框,聚焦时边框变成粉色。prefixIcon显示标题图标,提示文字"给这段回忆起个名字"很温馨。验证器检查标题不能为空且至少2个字符。

内容输入框

内容用多行输入框,让用户可以详细描述:

Widget _buildContentField() {
  return TextFormField(
    controller: _contentController,
    decoration: InputDecoration(
      labelText: '内容',
      hintText: '记录这段美好的回忆...',
      alignLabelWithHint: true,
      prefixIcon: Padding(
        padding: EdgeInsets.only(bottom: 60.h),
        child: const Icon(Icons.description, color: Color(0xFFE91E63)),
      ),
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12.r),
      ),
      enabledBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12.r),
        borderSide: BorderSide(color: Colors.grey[300]!),
      ),
      focusedBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12.r),
        borderSide: const BorderSide(
          color: Color(0xFFE91E63),
          width: 2,
        ),
      ),
      filled: true,
      fillColor: Colors.grey[50],
    ),
    maxLines: 5,
    minLines: 3,
    validator: (value) {
      if (value == null || value.isEmpty) {
        return '请输入内容';
      }
      if (value.length < 5) {
        return '内容至少5个字符';
      }
      return null;
    },
    textInputAction: TextInputAction.newline,
  );
}

maxLines设为5,minLines设为3,给用户足够的输入空间。图标用Padding调整位置,让它在顶部对齐。提示文字引导用户记录美好回忆,内容也是必填的。

日期选择器

用卡片样式展示日期选择:

Widget _buildDateSelector() {
  return Container(
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(12.r),
      border: Border.all(color: Colors.grey[300]!),
    ),
    child: ListTile(
      leading: Container(
        padding: EdgeInsets.all(8.w),
        decoration: BoxDecoration(
          color: const Color(0xFFE91E63).withOpacity(0.1),
          borderRadius: BorderRadius.circular(8.r),
        ),
        child: const Icon(
          Icons.calendar_today,
          color: Color(0xFFE91E63),
        ),
      ),
      title: const Text(
        '日期',
        style: TextStyle(fontWeight: FontWeight.w500),
      ),
      subtitle: Text(
        DateFormat('yyyy年MM月dd日 EEEE', 'zh_CN').format(_selectedDate),
        style: TextStyle(
          color: const Color(0xFFE91E63),
          fontSize: 14.sp,
        ),
      ),
      trailing: const Icon(Icons.chevron_right, color: Colors.grey),
      onTap: _selectDate,
    ),
  );
}

日期选择用ListTile包装在圆角容器里,左边有带颜色的图标。日期格式包含星期几,让用户更清楚。点击整个区域都能触发日期选择器。

日期选择对话框

弹出系统日期选择器:

Future<void> _selectDate() async {
  final date = await showDatePicker(
    context: context,
    initialDate: _selectedDate,
    firstDate: DateTime(1900),
    lastDate: DateTime.now(),
    locale: const Locale('zh', 'CN'),
    builder: (context, child) {
      return Theme(
        data: Theme.of(context).copyWith(
          colorScheme: const ColorScheme.light(
            primary: Color(0xFFE91E63),
            onPrimary: Colors.white,
            surface: Colors.white,
            onSurface: Colors.black,
          ),
        ),
        child: child!,
      );
    },
  );
  
  if (date != null && date != _selectedDate) {
    setState(() {
      _selectedDate = date;
    });
  }
}

lastDate设成今天,不能选未来的日期,因为回忆都是过去的事情。用Theme包装日期选择器,让它的颜色和应用主题一致。选择后更新状态,UI会自动刷新显示新日期。

地点输入框

地点是可选的,用户可以记录回忆发生的地方:

Widget _buildLocationField() {
  return TextFormField(
    controller: _locationController,
    decoration: InputDecoration(
      labelText: '地点(可选)',
      hintText: '这段回忆发生在哪里',
      prefixIcon: const Icon(Icons.location_on, color: Color(0xFFE91E63)),
      suffixIcon: _locationController.text.isNotEmpty
          ? IconButton(
              icon: const Icon(Icons.clear, size: 20),
              onPressed: () {
                setState(() {
                  _locationController.clear();
                });
              },
            )
          : null,
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12.r),
      ),
      enabledBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12.r),
        borderSide: BorderSide(color: Colors.grey[300]!),
      ),
      focusedBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12.r),
        borderSide: const BorderSide(
          color: Color(0xFFE91E63),
          width: 2,
        ),
      ),
      filled: true,
      fillColor: Colors.grey[50],
    ),
    textInputAction: TextInputAction.done,
  );
}

prefixIcon显示定位图标,标签标注"可选"让用户知道可以跳过。如果输入了内容,右边会显示清除按钮,方便用户快速清空。

家人选择器

用FilterChip实现多选家人:

Widget _buildMemberSelector(FamilyProvider provider) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Row(
        children: [
          Container(
            padding: EdgeInsets.all(8.w),
            decoration: BoxDecoration(
              color: const Color(0xFFE91E63).withOpacity(0.1),
              borderRadius: BorderRadius.circular(8.r),
            ),
            child: const Icon(
              Icons.people,
              color: Color(0xFFE91E63),
              size: 20,
            ),
          ),
          SizedBox(width: 8.w),
          Text(
            '相关家人',
            style: TextStyle(
              fontSize: 16.sp,
              fontWeight: FontWeight.w600,
              color: Colors.black87,
            ),
          ),
          const Spacer(),
          if (_selectedMemberIds.isNotEmpty)
            Container(
              padding: EdgeInsets.symmetric(
                horizontal: 8.w,
                vertical: 4.h,
              ),
              decoration: BoxDecoration(
                color: const Color(0xFFE91E63),
                borderRadius: BorderRadius.circular(12.r),
              ),
              child: Text(
                '已选 ${_selectedMemberIds.length}',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 12.sp,
                  fontWeight: FontWeight.w600,
                ),
              ),
            ),
        ],
      ),
      SizedBox(height: 12.h),
      if (provider.familyMembers.isEmpty)
        Container(
          padding: EdgeInsets.all(16.w),
          decoration: BoxDecoration(
            color: Colors.grey[100],
            borderRadius: BorderRadius.circular(12.r),
          ),
          child: Row(
            children: [
              Icon(Icons.info_outline, color: Colors.grey[600]),
              SizedBox(width: 8.w),
              Expanded(
                child: Text(
                  '还没有家人,先去添加家人吧',
                  style: TextStyle(
                    color: Colors.grey[600],
                    fontSize: 14.sp,
                  ),
                ),
              ),
            ],
          ),
        )
      else
        Wrap(
          spacing: 8.w,
          runSpacing: 8.h,
          children: provider.familyMembers.map((member) {
            final isSelected = _selectedMemberIds.contains(member.id);
            return FilterChip(
              label: Text(member.name),
              selected: isSelected,
              onSelected: (selected) {
                setState(() {
                  if (selected) {
                    _selectedMemberIds.add(member.id);
                  } else {
                    _selectedMemberIds.remove(member.id);
                  }
                });
              },
              selectedColor: const Color(0xFFE91E63).withOpacity(0.2),
              checkmarkColor: const Color(0xFFE91E63),
              backgroundColor: Colors.white,
              side: BorderSide(
                color: isSelected
                    ? const Color(0xFFE91E63)
                    : Colors.grey[300]!,
              ),
              labelStyle: TextStyle(
                color: isSelected
                    ? const Color(0xFFE91E63)
                    : Colors.black87,
                fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
              ),
            );
          }).toList(),
        ),
    ],
  );
}

标题旁边显示已选数量,让用户清楚知道选了几个人。如果还没有家人,显示提示信息引导用户先添加家人。遍历所有家人,每个人一个FilterChip,选中时添加ID到列表,取消时移除。

照片选择器

让用户可以关联照片到回忆:

Widget _buildPhotoSelector() {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Row(
        children: [
          Container(
            padding: EdgeInsets.all(8.w),
            decoration: BoxDecoration(
              color: const Color(0xFFE91E63).withOpacity(0.1),
              borderRadius: BorderRadius.circular(8.r),
            ),
            child: const Icon(
              Icons.photo_library,
              color: Color(0xFFE91E63),
              size: 20,
            ),
          ),
          SizedBox(width: 8.w),
          Text(
            '相关照片',
            style: TextStyle(
              fontSize: 16.sp,
              fontWeight: FontWeight.w600,
              color: Colors.black87,
            ),
          ),
          const Spacer(),
          TextButton.icon(
            onPressed: _selectPhotos,
            icon: const Icon(Icons.add, size: 18),
            label: const Text('添加照片'),
            style: TextButton.styleFrom(
              foregroundColor: const Color(0xFFE91E63),
            ),
          ),
        ],
      ),
      SizedBox(height: 12.h),
      if (_selectedPhotoIds.isEmpty)
        Container(
          padding: EdgeInsets.all(16.w),
          decoration: BoxDecoration(
            color: Colors.grey[100],
            borderRadius: BorderRadius.circular(12.r),
            border: Border.all(
              color: Colors.grey[300]!,
              style: BorderStyle.solid,
            ),
          ),
          child: Center(
            child: Column(
              children: [
                Icon(
                  Icons.photo_outlined,
                  size: 48.sp,
                  color: Colors.grey[400],
                ),
                SizedBox(height: 8.h),
                Text(
                  '还没有添加照片',
                  style: TextStyle(
                    color: Colors.grey[600],
                    fontSize: 14.sp,
                  ),
                ),
              ],
            ),
          ),
        )
      else
        GridView.builder(
          shrinkWrap: true,
          physics: const NeverScrollableScrollPhysics(),
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 4,
            crossAxisSpacing: 8.w,
            mainAxisSpacing: 8.h,
          ),
          itemCount: _selectedPhotoIds.length,
          itemBuilder: (context, index) {
            return _buildPhotoItem(index);
          },
        ),
    ],
  );
}

Widget _buildPhotoItem(int index) {
  return Stack(
    children: [
      Container(
        decoration: BoxDecoration(
          color: Colors.grey[300],
          borderRadius: BorderRadius.circular(8.r),
        ),
        child: Center(
          child: Icon(
            Icons.photo,
            color: Colors.grey[500],
          ),
        ),
      ),
      Positioned(
        top: 4.w,
        right: 4.w,
        child: GestureDetector(
          onTap: () {
            setState(() {
              _selectedPhotoIds.removeAt(index);
            });
          },
          child: Container(
            padding: EdgeInsets.all(4.w),
            decoration: const BoxDecoration(
              color: Colors.red,
              shape: BoxShape.circle,
            ),
            child: Icon(
              Icons.close,
              size: 12.sp,
              color: Colors.white,
            ),
          ),
        ),
      ),
    ],
  );
}

void _selectPhotos() {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: const Text('照片选择功能开发中'),
      behavior: SnackBarBehavior.floating,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(8.r),
      ),
      margin: EdgeInsets.all(16.w),
    ),
  );
}

照片选择器显示已添加的照片网格,每个照片右上角有删除按钮。如果还没添加照片,显示空状态提示。点击添加照片按钮可以选择照片(这里用SnackBar模拟)。

保存逻辑

验证表单并保存回忆:

void _saveMemory() {
  if (_formKey.currentState!.validate()) {
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (_) => const Center(
        child: CircularProgressIndicator(
          color: Color(0xFFE91E63),
        ),
      ),
    );
    
    final memory = Memory(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      title: _titleController.text.trim(),
      description: _contentController.text.trim(),
      date: _selectedDate,
      memberIds: _selectedMemberIds,
      photoIds: _selectedPhotoIds,
      location: _locationController.text.trim().isNotEmpty
          ? _locationController.text.trim()
          : null,
      createdAt: DateTime.now(),
    );
    
    Future.delayed(const Duration(milliseconds: 500), () {
      context.read<FamilyProvider>().addMemory(memory);
      
      Navigator.pop(context);
      Navigator.pop(context);
      
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Row(
            children: [
              const Icon(Icons.check_circle, color: Colors.white),
              SizedBox(width: 8.w),
              const Text('回忆添加成功'),
            ],
          ),
          backgroundColor: const Color(0xFF4CAF50),
          behavior: SnackBarBehavior.floating,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(8.r),
          ),
          margin: EdgeInsets.all(16.w),
          duration: const Duration(seconds: 2),
        ),
      );
    });
  }
}

保存前先验证表单,验证通过后显示加载提示。创建Memory对象时用当前时间戳作为ID,地点为空时传null。调用Provider的addMemory保存数据,然后关闭页面并显示成功提示。

添加确认对话框

在保存前让用户确认信息:

void _showConfirmDialog() {
  if (!_formKey.currentState!.validate()) {
    return;
  }
  
  showDialog(
    context: context,
    builder: (dialogContext) => AlertDialog(
      title: const Text('确认添加'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _buildConfirmItem('标题', _titleController.text),
          _buildConfirmItem(
            '日期',
            DateFormat('yyyy年MM月dd日').format(_selectedDate),
          ),
          if (_locationController.text.isNotEmpty)
            _buildConfirmItem('地点', _locationController.text),
          if (_selectedMemberIds.isNotEmpty)
            _buildConfirmItem(
              '家人',
              '${_selectedMemberIds.length} 位',
            ),
          if (_selectedPhotoIds.isNotEmpty)
            _buildConfirmItem(
              '照片',
              '${_selectedPhotoIds.length} 张',
            ),
        ],
      ),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16.r),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(dialogContext),
          child: Text(
            '再看看',
            style: TextStyle(
              color: Colors.grey[600],
              fontSize: 15.sp,
            ),
          ),
        ),
        TextButton(
          onPressed: () {
            Navigator.pop(dialogContext);
            _saveMemory();
          },
          child: Text(
            '确认添加',
            style: TextStyle(
              color: const Color(0xFFE91E63),
              fontSize: 15.sp,
              fontWeight: FontWeight.w600,
            ),
          ),
        ),
      ],
    ),
  );
}

Widget _buildConfirmItem(String label, String value) {
  return Padding(
    padding: EdgeInsets.only(bottom: 8.h),
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        SizedBox(
          width: 50.w,
          child: Text(
            '$label:',
            style: TextStyle(
              fontSize: 14.sp,
              color: Colors.grey[600],
            ),
          ),
        ),
        Expanded(
          child: Text(
            value,
            style: TextStyle(
              fontSize: 14.sp,
              fontWeight: FontWeight.w500,
            ),
          ),
        ),
      ],
    ),
  );
}

确认对话框列出用户填写的所有信息,让用户最后检查一遍。只显示已填写的项目,空的项目不显示。

总结

添加回忆页面通过清晰的表单布局收集用户输入。标题和内容是必填项,有完善的验证逻辑。日期选择器限制只能选过去的日期,符合回忆的特点。家人多选用FilterChip实现,选中状态有明显的视觉反馈。照片选择器支持添加和删除照片。保存前显示确认对话框,让用户检查信息。整个流程清晰流畅,用户体验良好。

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

Logo

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

更多推荐