flutter_for_openharmony家庭相册app实战+添加回忆实现
本文介绍了家庭回忆记录应用的添加回忆表单页面实现。该页面采用Flutter框架开发,包含标题、内容、日期、地点和参与者等输入项。核心功能包括: 表单验证:标题和内容为必填项,分别设置最小长度限制 交互设计:采用圆角输入框,聚焦时显示粉色边框提升用户体验 多行文本:内容区域支持3-5行输入,方便详细描述回忆 状态管理:使用TextEditingController处理输入内容,并正确释放资源 页面布

记录家庭回忆是这个应用的核心功能之一。一次聚餐、一场旅行、一个特别的日子,都值得记录下来。今天我们来实现添加回忆的表单页面,让用户可以方便地记录美好时光。
设计思路
添加回忆页面需要收集标题、内容、日期、地点和相关家人等信息。标题和内容是必填项,其他信息可选。相关家人用多选的方式,用户可以选择多个参与者。整个表单采用垂直滚动布局,每个输入项之间有适当的间距。
创建页面结构
先搭建基本框架:
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
更多推荐
所有评论(0)