Flutter for OpenHarmony 校园闲置跳蚤市场APP 实战DAY4:发布闲置页面+表单校验+本地存储提交
Flutter for OpenHarmony 校园闲置跳蚤市场APP实战DAY4摘要 本文是校园闲置跳蚤市场APP开发的第4天教程,重点实现"发布闲置"核心功能。主要内容包括: 页面布局:搭建包含图片上传、标题、价格、分类、成色、描述等表单的发布页面,采用SingleChildScrollView避免键盘遮挡问题。 表单实现: 图片上传占位区(后期可扩展真实上传) 标题输入框
Flutter for OpenHarmony 校园闲置跳蚤市场APP 实战DAY4:发布闲置页面+表单校验+本地存储提交
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
哈喽各位小伙伴!咱们校园闲置跳蚤市场连载来到DAY4啦🎉 进度稳步推进,新手跟着敲完全能跟上,全程不跳步、不搞复杂概念,还是老规矩:口语化大段讲解+每处关键功能附5–6行精简代码,不堆冗余代码,写完直接能发CSDN,兼顾实用性和毕设规范,今天重点搞定「发布闲置」这个核心功能——毕竟有发布,才能有真正的闲置商品,这也是整个APP的核心交互之一!
回顾前三期进度,帮大家快速回顾,避免脱节:
- DAY1:新建项目、配依赖、搭底部Tab导航、全局初始化,打好项目地基;
- DAY2:写商品实体类、预置校园分类/成色常量、搭首页顶部分类标签;
- DAY3:封装商品卡片、造模拟假数据、实现分类联动筛选,首页完全成型。
今天DAY4,我们聚焦「发布闲置」页面,从布局搭建、表单输入、校验,到提交保存、同步刷新首页,一步步落地,难度循序渐进,新手不用慌,每一步都有详细讲解和精简代码,照抄就能跑通。
🚀 DAY4 本期开发目标(详细版,新手必看)
- 搭建「发布闲置」完整页面布局,贴合校园场景,包含:多图上传占位、标题输入、价格输入、分类选择、成色选择、描述输入、提交按钮;
- 实现所有表单输入逻辑,绑定控制器,确保输入内容能正常获取;
- 做表单校验(必填项不能为空、价格不能为0/负数),避免无效提交,提升用户体验;
- 对接本地存储,实现「发布闲置提交后,自动存入本地,首页实时刷新」;
- 生成商品唯一ID(避免重复)、自动获取当前发布时间,不用手动输入;
- 优化发布页面UI,贴合鸿蒙简约风格,和首页、卡片样式统一,细节拉满;
- 解决新手常见的「发布后不刷新」「数据存不上」「表单报错」等问题,提前避坑。
一、搭建发布闲置页面整体布局(核心UI)
发布页面是用户交互的关键,布局要清晰、操作要简单,贴合学生使用习惯——不用复杂排版,按「图片上传→基本信息→提交」的顺序排布,一目了然。
先打开 page/publish/publish_page.dart(DAY1已经建好文件夹,直接新建这个文件),整体布局用 SingleChildScrollView 包裹,避免小屏手机键盘弹出后遮挡表单,这是新手最容易忽略的点,加上这个,体验更友好。
整体布局核心代码(精简版,直接复制)
import 'package:flutter/material.dart';
import 'package:campus_flea_market/config/app_color.dart';
import 'package:campus_flea_market/config/app_constant.dart';
class PublishPage extends StatefulWidget {
const PublishPage({super.key});
State<PublishPage> createState() => _PublishPageState();
}
class _PublishPageState extends State<PublishPage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("发布闲置"),
backgroundColor: AppColor.primary,
foregroundColor: Colors.white,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. 多图上传区域(今天先做占位,后期加真实上传)
_buildImageUpload(),
const SizedBox(height: 20),
// 2. 标题输入框
_buildTitleInput(),
const SizedBox(height: 15),
// 3. 价格输入框
_buildPriceInput(),
const SizedBox(height: 15),
// 4. 分类选择
_buildCategorySelect(),
const SizedBox(height: 15),
// 5. 成色选择
_buildConditionSelect(),
const SizedBox(height: 15),
// 6. 描述输入框
_buildDescInput(),
const SizedBox(height: 30),
// 7. 提交按钮
_buildSubmitBtn(),
],
),
),
);
}
}
布局说明(口语化拆解)
- 顶部AppBar:标题「发布闲置」,用全局主色,白色文字,贴合鸿蒙原生APP风格;
- 用SingleChildScrollView包裹所有表单,解决键盘遮挡问题,新手一定要加;
- 所有表单按顺序排布,间距统一(15/20),视觉整齐,操作流畅;
- 每个表单区域单独封装成一个方法(比如_buildImageUpload),代码结构清晰,后期修改方便,符合工程化规范,毕设加分。
二、逐个实现表单组件(附核心代码)
我们逐个实现上面布局里的7个部分,每个部分都附5–6行精简代码,不堆长代码,新手照抄就能用,重点讲解核心逻辑,不用死记硬背。
1. 多图上传占位区域(后期可拓展真实上传)
学生发布闲置,肯定要传商品图片,今天先做占位布局,样式美观,后期对接图片上传功能完全不用改布局,先实现“样子”,再完善功能。
// 多图上传占位
Widget _buildImageUpload() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("商品图片(最多3张)", style: TextStyle(fontSize: 15)),
const SizedBox(height: 8),
// 图片占位框,点击可添加图片
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.add_photo_alternate, color: Colors.grey),
),
],
);
}
- 用灰色边框+加号图标做占位,直观提示用户“点击添加图片”;
- 限制最多3张图,贴合校园闲置场景(不用太多图,清晰即可);
- 后期对接图片上传,只需要替换这个占位容器,其他布局不变,扩展性强。

