【开源鸿蒙跨平台开发先锋训练营】DAY8~DAY13 底部选项卡&首页功能实现

【开源鸿蒙跨平台开发先锋训练营】DAY8~DAY13 底部选项卡&首页功能实现

目录

[【开源鸿蒙跨平台开发先锋训练营】DAY8~DAY13 底部选项卡&首页功能实现](#【开源鸿蒙跨平台开发先锋训练营】DAY8~DAY13 底部选项卡&首页功能实现)

[摘 要](#摘 要)

[1 概述](#1 概述)

[1.1 开发背景](#1.1 开发背景)

[1.2 开发目标](#1.2 开发目标)

[1.3 核心技术栈](#1.3 核心技术栈)

[2 开发环境与准备](#2 开发环境与准备)

[2.1 环境配置](#2.1 环境配置)

[2.2 依赖安装(pubspec.yaml)](#2.2 依赖安装(pubspec.yaml))

[2.3 项目目录结构](#2.3 项目目录结构)

[3 底部选项卡开发](#3 底部选项卡开发)

[3.1 底部选项卡设计规范](#3.1 底部选项卡设计规范)

[3.2 底部导航组件实现](#3.2 底部导航组件实现)

[3.3 项目入口配置](#3.3 项目入口配置)

[4 首页功能实现](#4 首页功能实现)

[4.1 首页主页面实现](#4.1 首页主页面实现)

[4.2 首页子组件实现](#4.2 首页子组件实现)

[4.2.1 搜索栏](#4.2.1 搜索栏)

[4.2.2 轮播推荐区](#4.2.2 轮播推荐区)

[4.2.3 分类标签](#4.2.3 分类标签)

[4.2.4 美食卡片](#4.2.4 美食卡片)

[4.3 数据模型与接口实现](#4.3 数据模型与接口实现)

[4.3.1 美食模型](#4.3.1 美食模型)

[4.3.2 美食数据接口](#4.3.2 美食数据接口)

[5 各选项卡页面](#5 各选项卡页面)

[5.1 美食页面](#5.1 美食页面)

[5.2 动态页面](#5.2 动态页面)

[5.3 推荐页面](#5.3 推荐页面)

[5.4 我的页面](#5.4 我的页面)

[6 运行验证与代码提交](#6 运行验证与代码提交)

[6.1 开源鸿蒙终端运行验证](#6.1 开源鸿蒙终端运行验证)

[6.2 Git 代码提交规范(AtomGit)](#6.2 Git 代码提交规范(AtomGit))

[7 总结与拓展](#7 总结与拓展)

[7.1 任务总结](#7.1 任务总结)

[7.2 拓展方向](#7.2 拓展方向)

参考资料


摘 要

本次开源鸿蒙跨平台开发先锋训练营DAY8~DAY13阶段,以Flutter为核心跨平台技术栈,基于开源鸿蒙设备适配要求,完成了美食类应用底部选项卡及首页核心页面的开发与优化。开发过程严格遵循底部选项卡开发规范,实现了首页、美食、动态、推荐、我的5个核心选项卡设计,完成首页选项卡默认/选中的视觉区分、页面平滑切换及状态保留优化,解决了页面重复加载问题;重点实现首页搜索栏、轮播推荐、分类标签、美食卡片列表的功能闭环,接入下拉刷新、上拉分页加载交互,同时完善各选项卡页面的初识布局搭建,为后续开发搭好基础。最终工程代码按Git提交规范拆分粒度、标注清晰的commit message,推送至AtomGit本人个人公开仓库,实现代码可直接拉取复现。本次开发丰富了应用的交互维度与服务能力,搭建了清晰的Flutter+开源鸿蒙项目模块化架构,实现了跨平台应用底部选项卡基础框架及首页核心功能的实现。

1 概述

1.1 开发背景

在开源鸿蒙(OpenHarmony)跨平台应用开发中,底部选项卡是核心交互组件,能清晰划分应用功能模块、提升用户操作效率;而首页作为应用入口,需整合核心服务(如推荐、搜索、分类导航),形成完整的用户体验闭环。本次DAY8~DAY13 任务,聚焦底部选项卡开发规范落地、首页功能完善及各选项卡页面实现,同时完成开源鸿蒙终端模拟器的运行验证,最终将代码规范提交至AtomGit仓库。

1.2 开发目标

✅ 1. 搭建5个底部选项卡(首页、美食、动态、推荐、我的),实现完整交互状态(默认/选中)与平滑切换,保留页面状态避免重复加载;

✅ 2. 完善首页核心功能(搜索栏、轮播推荐、分类标签、美食卡片列表),接入数据加载、下拉刷新、上拉加载等交互;

✅ 3. 实现各选项卡对应页面的完整布局、核心功能与异常处理(空数据、加载失败兜底);

✅ 4. 遵循Git规范提交代码至AtomGit个人公开仓库,确保工程可直接拉取复现。

1.3 核心技术栈

⚡ 1. 跨平台框架:Flutter(兼容OpenHarmony)

🔧 2. UI 组件:Flutter原生组件+鸿蒙兼容三方库(carousel_slider、pull_to_refresh、infinite_scroll_pagination等)

📊 3. 数据处理:dio(网络请求)、json_annotation(数据解析)、shared_preferences(本地存储)

💻 4. 开发工具:VS Code、DevEco Studio 6.0.0 Release

2 开发环境与准备

2.1 环境配置

🚀 1. Flutter环境:安装 Flutter 3.27.5版本,配置flutter-OHOS-1.0.1(鸿蒙适配分支)分支(兼容 OpenHarmony);

📱 2. 鸿蒙环境:安装DevEco Studio 6.0.0 Release,配置 OpenHarmony SDK(API Version 20(6.0.0.47)),创建鸿蒙模拟器;

📂 3. 项目初始化:基于现有flutter_harmonyos工程,调整目录结构,安装依赖。

2.2 依赖安装(pubspec.yaml)

在项目根目录pubspec.yaml中添加鸿蒙兼容的三方库,执行flutter pub get安装:

复制代码
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8
  http: ^1.6.0                         # 网络请求核心库(鸿蒙兼容)
  cached_network_image: ^3.4.1         # 图片缓存(解决鸿蒙设备图片重复加载卡顿)
  pull_to_refresh: ^2.0.0              # 下拉刷新(适配鸿蒙触控交互)
  flutter_rating_bar: ^4.0.1           # 美食评分组件(鸿蒙UI适配)
  dio: 5.0.0                           # 网络请求封装
  json_annotation: ^4.9.0              # 数据解析
  infinite_scroll_pagination: 4.0.0    # 专注上拉分页(适配鸿蒙异步逻辑)
  easy_refresh: ^3.4.0                 # 轻量化刷新(适配鸿蒙触控交互)
  carousel_slider: ^5.1.1              # 轮播图组件
  shared_preferences: ^2.5.3           # 用于本地存储历史记录

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0
  json_serializable: ^6.9.5
  build_runner: ^2.4.15

2.3 项目目录结构

复制代码
lib/
├── main.dart                # 项目入口(根页面为底部导航)
├── api/                     # API接口层
│   └── food_api.dart        # 美食相关接口
├── components/              # 全局公共组件
│   └── bottom_nav_bar.dart  # 底部选项卡核心组件
├── core/                    # 核心工具层
│   └── http/                # 网络请求封装
│       ├── api_config.dart   # API配置(基础URL、请求头)
│       ├── api_response.dart # 响应数据模型
│       └── http_client.dart  # 网络请求客户端封装
├── models/                  # 数据模型层
│   ├── collect_model.dart    # 收藏数据模型
│   ├── food_detail_model.dart # 美食详情数据模型
│   ├── food_model.dart       # 美食基础数据模型
│   ├── food_model.g.dart     # 美食模型生成文件(如json_serializable)
│   └── search_model.dart     # 搜索相关数据模型
├── pages/                   # 页面层(按功能模块划分)
│   ├── home/                # 首页(对应底部"首页"选项卡)
│   │   ├── components/      # 首页子组件
│   │   │   ├── banner_section.dart  # 轮播推荐区
│   │   │   ├── category_tab.dart    # 早餐/午餐等分类标签
│   │   │   ├── food_card.dart       # 美食卡片(首页专用)
│   │   │   └── search_bar.dart      # 顶部搜索栏
│   │   └── home_page.dart   # 首页主页面
│   ├── food/                # 美食详情相关页面
│   │   ├── components/
│   │   │   └── food_card.dart       # 美食卡片(复用组件)
│   │   └── food_detail_page.dart # 美食详情页
│   ├── food_note/           # 动态页面(对应底部"动态"选项卡)
│   │   └── food_note_page.dart # 动态主页面
│   ├── food_show/           # 美食展示页面(对应底部"美食"选项卡)
│   │   └── food_show_page.dart # 美食展示主页面
│   ├── eat_what/            # 推荐页面(对应底部"推荐"选项卡)
│   │   └── eat_what_page.dart # 推荐主页面
│   ├── mine/                # 我的页面(对应底部"我的"选项卡)
│   │   └── mine_page.dart   # 我的主页面
│   └── search/              # 搜索相关页面
│       ├── components/
│       │   ├── hot_search.dart      # 热门搜索组件
│       │   └── search_history.dart  # 搜索历史组件
│       ├── search_page.dart # 搜索主页面
│       └── search_result_page.dart # 搜索结果页
├── utils/                   # 工具类层
│   └── collect_util.dart    # 收藏相关工具类
└── ohos/                    # 鸿蒙平台适配代码(原生交互、平台配置)

3 底部选项卡开发

3.1 底部选项卡设计规范

✅ 1. 选项卡数量:5 个(首页、美食、动态、推荐、我的),覆盖核心服务场景;

✅ 2. 交互状态:默认状态(灰色图标 + 文字)、选中状态(蓝色高亮图标 + 文字),视觉区分明确;

✅ 3. 切换逻辑:平滑切换,保留各页面状态(如列表滚动位置、输入框内容),避免重复请求数据。

3.2 底部导航组件实现

底部导航组件实现(lib/components/bottom_nav_bar.dart)。

复制代码
// components/              # 公共组件
// 底部导航栏:实现底部导航栏 bottom_nav_bar.dart

import 'package:flutter/material.dart';
// 1. 修复导入路径:去掉空格,用下划线,确保文件名匹配
import '../pages/home/home_page.dart';
import '../pages/food_show/food_show_page.dart';
import '../pages/food_note/food_note_page.dart';
import '../pages/eat_what/eat_what_page.dart';
import '../pages/mine/mine_page.dart';
// import 'package:flutter_harmonyos/models/food_model.dart';

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

  @override
  State<BottomNavBar> createState() => _BottomNavBarState();
}

class _BottomNavBarState extends State<BottomNavBar> {
  int _currentIndex = 0; // 默认选中首页
  // 2. 去掉const,避免StatefulWidget构造函数不匹配
  final List<Widget> _pages = [
    const HomePage(), // 如果HomePage构造函数是const,加const;否则去掉
    const FoodShowPage(), // 同理
    const FoodNotePage(),
    const EatWhatPage(),
    const MinePage(),
  ];
  // 3. 优化标签文字:标题+图标+样式全优化
  final List<BottomNavigationBarItem> _navItems = const [
    BottomNavigationBarItem(icon: Icon(Icons.home), label: '首页'),         // 🏠 房子图标
    BottomNavigationBarItem(icon: Icon(Icons.food_bank), label: '美食'),    // 🍽️ 食物图标
    BottomNavigationBarItem(icon: Icon(Icons.note_alt), label: '动态'),     // 📝 笔记图标  美食笔记
    BottomNavigationBarItem(icon: Icon(Icons.restaurant), label: '推荐'),   // 🍴 餐厅图标
    BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),       // 👤 人物图标
  ];
  void _onTabTapped(int index) {
    setState(() => _currentIndex = index);
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _pages[_currentIndex],
      bottomNavigationBar: BottomNavigationBar(
        items: _navItems,
        currentIndex: _currentIndex,
        onTap: _onTabTapped,
        type: BottomNavigationBarType.fixed,
        selectedItemColor: Colors.blue,
        unselectedItemColor: Colors.grey,
      ),
    );
  }
}

实现效果:

3.3 项目入口配置

项目入口配置(lib/main.dart)

将底部导航作为应用根页面,替换原有入口:

复制代码
// 项目入口(重构)
import 'package:flutter/material.dart';
import 'components/bottom_nav_bar.dart';
void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '本地美食清单',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        // visualDensity 是可选的适配优化,保留/删除都可以,不影响核心功能
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const BottomNavBar(), // 底部导航作为根页面
      debugShowCheckedModeBanner: false,
    );
  }
}

4 首页功能实现

首页作为应用核心入口,需实现搜索栏、轮播推荐、分类标签、美食卡片列表四大模块,同时接入数据加载、下拉刷新、上拉加载交互,匹配设计图效果。

4.1 首页主页面实现

首页主页面实现(lib/pages/home/home_page.dart)

核心逻辑:数据加载 + 布局整合

复制代码
// pages/                   # 页面目录
// pages/home/home_page.dart   # 首页主页面
// 整合搜索栏、轮播、分类标签、美食卡片:

import 'package:flutter/material.dart';
import 'components/search_bar.dart' as custom;
import 'components/banner_section.dart';
import 'components/category_tab.dart';
import 'components/food_card.dart';
import '../../models/food_model.dart';
import 'package:flutter_harmonyos/pages/search/search_page.dart';

// 分页常量(可根据需求调整)
const int _pageSize = 4; // 每页加载数量
const int _maxPages = 3; // 最大页数(模拟无更多数据)

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  List<FoodModel> _foodList = [];
  bool _isLoading = true; // 初始加载状态
  // 新增:上拉加载相关变量
  final ScrollController _scrollController = ScrollController();
  int _currentPage = 1; // 当前页码
  bool _isLoadingMore = false; // 是否正在加载更多
  bool _hasMoreData = true; // 是否还有更多数据

  // 统一的跳转搜索页方法
  void _navigateToSearchPage() {
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => const SearchPage()),
    );
  }

  @override
  void initState() {
    super.initState();
    _loadFoodData(_currentPage);
    // 新增:监听滚动事件(上拉加载核心)
    _scrollController.addListener(() {
      // 当滚动到距离底部100px以内,且不在加载中、还有更多数据时,触发加载更多
      if (_scrollController.position.pixels >=
              _scrollController.position.maxScrollExtent - 100 &&
          !_isLoadingMore &&
          _hasMoreData) {
        _loadMore();
      }
    });
  }

  @override
  void dispose() {
    // 新增:销毁滚动控制器,避免内存泄漏
    _scrollController.dispose();
    super.dispose();
  }

  // 修改:支持分页加载数据(传入页码)
  Future<void> _loadFoodData(int page) async {
    // 初始加载/刷新时显示加载状态,上拉加载时标记_isLoadingMore
    if (page == 1) {
      setState(() => _isLoading = true);
    } else {
      setState(() => _isLoadingMore = true);
    }

    // 模拟网络请求延迟
    await Future.delayed(const Duration(seconds: 1));

    // 模拟不同页码返回不同测试数据
    final List<String> foodNames = [
      '溜肉段', '白玉木耳炒西蓝花', '糖醋排骨', '一品红烧肉',
      '鱼香肉丝', '宫保鸡丁', '麻婆豆腐', '水煮鱼',
      '回锅肉', '东坡肉', '辣子鸡', '酸菜鱼'
    ];
    final List<String> foodSources = [
      '吃货品天下', '白玉木耳滑嫩脆弹,西蓝花清甜脆爽', '甜甜蜜蜜的糖醋排骨', '一碗直达灵魂的红烧肉,肥而不腻,入口即化',
      '酸甜适口,超级下饭', '外酥里嫩,酸甜微辣', '麻辣鲜香,豆腐嫩滑', '麻辣过瘾,鱼肉鲜嫩',
      '肥而不腻,酱香浓郁', '肥而不腻,入口即化', '麻辣鲜香,外酥里嫩', '酸辣开胃,鱼肉鲜嫩'
    ];

    // 计算当前页的起始/结束索引
    final startIndex = (page - 1) * _pageSize;
    final endIndex = startIndex + _pageSize;
    final newItems = <FoodModel>[];

    for (int i = startIndex; i < endIndex && i < foodNames.length; i++) {
      newItems.add(FoodModel(
        name: foodNames[i],
        image: 'assets/images/food${(i % 4) + 1}.jpg', // 复用4张图片
        source: foodSources[i],
        score: 4.5 + (i % 4) * 0.1,
      ));
    }

    setState(() {
      if (page == 1) {
        // 第一页/刷新:替换数据
        _foodList = newItems;
        _isLoading = false;
      } else {
        // 非第一页/上拉加载:追加数据
        _foodList.addAll(newItems);
        _isLoadingMore = false;
      }
      // 判断是否还有更多数据
      _hasMoreData = page < _maxPages;
    });
  }

  // 下拉刷新核心方法
  Future<void> _onRefresh() async {
    // 刷新时重置分页状态
    setState(() {
      _currentPage = 1;
      _hasMoreData = true;
    });
    await _loadFoodData(1);
    // 刷新完成提示
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('数据刷新成功!')),
      );
    }
  }

  // 新增:上拉加载更多方法
  Future<void> _loadMore() async {
    // 避免重复加载
    if (_isLoadingMore || !_hasMoreData) return;
    // 页码+1
    _currentPage++;
    // 加载对应页码数据
    await _loadFoodData(_currentPage);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('本地美食清单'),
        centerTitle: true,
        actions: [
          IconButton(
            icon: const Icon(Icons.search),
            onPressed: _navigateToSearchPage,
          ),
          IconButton(icon: const Icon(Icons.mail), onPressed: () {}),
        ],
      ),
      // 下拉刷新
      body: RefreshIndicator(
        onRefresh: _onRefresh,
        color: Colors.blue,
        // 绑定滚动控制器到可滚动组件
        child: _isLoading
            ? const Center(child: CircularProgressIndicator())
            : SingleChildScrollView(
                controller: _scrollController, // 新增:绑定滚动控制器
                padding: const EdgeInsets.all(16),
                physics: const AlwaysScrollableScrollPhysics(),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    GestureDetector(
                      onTap: _navigateToSearchPage,
                      child: custom.SearchBar(),
                    ),
                    const SizedBox(height: 16),
                    const BannerSection(),
                    const SizedBox(height: 16),
                    const CategoryTab(),
                    const SizedBox(height: 16),
                    GestureDetector(
                      onTap: _navigateToSearchPage,
                      child: Container(
                        margin: const EdgeInsets.all(16),
                        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
                        decoration: BoxDecoration(
                          color: Colors.grey[100],
                          borderRadius: BorderRadius.circular(20),
                        ),
                        child: const Row(
                          children: [
                            Icon(Icons.search, color: Colors.grey),
                            SizedBox(width: 8),
                            Text('搜索百万免费菜谱', style: TextStyle(color: Colors.grey)),
                          ],
                        ),
                      ),
                    ),
                    // 美食卡片网格
                    GridView.builder(
                      shrinkWrap: true,
                      physics: const NeverScrollableScrollPhysics(),
                      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                        crossAxisCount: 2,
                        crossAxisSpacing: 12,
                        mainAxisSpacing: 16,
                        childAspectRatio: 0.9,
                      ),
                      itemCount: _foodList.length,
                      itemBuilder: (_, index) => FoodCard(food: _foodList[index]),
                    ),
                    // 新增:上拉加载提示(加载中/无更多数据)
                    if (_isLoadingMore)
                      const Padding(
                        padding: EdgeInsets.symmetric(vertical: 16),
                        child: Center(child: CircularProgressIndicator()),
                      )
                    else if (!_hasMoreData)
                      const Padding(
                        padding: EdgeInsets.symmetric(vertical: 16),
                        child: Center(
                          child: Text(
                            '已加载全部美食',
                            style: TextStyle(color: Colors.grey, fontSize: 14),
                          ),
                        ),
                      ),
                  ],
                ),
              ),
      ),
    );
  }
}

实现效果:

上拉加载功能实现:

下拉刷新功能实现:

4.2 首页子组件实现

4.2.1 搜索栏

搜索栏(lib/pages/home/components/search_bar.dart):

复制代码
// components/      # 首页子组件
// lib/pages/home/components/search_bar.dart     # 顶部搜索栏
import 'package:flutter/material.dart';
import 'package:flutter_harmonyos/pages/search/search_page.dart'; // 导入搜索页

class SearchBar extends StatelessWidget {
  const SearchBar({super.key});

  @override
  Widget build(BuildContext context) {
    return TextField(
      // 禁止输入,仅点击跳转
      readOnly: true,
      // 点击时跳转到搜索页
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(builder: (context) => const SearchPage()),
        );
      },
      decoration: InputDecoration(
        hintText: '搜索百万免费菜谱',
        prefixIcon: const Icon(Icons.search, color: Colors.grey),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(20),
          borderSide: BorderSide.none,
        ),
        filled: true,
        fillColor: Colors.grey[100],
        contentPadding: const EdgeInsets.symmetric(vertical: 10),
      ),
    );
  }
}

首页搜索栏区域:

搜索栏实现效果:

4.2.2 轮播推荐区

轮播推荐区(lib/pages/home/components/banner_section.dart):

复制代码
// lib/pages/home/components/banner_section.dart  # 推荐轮播区
import 'package:flutter/material.dart';
import 'package:carousel_slider/carousel_slider.dart';

class BannerSection extends StatefulWidget {
  final Function(int)? onBannerTap;
  const BannerSection({
    super.key,
    this.onBannerTap,
  });

  @override
  State<BannerSection> createState() => _BannerSectionState();
}

class _BannerSectionState extends State<BannerSection> {
  // 修复:改用 CarouselSliderController
  final CarouselSliderController _carouselController = CarouselSliderController();
  int _currentBannerIndex = 0;
  final List<String> _bannerImages = const [
    'assets/images/banner1.jpg',
    'assets/images/banner2.jpg',
    'assets/images/banner3.jpg',
  ];

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        CarouselSlider(
          carouselController: _carouselController,
          options: CarouselOptions(
            height: 200,  // 修复:aspectRatio 与 height 冲突,只保留 height
            autoPlay: true,
            autoPlayInterval: const Duration(seconds: 3),
            autoPlayAnimationDuration: const Duration(milliseconds: 800),
            autoPlayCurve: Curves.fastOutSlowIn,
            enlargeCenterPage: true,
            viewportFraction: 0.9,
            // aspectRatio: 16/9,
            onPageChanged: (index, reason) {
              setState(() {
                _currentBannerIndex = index;
              });
            },
          ),
          items: _bannerImages.asMap().entries.map((entry) {
            final index = entry.key;
            final imagePath = entry.value;
            return Builder(
              builder: (BuildContext context) {
                return GestureDetector(
                  onTap: () {
                    if (widget.onBannerTap != null) {
                      widget.onBannerTap!(index);
                    }
                  },
                  child: Container(
                    width: MediaQuery.of(context).size.width,
                    margin: const EdgeInsets.symmetric(horizontal: 5),
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(12),
                    ),
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(12),
                      child: Image.asset(
                        imagePath,
                        fit: BoxFit.cover,
                      ),
                    ),
                  ),
                );
              },
            );
          }).toList(),
        ),
        const SizedBox(height: 8),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: _bannerImages.asMap().entries.map((entry) {
            final index = entry.key;
            return Container(
              width: 8,
              height: 8,
              margin: const EdgeInsets.symmetric(horizontal: 3),
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                color: _currentBannerIndex == index
                    ? Colors.orange
                    : Colors.grey[300],
              ),
            );
          }).toList(),
        ),
      ],
    );
  }
}

实现效果:

4.2.3 分类标签

分类标签(lib/pages/home/components/category_tab.dart):

复制代码
// lib/pages/home/components/category_tab.dart    # 早餐/午餐等分类标签
import 'package:flutter/material.dart';

// 1. StatefulWidget(需要管理选中状态)
class CategoryTab extends StatefulWidget {
  // 2. 添加回调函数参数:父组件可通过该回调获取选中的分类
  final Function(String)? onCategorySelected;
  // 构造函数保持 const,新增回调参数(可选,默认null)
  const CategoryTab({
    super.key,
    this.onCategorySelected,
  });

  @override
  State<CategoryTab> createState() => _CategoryTabState();
}

class _CategoryTabState extends State<CategoryTab> {
  // 3. 定义选中状态变量,初始选中第一个(索引0)
  int _selectedIndex = 0;

  // 分类列表保持不变,移到State类中(或仍放Widget类,二选一)
  final List<String> categories = const [
    '早餐',
    '午餐',
    '下午茶',
    '晚餐',
    '夜宵',
  ];

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      scrollDirection: Axis.horizontal, // 横向滚动
      child: Row(
        children: categories
            .asMap()
            .entries
            .map((entry) {
              final index = entry.key;
              final category = entry.value;
              return Padding(
                padding: const EdgeInsets.only(right: 8),
                child: ChoiceChip(
                  label: Text(
                    category,
                    style: TextStyle(
                      fontSize: 12,
                      // 4. 动态判断选中状态:选中的标签文字为白色,未选中为深灰
                      color: _selectedIndex == index ? Colors.white : Colors.grey[700],
                    ),
                  ),
                  // 5. 动态选中状态:当前索引等于选中索引则选中
                  selected: _selectedIndex == index,
                  selectedColor: Colors.orange,
                  backgroundColor: Colors.grey[200],
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(8),
                  ),
                  // 6. 点击标签时更新选中状态,并触发父组件回调
                  onSelected: (selected) {
                    if (selected) {
                      setState(() {
                        _selectedIndex = index; // 更新选中索引
                      });
                      // 调用父组件的回调,传递选中的分类名称
                      if (widget.onCategorySelected != null) {
                        widget.onCategorySelected!(category);
                      }
                    }
                  },
                ),
              );
            })
            .toList(),
      ),
    );
  }
}

实现效果:

4.2.4 美食卡片

美食卡片(lib/pages/home/components/food_card.dart):

复制代码
import 'package:flutter/material.dart';
import 'package:flutter_harmonyos/models/food_model.dart';
// 修复:改用相对路径导入,避免 URI 错误
import 'package:flutter_harmonyos/pages/food_detail/food_detail_page.dart';
class FoodCard extends StatelessWidget {
  final FoodModel food;
  final Function()? onCardTap;
  final Function()? onCardLongPress;

  const FoodCard({
    super.key,
    required this.food,
    this.onCardTap,
    this.onCardLongPress,
  });

  // 长按底部菜单
  void _showBottomMenu(BuildContext context) {
    showModalBottomSheet(
      context: context,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
      ),
      isDismissible: true,
      builder: (context) => Container(
        padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            ListTile(
              leading: const Icon(Icons.favorite_border, color: Colors.orange),
              title: const Text('收藏'),
              onTap: () {
                Navigator.pop(context);
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('已收藏「${food.name ?? '未知美食'}」')),
                );
              },
            ),
            ListTile(
              leading: const Icon(Icons.share, color: Colors.blue),
              title: const Text('分享'),
              onTap: () {
                Navigator.pop(context);
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('正在分享「${food.name ?? '未知美食'}」')),
                );
              },
            ),
            ListTile(
              leading: const Icon(Icons.report_problem, color: Colors.red),
              title: const Text('举报', style: TextStyle(color: Colors.red)),
              onTap: () {
                Navigator.pop(context);
                ScaffoldMessenger.of(context).showSnackBar(
                  const SnackBar(content: Text('请选择举报原因')),
                );
              },
            ),
          ],
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onCardTap ?? () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) => FoodDetailPage(food: food),
          ),
        );
      },
      onLongPress: onCardLongPress ?? () => _showBottomMenu(context),
      child: Container(
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(12),
          boxShadow: [
            BoxShadow(
              color: Colors.grey[200]!,
              blurRadius: 4,
              offset: const Offset(0, 2),
            ),
          ],
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            ClipRRect(
              borderRadius: BorderRadius.circular(12),
              child: food.isLocalImage
                  ? Image.asset(
                      food.effectiveImagePath,
                      height: 120,
                      fit: BoxFit.cover,
                      errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
                    )
                  : Image.network(
                      food.effectiveImagePath,
                      height: 120,
                      fit: BoxFit.cover,
                      errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
                    ),
            ),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    food.name ?? '未知美食',
                    style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                  const SizedBox(height: 4),
                  Row(
                    children: [
                      const Icon(Icons.star, color: Colors.orange, size: 14),
                      Text(' ${food.score ?? 0.0}', style: const TextStyle(fontSize: 12)),
                      const SizedBox(width: 8),
                      Expanded(
                        child: Text(
                          food.source ?? '未知来源',
                          style: const TextStyle(fontSize: 12, color: Colors.grey),
                          maxLines: 1,
                          overflow: TextOverflow.ellipsis,
                          softWrap: false,
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

实现效果:

4.3 数据模型与接口实现

4.3.1 美食模型

美食模型(lib/models/food_model.dart):

复制代码
// models/food_model.dart  美食数据模型
import 'package:json_annotation/json_annotation.dart';
part 'food_model.g.dart'; // 关联自动生成的解析代码文件

@JsonSerializable()
class FoodModel {
  final int? id;
  final String? name; // 美食名称
  // final String? desc; // 美食描述
  final String? desc;
  final String? image; // 本地assets图片路径(兼容本地)
  final String? imageUrl; // 网络图片URL(兼容接口)
  final String? source; // 美食来源(新增,对应图片方案)
  final double? score; // 美食评分
  final String? category;
  // 新增美食详情字段
  final List<String>? ingredients; // 食材列表
  final List<String>? steps;       // 制作步骤
  final String? cookTime;          // 烹饪时长(如"30分钟")
  final String? difficulty;        // 难度(如"简单/中等/困难")
  final String? calories;          // 热量(如"250大卡")
  // 构造函数:包含所有字段
  FoodModel({
    this.id,
    this.name,
    this.desc,
    this.image,
    this.imageUrl,
    this.source,
    this.score,
    this.category,
    this.ingredients,
    this.steps,       
    this.cookTime,    
    this.difficulty,  
    this.calories,    
  });

  // 自动生成的JSON转模型方法
  factory FoodModel.fromJson(Map<String, dynamic> json) => _$FoodModelFromJson(json);
  // 自动生成的模型转JSON方法
  Map<String, dynamic> toJson() => _$FoodModelToJson(this);

  // 辅助方法1:判断是否为本地assets图片
  // 计算属性:判断是否为本地图片(忽略序列化,避免 JSON 报错)
  @JsonKey(ignore: true)
  bool get isLocalImage => image?.startsWith('assets/') ?? false;
  // 辅助方法2:统一获取图片路径(优先本地,否则网络)
  // 计算属性:统一获取图片路径(忽略序列化,修复空指针风险)
  @JsonKey(ignore: true)
  String get effectiveImagePath => isLocalImage ? (image ?? '') : (imageUrl ?? '');
}

// 美食列表模型(适配接口返回的列表数据)
class FoodListModel {
  final List<FoodModel> foodList;

  const FoodListModel({required this.foodList});

  factory FoodListModel.fromJson(Map<String, dynamic> json) {
    List<FoodModel> list = <FoodModel>[];
    if (json['foodList'] != null && json['foodList'] is List) {
      list = (json['foodList'] as List)
          .where((e) => e is Map<String, dynamic>)
          .map((e) => FoodModel.fromJson(e))
          .toList();
    }
    return FoodListModel(foodList: list);
  }
}

执行flutter pub run build_runner build生成food_model.g.dart解析文件。

4.3.2 美食数据接口

美食数据接口(lib/api/food_api.dart):

复制代码
// api/                     # 数据接口
// food_api.dart        # 模拟美食数据
// 先导入依赖包
import 'package:dio/dio.dart';
import '../core/http/api_config.dart'; // 对应api_config的路径

// 定义FoodApi类
class FoodApi {
  // 改为类的静态方法,并接收分页参数page/pageSize
  static Future<dynamic> getFoodList({
    required int page,
    required int pageSize,
  }) async {
    try {
      Dio dio = Dio();
      // 构造分页请求参数(传递给接口的query参数)
      final queryParams = {
        "page": page,
        "pageSize": pageSize,
      };
      print("请求接口:${ApiConfig.food_list_url},参数:$queryParams"); // 打印请求地址
      // 发起请求时携带分页参数,调用api_config中配置的接口地址
      Response response = await dio.get(
        ApiConfig.food_list_url,
        queryParameters: queryParams, // 关键:把分页参数传给后端接口
      );
      print("接口返回:${response.data}"); // 打印返回数据
      return response.data; // 提取接口返回的美食列表数据
    } catch (e) {
      print("接口请求失败:$e"); // 打印错误
      throw e;
    }
  }
}

5 各选项卡页面

说明:当前阶段各选项卡页面仅完成基础框架搭建(页面标题、底部导航栏联动、基础布局骨架),未实现完整功能模块;原定的功能将在后续博客章节中逐步开发完善,而当前仅搭建的页面基础框架,为后续功能开发奠定基础。

5.1 美食页面

美食页面(lib/pages/food_show/food_show_page.dart):

基础框架搭建:完成页面基础布局,功能模块后续迭代完善。

复制代码
// food_show/food_show_page.dart           # 美食页面
// lib/pages/food_show/food_show_page.dart(美食)

import 'package:flutter/material.dart';

class FoodShowPage extends StatelessWidget {
  const FoodShowPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: Text('美食页面')),
    );
  }
}

实现效果:

5.2 动态页面

动态页面(lib/pages/food_note/food_note_page.dart):

基础框架搭建:完成页面基础布局,功能模块(动态列表、点赞评论交互、加载状态)后续迭代完善。

复制代码
// food_note/           # 动态页面
// lib/pages/food_note/food_note_page.dart(动态)

import 'package:flutter/material.dart';

class FoodNotePage extends StatelessWidget {
  const FoodNotePage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: Text('动态页面')),
    );
  }
}

