【maaath】Flutter for OpenHarmony 实战:健身运动应用的跨平台开发指南

Flutter for OpenHarmony 实战:健身运动应用的跨平台开发指南

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

作者:maaath


前言

随着鸿蒙生态的快速发展,Flutter 作为跨平台开发框架的佼佼者,也在积极拥抱 OpenHarmony 平台。本文将通过一个完整的健身运动应用实战案例,带大家深入了解如何使用 Flutter 开发可运行在鸿蒙设备上的跨平台应用。

一、项目概述

1.1 应用功能

本次实战开发的健身运动应用具备以下核心功能:

  • 网络请求健身课程数据:从后端接口获取课程列表
  • 课程分类列表:支持多维度分类筛选
  • 下拉刷新/上拉加载:流畅的列表交互体验
  • 底部选项卡:课程/训练/社区/我的 四大模块
  • 倒计时/计步动画效果:丰富的交互动画

1.2 技术栈

  • 框架:Flutter 3.x for OpenHarmony
  • 状态管理:Provider / Riverpod
  • 网络请求:dio
  • 本地存储:shared_preferences
  • 目标平台:OpenHarmony

二、项目结构

首先,让我们看一下项目的目录结构:

复制代码
lib/
├── main.dart                 # 应用入口
├── model/                    # 数据模型
│   └── fitness_model.dart
├── pages/                   # 页面
│   ├── fitness_main_page.dart
│   ├── course_page.dart
│   ├── training_page.dart
│   ├── community_page.dart
│   └── my_fitness_page.dart
├── services/                # 网络服务
│   └── fitness_service.dart
├── widgets/                 # 通用组件
│   └── fitness_widgets.dart
└── theme/                   # 主题配置
    └── app_theme.dart

三、核心代码实现

3.1 数据模型

健身课程数据模型是整个应用的基础,我们需要定义课程、训练计划、社区帖子等核心数据结构:

dart 复制代码
// model/fitness_model.dart

// 健身课程模型
class CourseModel {
  String id;
  String title;
  String coach;
  String coverUrl;
  String description;
  int duration;           // 时长(分钟)
  int calories;           // 消耗卡路里
  String difficulty;      // 初级/中级/高级
  String category;
  double rating;
  int participants;       // 参与人数
  List<String> tags;
  bool isHot;
  bool isNew;

  CourseModel({
    required this.id,
    required this.title,
    required this.coach,
    required this.coverUrl,
    required this.description,
    required this.duration,
    required this.calories,
    required this.difficulty,
    required this.category,
    required this.rating,
    required this.participants,
    required this.tags,
    this.isHot = false,
    this.isNew = false,
  });

  // 从JSON解析
  factory CourseModel.fromJson(Map<String, dynamic> json) {
    return CourseModel(
      id: json['id'] ?? '',
      title: json['title'] ?? '',
      coach: json['coach'] ?? '',
      coverUrl: json['coverUrl'] ?? '',
      description: json['description'] ?? '',
      duration: json['duration'] ?? 0,
      calories: json['calories'] ?? 0,
      difficulty: json['difficulty'] ?? '初级',
      category: json['category'] ?? '',
      rating: (json['rating'] ?? 0).toDouble(),
      participants: json['participants'] ?? 0,
      tags: List<String>.from(json['tags'] ?? []),
      isHot: json['isHot'] ?? false,
      isNew: json['isNew'] ?? false,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'title': title,
      'coach': coach,
      'coverUrl': coverUrl,
      'description': description,
      'duration': duration,
      'calories': calories,
      'difficulty': difficulty,
      'category': category,
      'rating': rating,
      'participants': participants,
      'tags': tags,
      'isHot': isHot,
      'isNew': isNew,
    };
  }
}

// 训练计划模型
class TrainingPlanModel {
  String id;
  String name;
  String description;
  int totalDays;
  int completedDays;
  String difficulty;
  String target;

  TrainingPlanModel({
    required this.id,
    required this.name,
    required this.description,
    required this.totalDays,
    required this.completedDays,
    required this.difficulty,
    required this.target,
  });
}

// 社区帖子模型
class PostModel {
  String id;
  String userName;
  String userAvatar;
  String content;
  List<String> images;
  int likes;
  int comments;
  bool isLiked;
  String createTime;
  String type;

  PostModel({
    required this.id,
    required this.userName,
    required this.userAvatar,
    required this.content,
    required this.images,
    required this.likes,
    required this.comments,
    required this.isLiked,
    required this.createTime,
    required this.type,
  });
}

