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

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

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. 主题切换:多主题支持、动态切换

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


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

posted @ 2026-03-06 17:05  gccbuaa  阅读(9)  评论(0)    收藏  举报