Flutter for OpenHarmony 校园闲置跳蚤市场APP 实战DAY4:发布闲置页面+表单校验+本地存储提交

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

哈喽各位小伙伴!咱们校园闲置跳蚤市场连载来到DAY4啦🎉 进度稳步推进,新手跟着敲完全能跟上,全程不跳步、不搞复杂概念,还是老规矩:口语化大段讲解+每处关键功能附5–6行精简代码,不堆冗余代码,写完直接能发CSDN,兼顾实用性和毕设规范,今天重点搞定「发布闲置」这个核心功能——毕竟有发布,才能有真正的闲置商品,这也是整个APP的核心交互之一!

回顾前三期进度,帮大家快速回顾,避免脱节:

  • DAY1:新建项目、配依赖、搭底部Tab导航、全局初始化,打好项目地基;
  • DAY2:写商品实体类、预置校园分类/成色常量、搭首页顶部分类标签;
  • DAY3:封装商品卡片、造模拟假数据、实现分类联动筛选,首页完全成型。

今天DAY4,我们聚焦「发布闲置」页面,从布局搭建、表单输入、校验,到提交保存、同步刷新首页,一步步落地,难度循序渐进,新手不用慌,每一步都有详细讲解和精简代码,照抄就能跑通。
在这里插入图片描述

🚀 DAY4 本期开发目标(详细版,新手必看)

  1. 搭建「发布闲置」完整页面布局,贴合校园场景,包含:多图上传占位、标题输入、价格输入、分类选择、成色选择、描述输入、提交按钮;
  2. 实现所有表单输入逻辑,绑定控制器,确保输入内容能正常获取;
  3. 做表单校验(必填项不能为空、价格不能为0/负数),避免无效提交,提升用户体验;
  4. 对接本地存储,实现「发布闲置提交后,自动存入本地,首页实时刷新」;
  5. 生成商品唯一ID(避免重复)、自动获取当前发布时间,不用手动输入;
  6. 优化发布页面UI,贴合鸿蒙简约风格,和首页、卡片样式统一,细节拉满;
  7. 解决新手常见的「发布后不刷新」「数据存不上」「表单报错」等问题,提前避坑。

一、搭建发布闲置页面整体布局(核心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细节优化+鸿蒙适配(毕设加分项)

  1. 统一样式:所有输入框、下拉框的圆角都是8,和卡片圆角呼应,视觉统一;
  2. 按钮状态:提交按钮可添加“加载中”状态(后期完善),避免用户重复点击;
  3. 输入提示:所有hintText都贴合校园场景,引导用户正确输入;
  4. 间距优化:所有表单间距统一,避免大小不一,视觉更整齐;
  5. 适配键盘: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件大事,难度适中,新手完全能跟上:

  1. 搭建了发布闲置完整页面布局,包含图片上传占位、所有表单组件,样式贴合鸿蒙风格;
  2. 实现了所有表单输入逻辑,绑定控制器,能正常获取输入内容;
  3. 做了表单校验,避免无效提交,提升用户体验;
  4. 对接本地存储,实现“发布商品存入本地”,返回首页提示发布成功。

现在,我们的APP已经能“发布闲置”了,虽然首页还不能自动刷新,但数据已经能正常存入本地,下一步就是完善首页刷新、我的发布页面,让整个流程闭环。

📅 DAY5 预告

DAY5 我们重点解决“发布后刷新”和“我的发布”页面,难度依旧循序渐进:

  1. 用通知机制,实现「发布闲置后,首页自动刷新列表」,不用重启APP;
  2. 搭建「我的发布」页面,展示当前用户发布的所有闲置商品;
  3. 实现“我的发布”页面与本地存储联动,实时同步发布、删除数据;
  4. 优化列表滑动流畅度,适配鸿蒙手机,避免卡顿;
  5. 补充“空数据占位”,我的发布页面没有商品时,显示空提示。

要不要我直接接着给你写DAY5完整正文,保持同风格、同结构、带精简代码、口语化讲解,直接可发CSDN?

Logo

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

更多推荐