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

Flutter for OpenHarmony 进阶:体育计分系统与数据持久化深度解析

摘要

在这里插入图片描述

体育计分系统不仅是记录分数的工具,更是理解状态管理、数据持久化、UI同步等高级技术的绝佳案例。本文深入讲解排球计分系统的进阶实现,包括多种计分规则支持、历史数据存储、数据统计分析、UI主题切换等核心技术点。通过本文学习,读者将掌握Flutter在鸿蒙平台上开发企业级应用的完整技巧。


一、多种计分规则支持

1.1 规则配置类

enum ScoringRule {
  standard25,     // 标准25分制
  standard15,     // 标准15分制
  rally25,        // 每球得分25分制
  custom,         // 自定义规则
}

class RuleConfig {
  final ScoringRule type;
  final int winningScore;      // 获胜分数
  final int minLead;           // 最小领先分差
  final int setsToWin;         // 获胜局数
  final bool hasTieBreak;      // 是否有决胜局
  final int tieBreakScore;     // 决胜局分数
  final String name;
  final String description;

  const RuleConfig({
    required this.type,
    required this.winningScore,
    required this.minLead,
    required this.setsToWin,
    required this.hasTieBreak,
    required this.tieBreakScore,
    required this.name,
    required this.description,
  });

  // 预设规则
  static const standardVolleyball = RuleConfig(
    type: ScoringRule.standard25,
    winningScore: 25,
    minLead: 2,
    setsToWin: 3,
    hasTieBreak: true,
    tieBreakScore: 15,
    name: '标准排球',
    description: '前四局25分,决胜局15分',
  );

  static const miniVolleyball = RuleConfig(
    type: ScoringRule.standard15,
    winningScore: 15,
    minLead: 2,
    setsToWin: 3,
    hasTieBreak: false,
    tieBreakScore: 15,
    name: '迷你排球',
    description: '每局15分,三局两胜',
  );

  static const beachVolleyball = RuleConfig(
    type: ScoringRule.rally25,
    winningScore: 21,
    minLead: 2,
    setsToWin: 2,
    hasTieBreak: true,
    tieBreakScore: 15,
    name: '沙滩排球',
    description: '每局21分,两局制,决胜局15分',
  );
}

1.2 规则切换实现

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

  
  State<ScoreboardPage> createState() => _ScoreboardPageState();
}

class _ScoreboardPageState extends State<ScoreboardPage> {
  RuleConfig _currentRule = RuleConfig.standardVolleyball;

  void _changeRule(RuleConfig newRule) {
    if (_matchStarted) {
      showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: const Text('确认切换规则'),
          content: const Text('切换规则将重置当前比赛,确定继续吗?'),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('取消'),
            ),
            TextButton(
              onPressed: () {
                Navigator.pop(context);
                setState(() {
                  _currentRule = newRule;
                  _resetMatch();
                });
              },
              child: const Text('确定'),
            ),
          ],
        ),
      );
    } else {
      setState(() {
        _currentRule = newRule;
      });
    }
  }
}

二、数据持久化

2.1 比赛记录模型

class MatchRecord {
  final String id;
  final DateTime date;
  final RuleConfig rule;
  final String teamAName;
  final String teamBName;
  final int teamASets;
  final int teamBSets;
  final List<SetRecord> sets;
  final int totalDuration;  // 总时长(秒)

  MatchRecord({
    required this.id,
    required this.date,
    required this.rule,
    required this.teamAName,
    required this.teamBName,
    required this.teamASets,
    required this.teamBSets,
    required this.sets,
    required this.totalDuration,
  });

  // 转换为JSON
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'date': date.toIso8601String(),
      'rule': rule.type.name,
      'teamAName': teamAName,
      'teamBName': teamBName,
      'teamASets': teamASets,
      'teamBSets': teamBSets,
      'sets': sets.map((s) => s.toJson()).toList(),
      'totalDuration': totalDuration,
    };
  }

  // 从JSON创建
  factory MatchRecord.fromJson(Map<String, dynamic> json) {
    return MatchRecord(
      id: json['id'] as String,
      date: DateTime.parse(json['date'] as String),
      rule: _getRuleFromType(json['rule'] as String),
      teamAName: json['teamAName'] as String,
      teamBName: json['teamBName'] as String,
      teamASets: json['teamASets'] as int,
      teamBSets: json['teamBSets'] as int,
      sets: (json['sets'] as List)
          .map((s) => SetRecord.fromJson(s as Map<String, dynamic>))
          .toList(),
      totalDuration: json['totalDuration'] as int,
    );
  }

  static RuleConfig _getRuleFromType(String type) {
    switch (type) {
      case 'standard25':
        return RuleConfig.standardVolleyball;
      case 'standard15':
        return RuleConfig.miniVolleyball;
      case 'rally25':
        return RuleConfig.beachVolleyball;
      default:
        return RuleConfig.standardVolleyball;
    }
  }
}