实现效果:

5.3 推荐页面

推荐页面(lib/pages/eat_what/eat_what_page.dart):

基础框架搭建:完成页面基础布局,功能模块(随机推荐、按钮交互)后续迭代完善。

复制代码
// eat_what/            # 推荐页面
// lib/pages/eat_what/eat_what_page.dart(推荐)

import 'package:flutter/material.dart';

class EatWhatPage extends StatelessWidget {
  const EatWhatPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: Text('推荐页面')),
    );
  }
}

实现效果:

5.4 我的页面

我的页面(lib/pages/mine/mine_page.dart):

基础框架搭建:完成页面基础布局,功能模块(用户信息、收藏、设置)后续迭代完善。

复制代码
// mine/                # 我的页面
// lib/pages/mine/mine_page.dart(我的)

import 'package:flutter/material.dart';

class MinePage extends StatelessWidget {
  const MinePage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: Text('我的页面')),
    );
  }
}

实现效果:

6 运行验证与代码提交

6.1 开源鸿蒙终端运行验证

✅ 模拟器运行:

打开 DevEco Studio,启动鸿蒙OpenHarmony SDK(API Version 20(6.0.0.47))、模拟器,点击右上角运行按钮;

或者通过VS Code / DevEco Studio 终端执行 flutter run 命令运行模拟器;