// 用户健身数据模型
class FitnessUserModel {
  int stepCount;
  int targetSteps;
  int caloriesBurned;
  int trainingDays;
  int totalCalories;
  int consecutiveDays;
  int level;

  FitnessUserModel({
    required this.stepCount,
    required this.targetSteps,
    required this.caloriesBurned,
    required this.trainingDays,
    required this.totalCalories,
    required this.consecutiveDays,
    required this.level,
  });
}

3.2 网络服务层

使用 Flutter 风格的网络请求封装,支持接口调用和数据模拟:

dart 复制代码
// services/fitness_service.dart

import 'package:dio/dio.dart';
import '../model/fitness_model.dart';

class FitnessService {
  late final Dio _dio;
  final int _pageSize = 10;

  FitnessService() {
    _dio = Dio(BaseOptions(
      baseUrl: 'https://api.example.com',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 10),
      headers: {
        'Content-Type': 'application/json',
      },
    ));
  }

  // 获取推荐课程列表
  Future<List<CourseModel>> getRecommendCourses() async {
    // 实际项目中调用接口
    // final response = await _dio.get('/courses/recommend');
    // return (response.data['list'] as List)
    //     .map((e) => CourseModel.fromJson(e))
    //     .toList();

    // 这里使用模拟数据
    return _getMockCourses();
  }

  // 根据分类获取课程
  Future<List<CourseModel>> getCoursesByCategory(
    String categoryId,
    int page,
  ) async {
    final allCourses = _getMockCourses();
    final start = (page - 1) * _pageSize;
    final end = start + _pageSize;

    if (categoryId == 'all') {
      return allCourses.sublist(
        start,
        end.clamp(0, allCourses.length),
      );
    }

    return allCourses
        .where((c) => c.category == categoryId)
        .skip(start)
        .take(_pageSize)
        .toList();
  }

  // 获取课程详情
  Future<CourseModel?> getCourseDetail(String courseId) async {
    final courses = _getMockCourses();
    try {
      return courses.firstWhere((c) => c.id == courseId);
    } catch (e) {
      return null;
    }
  }

  // 获取社区帖子
  Future<List<PostModel>> getCommunityPosts(int page) async {
    return _getMockPosts().skip((page - 1) * _pageSize).take(_pageSize).toList();
  }

  // 模拟课程数据
  List<CourseModel> _getMockCourses() {
    return [
      CourseModel(
        id: 'c1',
        title: '燃脂HIIT训练',
        coach: '李教练',
        coverUrl: 'https://example.com/hiit.jpg',
        description: '高强度间歇训练,快速燃脂',
        duration: 30,
        calories: 350,
        difficulty: '中级',
        category: '减脂',
        rating: 4.8,
        participants: 12500,
        tags: ['燃脂', 'HIIT', '有氧'],
        isHot: true,
      ),
      CourseModel(
        id: 'c2',
        title: '腹肌撕裂者',
        coach: '张教练',
        coverUrl: 'https://example.com/abs.jpg',
        description: '针对腹部的核心训练',
        duration: 25,
        calories: 200,
        difficulty: '中级',
        category: '核心',
        rating: 4.9,
        participants: 15800,
        tags: ['腹肌', '核心', '燃脂'],
        isHot: true,
      ),
      CourseModel(
        id: 'c3',
        title: '瑜伽放松课',
        coach: '王老师',
        coverUrl: 'https://example.com/yoga.jpg',
        description: '舒缓身心,提升柔韧性',
        duration: 45,
        calories: 120,
        difficulty: '初级',
        category: '瑜伽',
        rating: 4.7,
        participants: 8900,
        tags: ['瑜伽', '放松', '柔韧'],
        isNew: true,
      ),
      CourseModel(
        id: 'c4',
        title: '全身燃脂操',
        coach: '李教练',
        coverUrl: 'https://example.com/fullbody.jpg',
        description: '全身参与的有氧训练',
        duration: 45,
        calories: 450,
        difficulty: '中级',
        category: '减脂',
        rating: 4.8,
        participants: 11000,
        tags: ['全身', '燃脂', '有氧'],
        isHot: true,
      ),
      CourseModel(
        id: 'c5',
        title: 'Tabata燃脂特训',
        coach: '张教练',
        coverUrl: 'https://example.com/tabata.jpg',
        description: '4分钟Tabata训练,燃脂效果翻倍',
        duration: 20,
        calories: 250,
        difficulty: '中级',
        category: '减脂',
        rating: 4.9,
        participants: 18200,
        tags: ['Tabata', '燃脂', 'HIIT'],
        isHot: true,
      ),
    ];
  }

  // 模拟帖子数据
  List<PostModel> _getMockPosts() {
    return [
      PostModel(
        id: 'p1',
        userName: '运动达人小王',
        userAvatar: 'https://example.com/avatar1.jpg',
        content: '今天的HIIT训练完成!感觉状态超级好,暴汗的感觉太爽了💪',
        images: ['https://example.com/post1.jpg'],
        likes: 256,
        comments: 42,
        isLiked: false,
        createTime: DateTime.now().subtract(const Duration(hours: 2)).toIso8601String(),
        type: '打卡',
      ),
      PostModel(
        id: 'p2',
        userName: '瑜伽小白兔',
        userAvatar: 'https://example.com/avatar2.jpg',
        content: '瑜伽第30天打卡!坚持真的能看到变化,继续加油!',
        images: [],
        likes: 189,
        comments: 35,
        isLiked: false,
        createTime: DateTime.now().subtract(const Duration(hours: 5)).toIso8601String(),
        type: '打卡',
      ),
    ];
  }
}