2. 标题输入框(必填项)
标题是商品的核心标识,必须让用户填写,用TextField做输入框,限制输入长度(最多30字),避免标题过长。
// 标题输入框
final TextEditingController _titleCtrl = TextEditingController();
Widget _buildTitleInput() {
return TextField(
controller: _titleCtrl,
maxLength: 30,
decoration: InputDecoration(
hintText: "请输入商品标题(如:九成新iPhone13)",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
- 用TextEditingController获取输入的标题内容,后续提交时用;
- 限制30字,符合校园闲置标题习惯(简洁明了);
- 用OutlineInputBorder做边框,圆角8,贴合鸿蒙简约UI风格。
3. 价格输入框(必填项,只能输数字)
价格是学生交易最关心的,必须限制输入类型(只能输数字和小数点),避免输入中文、符号,同时后续要校验“不能为0、不能为负数”。
// 价格输入框
final TextEditingController _priceCtrl = TextEditingController();
Widget _buildPriceInput() {
return TextField(
controller: _priceCtrl,
keyboardType: TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
hintText: "请输入商品价格(元)",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
suffixText: "元",
),
);
}
- keyboardType设置为数字+小数点,避免无效输入;
- 后缀加“元”,用户体验更友好,不用手动输入单位;
- 同样用控制器获取输入的价格,后续转成double类型存入实体类。
4. 分类选择(下拉选择,复用全局常量)
分类直接复用DAY2预置的AppConstant.goodsCategory,不用手动写分类选项,下拉选择,操作简单,避免用户手动输入分类导致匹配不到。
// 分类选择(下拉框)
String? _selectedCategory;
Widget _buildCategorySelect() {
return DropdownButtonFormField(
value: _selectedCategory,
hint: const Text("请选择商品分类"),
items: AppConstant.goodsCategory
.skip(1) // 跳过“全部”,发布时不能选“全部”
.map((category) => DropdownMenuItem(
value: category,
child: Text(category),
))
.toList(),
decoration: InputDecoration(border: OutlineInputBorder(borderRadius: BorderRadius.circular(8))),
onChanged: (value) => setState(() => _selectedCategory = value),
);
}
- 跳过“全部”分类(发布商品必须选具体分类,不能选“全部”);
- 下拉选项直接复用全局常量,后期新增分类,这里自动同步,不用改代码;
- 用DropdownButtonFormField,和其他输入框样式统一,视觉更整齐。

5. 成色选择(下拉选择,复用全局常量)
和分类选择逻辑一样,复用DAY2预置的商品成色常量,下拉选择,操作简单,贴合学生发布习惯。
// 成色选择(下拉框)
String? _selectedCondition;
Widget _buildConditionSelect() {
return DropdownButtonFormField(
value: _selectedCondition,
hint: const Text("请选择商品成色"),
items: AppConstant.goodsCondition
.map((condition) => DropdownMenuItem(
value: condition,
child: Text(condition),
))
.toList(),
decoration: InputDecoration(border: OutlineInputBorder(borderRadius: BorderRadius.circular(8))),
onChanged: (value) => setState(() => _selectedCondition = value),
);
}
- 直接复用全局成色常量,不用重复写选项;
- 样式和分类下拉框统一,保持页面整体风格一致;
- 用String?类型,默认null,后续校验时判断是否选择。
6. 描述输入框(可选,可多行输入)
商品描述让用户补充商品细节(比如“无笔记、无划痕”),支持多行输入,限制最多200字,避免描述过长。
// 描述输入框
final TextEditingController _descCtrl = TextEditingController();
Widget _buildDescInput() {
return TextField(
controller: _descCtrl,
maxLines: 4,
maxLength: 200,
decoration: InputDecoration(
hintText: "请输入商品描述(可选,如:无划痕、无使用痕迹)",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
- maxLines设为4,支持多行输入,方便用户填写细节;
- 限制200字,避免描述过于冗长;
- 可选填,不用强制用户填写,更人性化。
7. 提交按钮(核心交互)
提交按钮要突出,用全局主色,点击后触发表单校验、数据封装、本地存储,最后返回首页并刷新列表。
// 提交按钮
Widget _buildSubmitBtn() {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppConstant.primary,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: _submitGoods, // 点击触发提交逻辑
child: const Text("发布闲置", style: TextStyle(fontSize: 16, color: Colors.white)),
),
);
}
- 按钮占满全屏宽度,视觉突出,方便点击;
- 用全局主色,和AppBar颜色统一,风格一致;
- 绑定_submitGoods方法,后续实现提交逻辑,代码解耦,方便维护。
三、实现表单校验+提交逻辑(本期重点)
提交按钮点击后,不能直接存数据,要先做校验(避免必填项为空、价格异常),校验通过后,再封装成GoodsModel对象,存入本地存储,最后返回首页并刷新列表,逻辑一步都不能少,新手跟着步骤来,不踩坑。
1. 表单校验逻辑(核心,避免无效提交)
先实现_submitGoods方法,第一步做校验,用大白话讲清楚校验规则:
- 标题不能为空;
- 价格不能为空、不能为0、不能为负数;
- 分类必须选择;
- 成色必须选择。
// 提交闲置逻辑
void _submitGoods() {
// 1. 获取所有输入内容
String title = _titleCtrl.text.trim();
String priceStr = _priceCtrl.text.trim();
String desc = _descCtrl.text.trim();
// 2. 表单校验
if (title.isEmpty) {
_showToast("请输入商品标题");
return;
}
if (priceStr.isEmpty || double.parse(priceStr) <= 0) {
_showToast("请输入有效的商品价格(大于0)");
return;
}
if (_selectedCategory == null) {
_showToast("请选择商品分类");
return;
}
if (_selectedCondition == null) {
_showToast("请选择商品成色");
return;
}
// 校验通过,继续封装数据
_saveGoods(title, priceStr, desc);
}
2. 封装提示弹窗(复用,提升体验)
上面用到的_showToast方法,是提示用户“必填项未填写”“价格无效”的弹窗,单独封装,后续可复用,不用重复写代码。
// 提示弹窗(复用)
void _showToast(String msg) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
duration: const Duration(seconds: 1),
backgroundColor: Colors.grey.shade800,
),
);
}
- 用SnackBar做提示,贴合鸿蒙原生交互风格;
- 提示时长1秒,不遮挡太久,用户体验友好;
- 单独封装,后续其他页面也能复用。
3. 封装商品数据+存入本地存储
校验通过后,将所有输入内容封装成GoodsModel对象,生成唯一ID、获取当前时间,然后存入本地存储,最后返回首页并刷新列表。
// 封装商品数据,存入本地
void _saveGoods(String title, String priceStr, String desc) {
// 生成商品唯一ID(避免重复,用时间戳+随机数)
String goodsId = DateTime.now().millisecondsSinceEpoch.toString();
// 价格转成double类型
double price = double.parse(priceStr);
// 获取当前发布时间(格式:yyyy-MM-dd)
String time = DateFormat("yyyy-MM-dd").format(DateTime.now());
// 封装成GoodsModel对象
GoodsModel goods = GoodsModel(
id: goodsId,
title: title,
price: price,
category: _selectedCategory!,
condition: _selectedCondition!,
desc: desc,
time: time,
);
// 存入本地存储
_saveToLocal(goods);
}
- 唯一ID:用当前时间戳(毫秒级),确保不会重复,新手不用搞复杂的ID生成方式,这个足够用;
- 发布时间:自动获取当前日期,不用用户手动输入,更便捷;
- 强制解包_selectedCategory和_selectedCondition(因为前面已经校验过,不会为null)。
4. 本地存储逻辑(对接DAY1的StorageUtil)
调用DAY1封装的StorageUtil工具类,将新发布的商品添加到本地列表中,然后返回首页,刷新首页列表,实现“发布即显示”。
// 存入本地存储,刷新首页
void _saveToLocal(GoodsModel goods) {
// 1. 获取本地已有的商品列表
List<GoodsModel> localList = StorageUtil.getGoodsList();
// 2. 添加新发布的商品到列表最前面(最新发布的置顶)
localList.insert(0, goods);
// 3. 重新保存到本地
StorageUtil.saveGoodsList(localList);
// 4. 返回首页,关闭发布页面
Navigator.pop(context);
// 5. 发送通知,让首页刷新列表(后续DAY5完善,今天先实现基础保存)
_showToast("发布成功!");
}
- 最新发布的商品插入到列表最前面,实现“最新置顶”,符合用户习惯;
- 保存完成后,关闭发布页面,返回首页,提示“发布成功”;
- 首页刷新逻辑,我们DAY5完善(用通知或状态管理),今天先实现“发布后存入本地”,确保数据不会丢失。
四、补充:完善StorageUtil工具类(新增商品存储方法)
DAY1我们只写了基础的字符串存储,今天需要新增“商品列表”的存取方法,适配GoodsModel实体类,直接复制下面的代码,添加到util/storage_util.dart中。
// 新增:保存商品列表到本地
static Future<void> saveGoodsList(List<GoodsModel> list) async {
List<String> jsonList = list.map((e) => jsonEncode(e.toJson())).toList();
await _prefs.setStringList("goods_list", jsonList);
}
// 新增:从本地获取商品列表
static List<GoodsModel> getGoodsList() {
List<String>? jsonList = _prefs.getStringList("goods_list");
if (jsonList == null) return [];
return jsonList.map((e) => GoodsModel.fromJson(jsonDecode(e))).toList();
}
- 和DAY2的商品实体类序列化对应,确保数据能正常存、正常读;
- 存储key用“goods_list”,和之前的存储key区分开,避免冲突;
- 空值兜底,第一次打开APP,本地没有商品列表时,返回空列表,不会报错。
五、UI细节优化+鸿蒙适配(毕设加分项)
- 统一样式:所有输入框、下拉框的圆角都是8,和卡片圆角呼应,视觉统一;
- 按钮状态:提交按钮可添加“加载中”状态(后期完善),避免用户重复点击;
- 输入提示:所有hintText都贴合校园场景,引导用户正确输入;
- 间距优化:所有表单间距统一,避免大小不一,视觉更整齐;
- 适配键盘:SingleChildScrollView确保键盘弹出时,表单不会被遮挡,鸿蒙手机适配无压力。
六、新手常见问题答疑(避坑重点)
问题1:提交后,本地存储存不上数据?
原因:1. 没完善StorageUtil的商品存取方法;2. 实体类序列化代码写错;3. 存储key和读取key不一致。
解决:直接复制本文中的StorageUtil新增代码,确保key是“goods_list”,序列化代码和实体类字段一致。
问题2:价格输入框能输入中文、符号?
原因:没设置keyboardType为numberWithOptions(decimal: true);
解决:检查_priceCtrl对应的TextField,加上keyboardType配置,重启运行即可。
问题3:提交后,首页列表不刷新?
原因:今天我们只实现了“存入本地”,还没做首页刷新逻辑,DAY5会完善(用通知刷新);
解决:暂时可以重启APP,就能看到新发布的商品,DAY5我们实现“发布后自动刷新”。
问题4:下拉选择分类/成色后,点击提交还是提示“请选择”?
原因:_selectedCategory或_selectedCondition没有用setState更新状态;
解决:检查DropdownButtonFormField的onChanged方法,确保用setState更新变量。
问题5:生成的商品ID重复?
原因:用了简单的随机数,容易重复;
解决:本文用“时间戳”生成ID,毫秒级时间戳不会重复,直接用本文的代码即可。
✅ DAY4 小结
今天我们完成了「发布闲置」核心功能,实打实落地了4件大事,难度适中,新手完全能跟上:
- 搭建了发布闲置完整页面布局,包含图片上传占位、所有表单组件,样式贴合鸿蒙风格;
- 实现了所有表单输入逻辑,绑定控制器,能正常获取输入内容;
- 做了表单校验,避免无效提交,提升用户体验;
- 对接本地存储,实现“发布商品存入本地”,返回首页提示发布成功。
现在,我们的APP已经能“发布闲置”了,虽然首页还不能自动刷新,但数据已经能正常存入本地,下一步就是完善首页刷新、我的发布页面,让整个流程闭环。
📅 DAY5 预告
DAY5 我们重点解决“发布后刷新”和“我的发布”页面,难度依旧循序渐进:
- 用通知机制,实现「发布闲置后,首页自动刷新列表」,不用重启APP;
- 搭建「我的发布」页面,展示当前用户发布的所有闲置商品;
- 实现“我的发布”页面与本地存储联动,实时同步发布、删除数据;
- 优化列表滑动流畅度,适配鸿蒙手机,避免卡顿;
- 补充“空数据占位”,我的发布页面没有商品时,显示空提示。
要不要我直接接着给你写DAY5完整正文,保持同风格、同结构、带精简代码、口语化讲解,直接可发CSDN?
更多推荐
所有评论(0)