✅ 验证功能:底部选项卡切换流畅、首页数据加载正常、各页面交互无异常。

6.2 Git 代码提交规范(AtomGit)

  1. 提交粒度:按功能模块拆分提交(如feat: 底部选项卡实现、feat: 首页轮播+搜索栏开发、fix: 页面状态保留优化);

  2. Commit Message 规范:

⚡ feat: 新增功能(如底部选项卡、首页组件)

🔧 fix: 修复bug(如页面重复加载、布局错乱)

📊 docs: 文档更新(如 README、注释)

💻 refactor: 代码重构(如目录结构优化、组件复用,没有新增功能或修复Bug)

  1. 提交步骤

所需全部命令:

复制代码
# 1. 查看修改
git status
# 2. 添加修改文件
git add .
# 3. 提交(规范Commit Message)
git commit -m "feat: 完成底部选项卡+首页功能实现"
# 4. 推送到AtomGit远程仓库
git push origin main

✅ (1)查看本地修改(确认要提交的内容):

在 VS Code 终端执行命令,查看当前修改的文件:

复制代码
git status

✅ (2)暂存本地修改

将所有修改的文件暂存到 Git 缓存区(若只想提交部分文件,可替换.为具体文件名,如git add lib/pages/home/home_page.dart):

复制代码
git add .