3.3 课程列表页面

课程列表页面展示了应用的核心 UI,包含下拉刷新、上拉加载、分类筛选等功能:

dart 复制代码
// pages/course_page.dart

import 'package:flutter/material.dart';
import '../model/fitness_model.dart';
import '../services/fitness_service.dart';

class CoursePage extends StatefulWidget {
  const CoursePage({Key? key}) : super(key: key);

  @override
  State<CoursePage> createState() => _CoursePageState();
}

class _CoursePageState extends State<CoursePage> {
  final FitnessService _service = FitnessService();
  final List<String> _categories = ['全部', '减脂', '力量', '瑜伽', '拳击', '拉伸'];

  List<CourseModel> _courses = [];
  String _selectedCategory = '全部';
  bool _isLoading = false;
  bool _isRefreshing = false;
  bool _hasMore = true;
  int _currentPage = 1;

  @override
  void initState() {
    super.initState();
    _loadCourses();
  }

  Future<void> _loadCourses() async {
    setState(() => _isLoading = true);
    try {
      final courses = await _service.getRecommendCourses();
      setState(() {
        _courses = courses;
        _isLoading = false;
        _hasMore = courses.length >= 10;
      });
    } catch (e) {
      setState(() => _isLoading = false);
    }
  }

  Future<void> _onRefresh() async {
    setState(() {
      _isRefreshing = true;
      _currentPage = 1;
      _hasMore = true;
    });
    try {
      final courses = await _service.getRecommendCourses();
      setState(() {
        _courses = courses;
        _isRefreshing = false;
      });
    } catch (e) {
      setState(() => _isRefreshing = false);
    }
  }

  Future<void> _loadMore() async {
    if (!_hasMore || _isLoading) return;
    setState(() => _isLoading = true);
    try {
      _currentPage++;
      final moreCourses = await _service.getCoursesByCategory(
        _selectedCategory == '全部' ? 'all' : _selectedCategory,
        _currentPage,
      );
      setState(() {
        _courses = [..._courses, ...moreCourses];
        _isLoading = false;
        _hasMore = moreCourses.length >= 10;
      });
    } catch (e) {
      setState(() => _isLoading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        _buildHeader(),
        _buildCategoryTabs(),
        Expanded(child: _buildCourseList()),
      ],
    );
  }