class SetRecord {
  final int setNumber;
  final int teamAScore;
  final int teamBScore;
  final int duration;

  SetRecord({
    required this.setNumber,
    required this.teamAScore,
    required this.teamBScore,
    required this.duration,
  });

  Map<String, dynamic> toJson() {
    return {
      'setNumber': setNumber,
      'teamAScore': teamAScore,
      'teamBScore': teamBScore,
      'duration': duration,
    };
  }

  factory SetRecord.fromJson(Map<String, dynamic> json) {
    return SetRecord(
      setNumber: json['setNumber'] as int,
      teamAScore: json['teamAScore'] as int,
      teamBScore: json['teamBScore'] as int,
      duration: json['duration'] as int,
    );
  }
}

2.2 存储服务

import 'dart:io';
import 'dart:convert';

class MatchStorageService {
  static const String _matchesDir = 'matches';
  static const String _indexFile = 'matches_index.json';

  // 保存比赛记录
  Future<void> saveMatch(MatchRecord match) async {
    final directory = await _getMatchesDirectory();
    final file = File('${directory.path}/${match.id}.json');
    await file.writeAsString(jsonEncode(match.toJson()));
    await _updateIndex(match);
  }

  // 获取所有比赛记录
  Future<List<MatchRecord>> getAllMatches() async {
    final directory = await _getMatchesDirectory();
    final indexFile = File('${directory.path}/$_indexFile');

    if (!await indexFile.exists()) {
      return [];
    }

    final indexData = await indexFile.readAsString();
    final List<dynamic> index = jsonDecode(indexData);

    final matches = <MatchRecord>[];
    for (final item in index) {
      final matchFile = File('${directory.path}/${item['id']}.json}');
      if (await matchFile.exists()) {
        final matchData = await matchFile.readAsString();
        matches.add(MatchRecord.fromJson(jsonDecode(matchData)));
      }
    }

    // 按日期倒序排列
    matches.sort((a, b) => b.date.compareTo(a.date));
    return matches;
  }

  // 删除比赛记录
  Future<void> deleteMatch(String matchId) async {
    final directory = await _getMatchesDirectory();
    final file = File('${directory.path}/$matchId.json');
    if (await file.exists()) {
      await file.delete();
      await _removeFromIndex(matchId);
    }
  }

  // 获取比赛目录
  Future<Directory> _getMatchesDirectory() async {
    final appDir = await getApplicationDocumentsDirectory();
    final matchesDir = Directory('${appDir.path}/$_matchesDir');
    if (!await matchesDir.exists()) {
      await matchesDir.create(recursive: true);
    }
    return matchesDir;
  }

  // 更新索引
  Future<void> _updateIndex(MatchRecord match) async {
    final directory = await _getMatchesDirectory();
    final indexFile = File('${directory.path}/$_indexFile');

    List<Map<String, dynamic>> index = [];
    if (await indexFile.exists()) {
      final indexData = await indexFile.readAsString();
      index = List<Map<String, dynamic>>.from(jsonDecode(indexData));
    }

    index.add({
      'id': match.id,
      'date': match.date.toIso8601String(),
      'teamA': match.teamAName,
      'teamB': match.teamBName,
      'scoreA': match.teamASets,
      'scoreB': match.teamBSets,
    });

    await indexFile.writeAsString(jsonEncode(index));
  }

  // 从索引移除
  Future<void> _removeFromIndex(String matchId) async {
    final directory = await _getMatchesDirectory();
    final indexFile = File('${directory.path}/$_indexFile');

    if (!await indexFile.exists()) return;

    final indexData = await indexFile.readAsString();
    final index = List<Map<String, dynamic>>.from(jsonDecode(indexData));

    index.removeWhere((item) => item['id'] == matchId);

    await indexFile.writeAsString(jsonEncode(index));
  }
}

2.3 使用SharedPreferences存储配置