✅ (3)提交本地修改(写清楚更新内容):

按照 Git 提交规范,编写清晰的 commit message(说明本次更新了什么):

复制代码
git commit -m "feat: 完成底部选项卡+首页功能实现"

✅ (4)拉取远程仓库最新代码

先拉取 AtomGit 上的最新代码(防止远程有更新导致推送冲突):

复制代码
git pull origin main

✅ (5)推送更新到 AtomGit 仓库

将本地最新提交推送到 AtomGit 的main分支:

复制代码
git push origin main

✅ (6)验证推送结果

打开 AtomGit 仓库页面(在 AtomGit 创建的个人公开仓库),刷新页面:

确认仓库的"提交记录"中出现刚写的commit message;

刷新后的页面也能看到最新的提交记录和更新时间。

  1. 仓库要求:确保工程包含pubspec.yamllib/源码、assets/资源、调试日志,可直接拉取复现运行效果。

7 总结与拓展

7.1 任务总结

本次 DAY8~DAY13 阶段完成了底部选项卡的规范开发(5 个选项卡、状态保留、平滑切换)与首页核心功能实现(搜索、轮播、分类、美食列表),同时搭建美食、动态、推荐、我的四大选项卡页面基本框架,配合后续功能实现;最终在开源鸿蒙终端完成运行验证并规范提交代码。