  Widget _buildHeader() {
    return Container(
      height: 56,
      padding: const EdgeInsets.symmetric(horizontal: 16),
      decoration: BoxDecoration(
        color: Colors.white,
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.05),
            blurRadius: 4,
            offset: const Offset(0, 2),
          ),
        ],
      ),
      child: Row(
        children: [
          const Text(
            '💪 健身课程',
            style: TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.bold,
              color: Color(0xFF2D3436),
            ),
          ),
          const Spacer(),
          IconButton(
            icon: const Icon(Icons.search, size: 24),
            onPressed: () {},
          ),
        ],
      ),
    );
  }

  Widget _buildCategoryTabs() {
    return Container(
      height: 50,
      color: Colors.white,
      child: ListView.builder(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        itemCount: _categories.length,
        itemBuilder: (context, index) {
          final category = _categories[index];
          final isSelected = _selectedCategory == category;
          return GestureDetector(
            onTap: () {
              setState(() => _selectedCategory = category);
              _loadCourses();
            },
            child: Container(
              margin: const EdgeInsets.only(right: 8),
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
              decoration: BoxDecoration(
                color: isSelected ? const Color(0xFFFF6B6B) : Colors.transparent,
                borderRadius: BorderRadius.circular(20),
              ),
              child: Text(
                category,
                style: TextStyle(
                  fontSize: 14,
                  color: isSelected ? Colors.white : const Color(0xFF636E72),
                  fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
                ),
              ),
            ),
          );
        },
      ),
    );
  }

  Widget _buildCourseList() {
    if (_isLoading && _courses.isEmpty) {
      return const Center(child: CircularProgressIndicator());
    }

    return RefreshIndicator(
      onRefresh: _onRefresh,
      color: const Color(0xFFFF6B6B),
      child: ListView.builder(
        padding: const EdgeInsets.all(16),
        itemCount: _courses.length + (_hasMore ? 1 : 0),
        itemBuilder: (context, index) {
          if (index >= _courses.length) {
            return _buildLoadMoreButton();
          }
          return _buildCourseCard(_courses[index]);
        },
      ),
    );
  }

  Widget _buildCourseCard(CourseModel course) {
    return Container(
      margin: const EdgeInsets.only(bottom: 16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.06),
            blurRadius: 8,
            offset: const Offset(0, 2),
          ),
        ],
      ),
      child: InkWell(
        onTap: () {},
        borderRadius: BorderRadius.circular(16),
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Row(
            children: [
              ClipRRect(
                borderRadius: BorderRadius.circular(12),
                child: Container(
                  width: 120,
                  height: 90,
                  color: Colors.grey[200],
                  child: Image.network(
                    course.coverUrl,
                    fit: BoxFit.cover,
                    errorBuilder: (context, error, stackTrace) {
                      return Container(
                        color: Colors.grey[200],
                        child: const Icon(Icons.fitness_center),
                      );
                    },
                  ),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      course.title,
                      style: const TextStyle(
                        fontSize: 18,
                        fontWeight: FontWeight.bold,
                        color: Color(0xFF2D3436),
                      ),
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                    ),
                    const SizedBox(height: 4),
                    Text(
                      '教练: ${course.coach}',
                      style: const TextStyle(
                        fontSize: 12,
                        color: Color(0xFF636E72),
                      ),
                    ),
                    const SizedBox(height: 6),
                    Row(
                      children: [
                        _buildTag('⭐ ${course.rating}'),
                        const SizedBox(width: 8),
                        _buildTag('${course.participants ~/ 1000}k人训练'),
                      ],
                    ),
                    const SizedBox(height: 8),
                    Row(
                      children: [
                        _buildInfoChip('⏱️ ${course.duration}min'),
                        const SizedBox(width: 8),
                        _buildInfoChip('🔥 ${course.calories}卡'),
                        const Spacer(),
                        _buildDifficultyBadge(course.difficulty),
                      ],
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildTag(String text) {
    return Text(
      text,
      style: const TextStyle(
        fontSize: 10,
        color: Color(0xFFFFD93D),
      ),
    );
  }

  Widget _buildInfoChip(String text) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
      decoration: BoxDecoration(
        color: const Color(0xFFE9ECEF),
        borderRadius: BorderRadius.circular(4),
      ),
      child: Text(
        text,
        style: const TextStyle(
          fontSize: 10,
          color: Color(0xFF636E72),
        ),
      ),
    );
  }

  Widget _buildDifficultyBadge(String difficulty) {
    Color color;
    switch (difficulty) {
      case '初级':
        color = const Color(0xFF00B894);
        break;
      case '中级':
        color = const Color(0xFFFDCB6E);
        break;
      case '高级':
        color = const Color(0xFFE74C3C);
        break;
      default:
        color = const Color(0xFFFF6B6B);
    }
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
      decoration: BoxDecoration(
        color: color.withOpacity(0.2),
        borderRadius: BorderRadius.circular(4),
      ),
      child: Text(
        difficulty,
        style: TextStyle(
          fontSize: 10,
          color: color,
        ),
      ),
    );
  }

  Widget _buildLoadMoreButton() {
    return Container(
      height: 50,
      alignment: Alignment.center,
      child: _isLoading
          ? const SizedBox(
              width: 24,
              height: 24,
              child: CircularProgressIndicator(strokeWidth: 2),
            )
          : TextButton(
              onPressed: _loadMore,
              child: const Text('上拉加载更多'),
            ),
    );
  }
}

3.4 训练页面 - 倒计时动画

训练页面实现了倒计时和计步动画,是应用的核心交互页面:

dart 复制代码
// pages/training_page.dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../model/fitness_model.dart';
import '../services/fitness_service.dart';

class TrainingPage extends StatefulWidget {
  final String? courseId;

  const TrainingPage({Key? key, this.courseId}) : super(key: key);

  @override
  State<TrainingPage> createState() => _TrainingPageState();
}

class _TrainingPageState extends State<TrainingPage>
    with SingleTickerProviderStateMixin {
  final FitnessService _service = FitnessService();

  CourseModel? _course;
  String _status = '准备中';
  int _countdown = 10;
  int _currentTime = 0;
  int _totalTime = 0;
  double _progress = 0;
  bool _isPaused = false;
  bool _showCountdown = false;
  int _stepCount = 0;
  int _targetSteps = 100;

  Timer? _timer;
  Timer? _countdownTimer;

  @override
  void initState() {
    super.initState();
    _loadCourseData();
  }

  @override
  void dispose() {
    _timer?.cancel();
    _countdownTimer?.cancel();
    super.dispose();
  }

  Future<void> _loadCourseData() async {
    final courses = await _service.getRecommendCourses();
    if (courses.isNotEmpty) {
      setState(() {
        _course = courses.first;
        _totalTime = (_course!.duration * 60);
        _targetSteps = _course!.calories ~/ 3;
      });
    }
  }

  void _startCountdown() {
    setState(() {
      _showCountdown = true;
      _countdown = 10;
      _status = '倒计时';
    });

    _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
      setState(() {
        _countdown--;
      });

      if (_countdown <= 0) {
        timer.cancel();
        setState(() => _showCountdown = false);
        _startTraining();
      }
    });
  }

  void _startTraining() {
    setState(() {
      _status = '训练中';
      _isPaused = false;
      _currentTime = 0;
    });

    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      if (!_isPaused) {
        setState(() {
          _currentTime++;
          _progress = (_currentTime / _totalTime) * 100;

          // 模拟计步动画
          if (_currentTime % 3 == 0 && _stepCount < _targetSteps) {
            _stepCount += 1 + (_targetSteps ~/ 100);
          }

          if (_currentTime >= _totalTime) {
            _finishTraining();
          }
        });
      }
    });
  }

  void _pauseTraining() {
    setState(() {
      _isPaused = true;
      _status = '已暂停';
    });
  }

  void _resumeTraining() {
    setState(() {
      _isPaused = false;
      _status = '训练中';
    });
  }

  void _stopTraining() {
    _timer?.cancel();
    _countdownTimer?.cancel();
    setState(() {
      _status = '准备中';
      _currentTime = 0;
      _progress = 0;
      _stepCount = 0;
      _showCountdown = false;
    });
  }

  void _finishTraining() {
    _timer?.cancel();
    setState(() {
      _status = '已完成';
      _progress = 100;
    });
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(
        content: Text('🎉 训练完成!太棒了!'),
        duration: Duration(seconds: 3),
      ),
    );
  }

  String _formatTime(int seconds) {
    final mins = seconds ~/ 60;
    final secs = seconds % 60;
    return '${mins.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          Container(
            decoration: BoxDecoration(
              gradient: LinearGradient(
                begin: Alignment.topCenter,
                end: Alignment.bottomCenter,
                colors: [
                  const Color(0xFFFF6B6B).withOpacity(0.2),
                  const Color(0xFFF8F9FA),
                ],
              ),
            ),
            child: SafeArea(
              child: Column(
                children: [
                  _buildHeader(),
                  Expanded(child: _buildTrainingArea()),
                  _buildControlPanel(),
                ],
              ),
            ),
          ),
          if (_showCountdown) _buildCountdownOverlay(),
        ],
      ),
    );
  }

  Widget _buildHeader() {
    return Container(
      height: 56,
      padding: const EdgeInsets.symmetric(horizontal: 16),
      child: Row(
        children: [
          IconButton(
            icon: const Icon(Icons.arrow_back, size: 28),
            onPressed: () {
              _stopTraining();
              Navigator.pop(context);
            },
          ),
          const Spacer(),
          if (_course != null)
            Text(
              _course!.title,
              style: const TextStyle(
                fontSize: 16,
                fontWeight: FontWeight.bold,
              ),
            ),
          const Spacer(),
          const SizedBox(width: 48),
        ],
      ),
    );
  }

  Widget _buildTrainingArea() {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        _buildProgressCircle(),
        const SizedBox(height: 24),
        if (_status == '训练中' || _status == '已暂停') _buildStepCounter(),
        const SizedBox(height: 24),
        _buildTrainingInfo(),
      ],
    );
  }

  Widget _buildProgressCircle() {
    return SizedBox(
      width: 280,
      height: 280,
      child: Stack(
        alignment: Alignment.center,
        children: [
          // 外圈背景
          Container(
            width: 280,
            height: 280,
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              color: const Color(0xFFE9ECEF).withOpacity(0.4),
            ),
          ),
          // 进度条
          SizedBox(
            width: 280,
            height: 280,
            child: CircularProgressIndicator(
              value: _progress / 100,
              strokeWidth: 12,
              backgroundColor: Colors.transparent,
              valueColor: const AlwaysStoppedAnimation<Color>(
                Color(0xFFFF6B6B),
              ),
            ),
          ),
          // 中心内容
          Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              if (_status == '准备中') ...[
                const Text(
                  '准备开始',
                  style: TextStyle(
                    fontSize: 20,
                    color: Color(0xFF636E72),
                  ),
                ),
                const SizedBox(height: 8),
                const Text(
                  '点击下方开始训练',
                  style: TextStyle(
                    fontSize: 14,
                    color: Color(0xFFB2BEC3),
                  ),
                ),
              ] else if (_status == '倒计时') ...[
                Text(
                  '$_countdown',
                  style: const TextStyle(
                    fontSize: 64,
                    fontWeight: FontWeight.bold,
                    color: Color(0xFFFF6B6B),
                  ),
                ),
              ] else ...[
                Text(
                  _formatTime(_totalTime - _currentTime),
                  style: const TextStyle(
                    fontSize: 48,
                    fontWeight: FontWeight.bold,
                    color: Color(0xFF2D3436),
                  ),
                ),
                const SizedBox(height: 8),
                Text(
                  _status,
                  style: const TextStyle(
                    fontSize: 14,
                    color: Color(0xFFFF6B6B),
                  ),
                ),
              ],
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildStepCounter() {
    return Container(
      margin: const EdgeInsets.symmetric(horizontal: 32),
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(24),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.1),
            blurRadius: 8,
            offset: const Offset(0, 4),
          ),
        ],
      ),
      child: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Text('👟', style: TextStyle(fontSize: 32)),
              const SizedBox(width: 8),
              TweenAnimationBuilder<int>(
                tween: IntTween(begin: 0, end: _stepCount),
                duration: const Duration(milliseconds: 300),
                builder: (context, value, child) {
                  return Text(
                    value.toString(),
                    style: const TextStyle(
                      fontSize: 32,
                      fontWeight: FontWeight.bold,
                      color: Color(0xFFFF6B6B),
                    ),
                  );
                },
              ),
              Text(
                '/ $_targetSteps',
                style: const TextStyle(
                  fontSize: 20,
                  color: Color(0xFFB2BEC3),
                ),
              ),
            ],
          ),
          const Text(
            '步数',
            style: TextStyle(
              fontSize: 14,
              color: Color(0xFF636E72),
            ),
          ),
          const SizedBox(height: 12),
          ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: LinearProgressIndicator(
              value: _stepCount / _targetSteps,
              backgroundColor: const Color(0xFFE9ECEF),
              valueColor: const AlwaysStoppedAnimation<Color>(
                Color(0xFF4ECDC4),
              ),
              minHeight: 8,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildTrainingInfo() {
    if (_course == null) return const SizedBox();

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          _buildInfoItem('⏱️', '${_course!.duration}min', '时长'),
          _buildInfoItem('🔥', '${_course!.calories}', '卡路里'),
          _buildInfoItem('👨‍🏫', _course!.coach, '教练'),
        ],
      ),
    );
  }

  Widget _buildInfoItem(String icon, String value, String label) {
    return Column(
      children: [
        Text(icon, style: const TextStyle(fontSize: 24)),
        const SizedBox(height: 4),
        Text(
          value,
          style: const TextStyle(
            fontSize: 18,
            fontWeight: FontWeight.bold,
            color: Color(0xFF2D3436),
          ),
        ),
        Text(
          label,
          style: const TextStyle(
            fontSize: 10,
            color: Color(0xFFB2BEC3),
          ),
        ),
      ],
    );
  }

  Widget _buildControlPanel() {
    return Container(
      padding: const EdgeInsets.all(32),
      child: _buildControlButtons(),
    );
  }

  Widget _buildControlButtons() {
    switch (_status) {
      case '准备中':
        return SizedBox(
          width: double.infinity,
          height: 50,
          child: ElevatedButton(
            onPressed: _startCountdown,
            style: ElevatedButton.styleFrom(
              backgroundColor: const Color(0xFFFF6B6B),
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(25),
              ),
            ),
            child: const Text(
              '开始训练',
              style: TextStyle(
                fontSize: 16,
                fontWeight: FontWeight.bold,
                color: Colors.white,
              ),
            ),
          ),
        );
      case '倒计时':
        return SizedBox(
          width: double.infinity,
          height: 50,
          child: ElevatedButton(
            onPressed: () {
              _countdownTimer?.cancel();
              setState(() => _showCountdown = false);
            },
            style: ElevatedButton.styleFrom(
              backgroundColor: const Color(0xFFE9ECEF),
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(25),
              ),
            ),
            child: const Text(
              '取消',
              style: TextStyle(
                fontSize: 16,
                color: Color(0xFF636E72),
              ),
            ),
          ),
        );
      case '训练中':
        return Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            Expanded(
              child: ElevatedButton(
                onPressed: _pauseTraining,
                style: ElevatedButton.styleFrom(
                  backgroundColor: const Color(0xFFFDCB6E),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(25),
                  ),
                ),
                child: const Text(
                  '暂停',
                  style: TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.bold,
                    color: Colors.white,
                  ),
                ),
              ),
            ),
            const SizedBox(width: 16),
            Expanded(
              child: ElevatedButton(
                onPressed: _stopTraining,
                style: ElevatedButton.styleFrom(
                  backgroundColor: const Color(0xFFE74C3C),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(25),
                  ),
                ),
                child: const Text(
                  '结束',
                  style: TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.bold,
                    color: Colors.white,
                  ),
                ),
              ),
            ),
          ],
        );
      case '已暂停':
        return Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            Expanded(
              child: ElevatedButton(
                onPressed: _resumeTraining,
                style: ElevatedButton.styleFrom(
                  backgroundColor: const Color(0xFF00B894),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(25),
                  ),
                ),
                child: const Text(
                  '继续',
                  style: TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.bold,
                    color: Colors.white,
                  ),
                ),
              ),
            ),
            const SizedBox(width: 16),
            Expanded(
              child: ElevatedButton(
                onPressed: _stopTraining,
                style: ElevatedButton.styleFrom(
                  backgroundColor: const Color(0xFFE74C3C),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(25),
                  ),
                ),
                child: const Text(
                  '结束',
                  style: TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.bold,
                    color: Colors.white,
                  ),
                ),
              ),
            ),
          ],
        );
      case '已完成':
        return SizedBox(
          width: double.infinity,
          height: 50,
          child: ElevatedButton(
            onPressed: () {
              _stopTraining();
              _startCountdown();
            },
            style: ElevatedButton.styleFrom(
              backgroundColor: const Color(0xFFFF6B6B),
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(25),
              ),
            ),
            child: const Text(
              '再练一次',
              style: TextStyle(
                fontSize: 16,
                fontWeight: FontWeight.bold,
                color: Colors.white,
              ),
            ),
          ),
        );
      default:
        return const SizedBox();
    }
  }

  Widget _buildCountdownOverlay() {
    return Container(
      color: Colors.white.withOpacity(0.95),
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TweenAnimationBuilder<int>(
              tween: IntTween(begin: _countdown, end: _countdown),
              duration: const Duration(milliseconds: 300),
              builder: (context, value, child) {
                return Text(
                  '$value',
                  style: const TextStyle(
                    fontSize: 150,
                    fontWeight: FontWeight.bold,
                    color: Color(0xFFFF6B6B),
                  ),
                );
              },
            ),
            const SizedBox(height: 16),
            const Text(
              '准备开始',
              style: TextStyle(
                fontSize: 20,
                color: Color(0xFF636E72),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

3.5 底部选项卡整合

主页面整合了四个底部选项卡,实现完整的应用导航:

dart 复制代码
// pages/fitness_main_page.dart

import 'package:flutter/material.dart';
import 'course_page.dart';
import 'training_page.dart';
import 'community_page.dart';
import 'my_fitness_page.dart';

class FitnessMainPage extends StatefulWidget {
  const FitnessMainPage({Key? key}) : super(key: key);

  @override
  State<FitnessMainPage> createState() => _FitnessMainPageState();
}

class _FitnessMainPageState extends State<FitnessMainPage> {
  int _currentIndex = 0;

  final List<Widget> _pages = const [
    CoursePage(),
    TrainingPage(),
    CommunityPage(),
    MyFitnessPage(),
  ];

  final List<String> _titles = ['课程', '训练', '社区', '我的'];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        children: _pages,
      ),
      bottomNavigationBar: Container(
        decoration: BoxDecoration(
          color: Colors.white,
          boxShadow: [
            BoxShadow(
              color: Colors.black.withOpacity(0.08),
              blurRadius: 20,
              offset: const Offset(0, -5),
            ),
          ],
        ),
        child: SafeArea(
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildNavItem(0, Icons.fitness_center, '💪', '课程'),
              _buildNavItem(1, Icons.local_fire_department, '🔥', '训练'),
              _buildNavItem(2, Icons.people, '💬', '社区'),
              _buildNavItem(3, Icons.person, '👤', '我的'),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildNavItem(int index, IconData icon, String emoji, String title) {
    final isSelected = _currentIndex == index;
    return GestureDetector(
      onTap: () {
        setState(() => _currentIndex = index);
      },
      behavior: HitTestBehavior.opaque,
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 200),
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              emoji,
              style: TextStyle(
                fontSize: 24,
                color: isSelected ? const Color(0xFFFF6B6B) : Colors.grey,
              ),
            ),
            const SizedBox(height: 4),
            Text(
              title,
              style: TextStyle(
                fontSize: 11,
                color: isSelected ? const Color(0xFFFF6B6B) : Colors.grey,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

四、截图运行验证

4.1 应用启动页

应用启动时展示精美的启动动画,渐变背景配合健身主题图标:

4.2 课程列表页面

展示热门课程推荐,支持分类筛选和下拉刷新:

4.3 训练页面

倒计时动画和圆形进度环,实时显示训练状态:

4.4 社区页面

用户动态、健身打卡、互动交流:

4.5 我的健身页面

计步器动画、数据统计、训练记录:


五、代码仓库

本文所有代码已托管至 AtomGit 平台:

仓库地址https://atomgit.com/maaath/fitness_flutter_app

仓库结构:

复制代码
fitness_flutter_app/
├── lib/
│   ├── main.dart
│   ├── model/
│   │   └── fitness_model.dart
│   ├── pages/
│   │   ├── fitness_main_page.dart
│   │   ├── course_page.dart
│   │   ├── training_page.dart
│   │   ├── community_page.dart
│   │   └── my_fitness_page.dart
│   ├── services/
│   │   └── fitness_service.dart
│   └── theme/
│       └── app_theme.dart
├── screenshots/
│   └── ...
└── README.md

六、总结与展望

通过本文的实战案例,我们成功使用 Flutter for OpenHarmony 开发了一个功能完整的健身运动应用。应用涵盖了:

  1. 网络请求与数据管理:使用 dio 进行网络请求,实现数据层与 UI 层分离
  2. 丰富的 UI 组件:下拉刷新、上拉加载、动画效果、底部导航栏
  3. 状态管理:使用 StatefulWidget 进行组件级状态管理
  4. 跨平台兼容:代码在 OpenHarmony 设备上流畅运行

后续优化方向

  • 引入 Provider 或 Riverpod 进行全局状态管理
  • 添加本地数据持久化,支持离线缓存
  • 实现用户登录与数据同步
  • 增加视频课程播放功能
  • 接入真实后端 API

相关推荐
Swift社区7 小时前
传统游戏引擎 vs 鸿蒙 System 架构
架构·游戏引擎·harmonyos
maaath7 小时前
【maaath】 Flutter for OpenHarmony 新闻资讯应用实战开发
flutter·华为·harmonyos
maaath7 小时前
【maaath】Flutter for OpenHarmony 跨平台图书阅读应用开发实践
flutter·华为·harmonyos
xmdy58667 小时前
Flutter+开源鸿蒙实战|智联邻里Day2 首页UI开发+全局组件封装+鸿蒙多端适配
flutter·开源·harmonyos
特立独行的猫a7 小时前
移植 vcpkg 到鸿蒙 PC:vcpkg-tool 交叉编译与实践手记
华为·harmonyos·vcpkg·鸿蒙pc·vcpkg-tool
911hzh8 小时前
Flutter 音视频通话集成实战:WebSocket 做信令,WebRTC 传音视频,附详细事件时序图
websocket·flutter·音视频
万添裁8 小时前
huawei 机考
算法·华为·深度优先
里欧跑得慢16 小时前
15. Web可访问性最佳实践:让每个用户都能平等访问
前端·css·flutter·web
nashane17 小时前
HarmonyOS Wi-Fi连接用户操作监听全解析:从系统弹框到Promise回调
华为·harmonyos·harmonyos 5