import 'package:shared_preferences/shared_preferences.dart';

class SettingsService {
  static const String _defaultRuleKey = 'default_rule';
  static const String _themeKey = 'theme_mode';
  static const String _soundEnabledKey = 'sound_enabled';
  static const String _vibrationEnabledKey = 'vibration_enabled';

  // 保存默认规则
  Future<void> saveDefaultRule(ScoringRule rule) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_defaultRuleKey, rule.name);
  }

  // 获取默认规则
  Future<ScoringRule?> getDefaultRule() async {
    final prefs = await SharedPreferences.getInstance();
    final ruleName = prefs.getString(_defaultRuleKey);
    if (ruleName != null) {
      return ScoringRule.values.firstWhere(
        (r) => r.name == ruleName,
        orElse: () => ScoringRule.standard25,
      );
    }
    return null;
  }

  // 保存主题模式
  Future<void> saveThemeMode(String themeMode) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_themeKey, themeMode);
  }

  // 获取主题模式
  Future<String?> getThemeMode() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getString(_themeKey);
  }

  // 保存声音设置
  Future<void> saveSoundEnabled(bool enabled) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(_soundEnabledKey, enabled);
  }

  // 获取声音设置
  Future<bool> getSoundEnabled() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getBool(_soundEnabledKey) ?? true;
  }
}

三、数据统计分析

3.1 统计数据模型

class MatchStatistics {
  final int totalMatches;
  final int teamAWins;
  final int teamBWins;
  final double teamAWinRate;
  final double teamBWinRate;
  final int totalSets;
  final int longestMatch;
  final int shortestMatch;
  final double averageDuration;

  MatchStatistics({
    required this.totalMatches,
    required this.teamAWins,
    required this.teamBWins,
    required this.teamAWinRate,
    required this.teamBWinRate,
    required this.totalSets,
    required this.longestMatch,
    required this.shortestMatch,
    required this.averageDuration,
  });
}

class StatisticsService {
  // 计算统计数据
  static MatchStatistics calculateStatistics(List<MatchRecord> matches) {
    if (matches.isEmpty) {
      return MatchStatistics(
        totalMatches: 0,
        teamAWins: 0,
        teamBWins: 0,
        teamAWinRate: 0.0,
        teamBWinRate: 0.0,
        totalSets: 0,
        longestMatch: 0,
        shortestMatch: 0,
        averageDuration: 0.0,
      );
    }

    int teamAWins = 0;
    int teamBWins = 0;
    int totalSets = 0;
    int longestMatch = 0;
    int shortestMatch = 999999;
    int totalDuration = 0;

    for (final match in matches) {
      if (match.teamASets > match.teamBSets) {
        teamAWins++;
      } else {
        teamBWins++;
      }

      totalSets += match.teamASets + match.teamBSets;
      totalDuration += match.totalDuration;

      if (match.totalDuration > longestMatch) {
        longestMatch = match.totalDuration;
      }
      if (match.totalDuration < shortestMatch) {
        shortestMatch = match.totalDuration;
      }
    }

    return MatchStatistics(
      totalMatches: matches.length,
      teamAWins: teamAWins,
      teamBWins: teamBWins,
      teamAWinRate: teamAWins / matches.length,
      teamBWinRate: teamBWins / matches.length,
      totalSets: totalSets,
      longestMatch: longestMatch,
      shortestMatch: shortestMatch == 999999 ? 0 : shortestMatch,
      averageDuration: totalDuration / matches.length,
    );
  }
}

3.2 统计图表UI

在这里插入图片描述

class StatisticsPage extends StatelessWidget {
  final List<MatchRecord> matches;

  const StatisticsPage({super.key, required this.matches});

  
  Widget build(BuildContext context) {
    final stats = StatisticsService.calculateStatistics(matches);

    return Scaffold(
      appBar: AppBar(
        title: const Text('数据统计'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          _buildOverviewCard(stats),
          const SizedBox(height: 16),
          _buildWinRateCard(stats),
          const SizedBox(height: 16),
          _buildMatchHistoryCard(matches),
        ],
      ),
    );
  }