核心成果包括:

✅ 搭建了清晰的 Flutter + 鸿蒙项目目录结构,实现组件复用与模块化开发;

✅ 掌握了底部选项卡的状态管理、页面状态保留、交互优化技巧;

✅ 实现了首页的完整功能闭环,接入了下拉刷新、上拉加载等交互。

7.2 拓展方向

说明:当前项目已完成首页核心功能与选项卡基础框架,后续将继续完善其他页面功能、补充完整模块与异常处理,持续优化项目体验。

后续将继续完善项目:

🔧 完善其他选项卡页面功能(美食、动态、推荐、我的页面);

📊 补充完整功能模块与异常处理,优化整体体验。

参考资料

参考前文:

https://blog.csdn.net/m0_74451345/article/details/156915775?spm=1001.2014.3001.5501

https://blog.csdn.net/m0_74451345/article/details/157024056?spm=1001.2014.3001.5501

https://blog.csdn.net/m0_74451345/article/details/157032531?spm=1001.2014.3001.5501

参考三方库:

OpenHarmony兼容的三方库:https://gitcode.com/openharmony-tpc/flutter_packages

最后,

欢迎加入开源鸿蒙跨平台社区:

https://openharmonycrossplatform.csdn.net

相关推荐
大雷神2 小时前
HarmonyOS智慧农业管理应用开发教程--高高种地-- 第19篇:语音合成 - TTS语音播报
华为·语音识别·harmonyos
b2077212 小时前
Flutter for OpenHarmony 身体健康状况记录App实战 - 提醒设置实现
python·flutter·macos·cocoa·harmonyos
2601_949613022 小时前
flutter_for_openharmony家庭药箱管理app实战+药品详情实现
java·前端·flutter
xingfanjiuge3 小时前
Flutter框架跨平台鸿蒙开发——ListView.builder深度解析
flutter·华为·harmonyos
浩宇软件开发3 小时前
基于OpenHarmony鸿蒙开发,电影购票选座APP系统
华为·harmonyos
2601_949847754 小时前
Flutter for OpenHarmony 剧本杀组队App实战:邀请好友功能实现
开发语言·javascript·flutter
2601_949868364 小时前
Flutter for OpenHarmony 电子合同签署App实战 - 数据持久化实现
java·数据库·flutter
摘星编程4 小时前
React Native鸿蒙:useLayoutEffect同步布局计算
react native·react.js·harmonyos
ITUnicorn4 小时前
Flutter x HarmonyOS 6:依托小艺开放平台创建智能体并在应用中调用
flutter·harmonyos·鸿蒙·智能体·harmonyos6