  Widget _buildOverviewCard(MatchStatistics stats) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('比赛概览', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: [
                _buildStatItem('总场次', '${stats.totalMatches}', Icons.sports_volleyball),
                _buildStatItem('总局数', '${stats.totalSets}', Icons.format_list_numbered),
                _buildStatItem('最长', _formatTime(stats.longestMatch), Icons.timer),
                _buildStatItem('平均', _formatTime(stats.averageDuration.toInt()), Icons.schedule),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildWinRateCard(MatchStatistics stats) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('胜率分析', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 16),
            Row(
              children: [
                Expanded(
                  child: _buildTeamWinRate('主队', stats.teamAWins, stats.teamAWinRate, Colors.blue),
                ),
                const SizedBox(width: 16),
                Expanded(
                  child: _buildTeamWinRate('客队', stats.teamBWins, stats.teamBWinRate, Colors.red),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildTeamWinRate(String name, int wins, double rate, Color color) {
    return Column(
      children: [
        Text(name, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: color)),
        const SizedBox(height: 8),
        Text('$wins 场', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
        const SizedBox(height: 4),
        Text('${(rate * 100).toStringAsFixed(1)}%', style: TextStyle(fontSize: 14, color: Colors.grey.shade600)),
        const SizedBox(height: 8),
        ClipRRect(
          borderRadius: BorderRadius.circular(4),
          child: LinearProgressIndicator(
            value: rate,
            backgroundColor: Colors.grey.shade200,
            valueColor: AlwaysStoppedAnimation<Color>(color),
            minHeight: 8,
          ),
        ),
      ],
    );
  }

  Widget _buildStatItem(String label, String value, IconData icon) {
    return Column(
      children: [
        Icon(icon, size: 32, color: Colors.blue),
        const SizedBox(height: 8),
        Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
        const SizedBox(height: 4),
        Text(label, style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
      ],
    );
  }

  String _formatTime(int seconds) {
    final hours = seconds ~/ 3600;
    final minutes = (seconds % 3600) ~/ 60;
    final secs = seconds % 60;
    if (hours > 0) {
      return '${hours}h ${minutes}m';
    } else if (minutes > 0) {
      return '${minutes}m ${secs}s';
    } else {
      return '${secs}s';
    }
  }
}

四、主题切换

4.1 主题配置

class AppTheme {
  static ThemeData get lightTheme {
    return ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: Colors.blue,
        brightness: Brightness.light,
      ),
      useMaterial3: true,
    );
  }

  static ThemeData get darkTheme {
    return ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: Colors.blue,
        brightness: Brightness.dark,
      ),
      useMaterial3: true,
    );
  }

  static ThemeData get blueTheme {
    return ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: Colors.blue,
        primary: Colors.blue,
        secondary: Colors.lightBlue,
      ),
      useMaterial3: true,
    );
  }

  static ThemeData get greenTheme {
    return ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: Colors.green,
        primary: Colors.green,
        secondary: Colors.lightGreen,
      ),
      useMaterial3: true,
    );
  }

  static ThemeData get orangeTheme {
    return ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: Colors.orange,
        primary: Colors.orange,
        secondary: Colors.deepOrange,
      ),
      useMaterial3: true,
    );
  }
}

4.2 主题切换实现

class ThemeProvider with ChangeNotifier {
  static const String _defaultTheme = 'blue';
  static const List<String> _availableThemes = [
    'blue',
    'green',
    'orange',
  ];

  String _currentTheme = _defaultTheme;

  String get currentTheme => _currentTheme;
  List<String> get availableThemes => _availableThemes;

  ThemeData get themeData {
    switch (_currentTheme) {
      case 'green':
        return AppTheme.greenTheme;
      case 'orange':
        return AppTheme.orangeTheme;
      default:
        return AppTheme.blueTheme;
    }
  }

  void setTheme(String theme) {
    if (_availableThemes.contains(theme)) {
      _currentTheme = theme;
      notifyListeners();
      _saveTheme(theme);
    }
  }

  Future<void> _saveTheme(String theme) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('theme', theme);
  }

  Future<void> loadTheme() async {
    final prefs = await SharedPreferences.getInstance();
    final savedTheme = prefs.getString('theme') ?? _defaultTheme;
    setTheme(savedTheme);
  }
}

五、总结

本文深入讲解了排球计分系统的进阶实现,主要内容包括:

  1. 多种规则:规则配置、规则切换
  2. 数据持久化:比赛记录存储、配置保存
  3. 统计分析:胜率计算、数据分析
  4. 主题切换:多主题支持、动态切换

这些技术可以应用到各种需要数据持久化和统计分析的应用中。


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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