From 6ec973144ea69c27228377de139830139ac71690 Mon Sep 17 00:00:00 2001 From: xkeyC <3334969096@qq.com> Date: Wed, 17 Dec 2025 12:26:57 +0800 Subject: [PATCH] feat: init YearlyReportUI --- lib/common/helper/game_log_analyzer.dart | 16 + lib/common/helper/yearly_report_analyzer.dart | 342 ++-- lib/provider/dcb_viewer.g.dart | 2 +- .../home_game_login_dialog_ui_model.g.dart | 2 +- .../localization/localization_ui_model.g.dart | 2 +- .../performance/performance_ui_model.g.dart | 2 +- .../log_analyze_ui/log_analyze_provider.dart | 68 +- .../log_analyze_provider.g.dart | 37 +- .../tools/log_analyze_ui/log_analyze_ui.dart | 218 +-- lib/ui/tools/tools_ui_model.dart | 29 + lib/ui/tools/tools_ui_model.g.dart | 2 +- .../yearly_report_ui/yearly_report_ui.dart | 1415 +++++++++++++++++ 12 files changed, 1914 insertions(+), 221 deletions(-) create mode 100644 lib/ui/tools/yearly_report_ui/yearly_report_ui.dart diff --git a/lib/common/helper/game_log_analyzer.dart b/lib/common/helper/game_log_analyzer.dart index cc01bb4..ee4ba87 100644 --- a/lib/common/helper/game_log_analyzer.dart +++ b/lib/common/helper/game_log_analyzer.dart @@ -104,6 +104,22 @@ class GameLogAnalyzer { // 本地库存请求解析 static final _requestLocationInventoryPattern = RegExp(r"Player\[([^\]]+)\].*?Location\[([^\]]+)\]"); + // 载具控制解析 + static final vehicleControlPattern = RegExp(r"granted control token for '([^']+)'\s+\[(\d+)\]"); + + /// 公开的日期时间解析方法,供其他模块使用 + static DateTime? getLogLineDateTime(String line) => _getLogLineDateTime(line); + + /// 公开的日期时间字符串解析方法 + static String? getLogLineDateTimeString(String line) => _getLogLineDateTimeString(line); + + /// 从载具名称中移除末尾的ID + /// 示例: ANVL_Hornet_F7A_Mk2_3467069517923 -> ANVL_Hornet_F7A_Mk2 + static String removeVehicleId(String vehicleName) { + final regex = RegExp(r'_\d+$'); + return vehicleName.replaceAll(regex, ''); + } + /// 分析整个日志文件 /// /// [logFile] 日志文件 diff --git a/lib/common/helper/yearly_report_analyzer.dart b/lib/common/helper/yearly_report_analyzer.dart index 3cc5d08..0b5070b 100644 --- a/lib/common/helper/yearly_report_analyzer.dart +++ b/lib/common/helper/yearly_report_analyzer.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'dart:convert'; +import 'dart:isolate'; import 'package:starcitizen_doctor/common/helper/game_log_analyzer.dart'; -import 'package:starcitizen_doctor/common/utils/log.dart'; /// 年度报告数据类 class YearlyReportData { @@ -18,6 +18,13 @@ class YearlyReportData { final DateTime? earliestPlayDate; // 年度游玩最早的一天 (05:00及以后) final DateTime? latestPlayDate; // 年度游玩最晚的一天 (04:00及以前) + // 游玩时长统计 + final Duration? longestSession; // 最长单次游玩时长 + final DateTime? longestSessionDate; // 最长游玩那一天 + final Duration? shortestSession; // 最短单次游玩时长 (超过5分钟的) + final DateTime? shortestSessionDate; // 最短游玩那一天 + final Duration? averageSessionTime; // 平均单次游玩时长 + // 载具统计 final int yearlyVehicleDestructionCount; // 年度炸船次数 final String? mostDestroyedVehicle; // 年度炸的最多的船 @@ -25,19 +32,23 @@ class YearlyReportData { final String? mostPilotedVehicle; // 年度最爱驾驶的载具 final int mostPilotedVehicleCount; // 驾驶次数 - // 战斗统计 - final int yearlyKillCount; // 年度击杀次数 - final int yearlyDeathCount; // 年度死亡次数 - // 账号统计 final int accountCount; // 账号数量 final String? mostPlayedAccount; // 游玩最多的账号 final int mostPlayedAccountSessionCount; // 游玩最多的账号的会话次数 + // 地点统计 + final List> topLocations; // Top 地点访问统计 + + // 击杀统计 (K/D) + final int yearlyKillCount; // 年度击杀次数 + final int yearlyDeathCount; // 年度死亡次数 + final int yearlySelfKillCount; // 年度自杀次数 + // 详细数据 (用于展示) - final Map vehicleDestructionDetails; // 载具损毁详情 final Map vehiclePilotedDetails; // 驾驶载具详情 final Map accountSessionDetails; // 账号会话详情 + final Map locationDetails; // 地点访问详情 const YearlyReportData({ required this.totalLaunchCount, @@ -49,49 +60,44 @@ class YearlyReportData { this.yearlyFirstLaunchTime, this.earliestPlayDate, this.latestPlayDate, + this.longestSession, + this.longestSessionDate, + this.shortestSession, + this.shortestSessionDate, + this.averageSessionTime, required this.yearlyVehicleDestructionCount, this.mostDestroyedVehicle, required this.mostDestroyedVehicleCount, this.mostPilotedVehicle, required this.mostPilotedVehicleCount, - required this.yearlyKillCount, - required this.yearlyDeathCount, required this.accountCount, this.mostPlayedAccount, required this.mostPlayedAccountSessionCount, - required this.vehicleDestructionDetails, + required this.topLocations, + required this.yearlyKillCount, + required this.yearlyDeathCount, + required this.yearlySelfKillCount, required this.vehiclePilotedDetails, required this.accountSessionDetails, + required this.locationDetails, }); - /// 将 DateTime 转换为带时区的 ISO 8601 字符串 - /// 输出格式: 2025-12-17T10:30:00.000+08:00 - static String? _toIso8601WithTimezone(DateTime? dateTime) { + /// 将 DateTime 转换为 UTC 毫秒时间戳 + static int? _toUtcTimestamp(DateTime? dateTime) { if (dateTime == null) return null; - final local = dateTime.toLocal(); - final offset = local.timeZoneOffset; - final sign = offset.isNegative ? '-' : '+'; - final hours = offset.inHours.abs().toString().padLeft(2, '0'); - final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, '0'); - // 使用本地时间的 ISO 字符串,然后附加时区偏移 - final isoString = local.toIso8601String(); - // 移除可能的 'Z' 后缀(UTC 标记) - final baseString = isoString.endsWith('Z') ? isoString.substring(0, isoString.length - 1) : isoString; - return '$baseString$sign$hours:$minutes'; + return dateTime.toUtc().millisecondsSinceEpoch; } /// 转换为 JSON Map + /// + /// 时间字段使用 UTC 毫秒时间戳 (int),配合 timezoneOffsetMinutes 可在客户端还原本地时间 Map toJson() { final now = DateTime.now(); final offset = now.timeZoneOffset; - final sign = offset.isNegative ? '-' : '+'; - final hours = offset.inHours.abs().toString().padLeft(2, '0'); - final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, '0'); return { // 元数据 - 'generatedAt': _toIso8601WithTimezone(now), - 'timezoneOffset': '$sign$hours:$minutes', + 'generatedAtUtc': _toUtcTimestamp(now), 'timezoneOffsetMinutes': offset.inMinutes, // 基础统计 @@ -102,10 +108,17 @@ class YearlyReportData { 'totalCrashCount': totalCrashCount, 'yearlyCrashCount': yearlyCrashCount, - // 时间统计 (带时区) - 'yearlyFirstLaunchTime': _toIso8601WithTimezone(yearlyFirstLaunchTime), - 'earliestPlayDate': _toIso8601WithTimezone(earliestPlayDate), - 'latestPlayDate': _toIso8601WithTimezone(latestPlayDate), + // 时间统计 (UTC 毫秒时间戳) + 'yearlyFirstLaunchTimeUtc': _toUtcTimestamp(yearlyFirstLaunchTime), + 'earliestPlayDateUtc': _toUtcTimestamp(earliestPlayDate), + 'latestPlayDateUtc': _toUtcTimestamp(latestPlayDate), + + // 游玩时长统计 + 'longestSessionMs': longestSession?.inMilliseconds, + 'longestSessionDateUtc': _toUtcTimestamp(longestSessionDate), + 'shortestSessionMs': shortestSession?.inMilliseconds, + 'shortestSessionDateUtc': _toUtcTimestamp(shortestSessionDate), + 'averageSessionTimeMs': averageSessionTime?.inMilliseconds, // 载具统计 'yearlyVehicleDestructionCount': yearlyVehicleDestructionCount, @@ -114,28 +127,26 @@ class YearlyReportData { 'mostPilotedVehicle': mostPilotedVehicle, 'mostPilotedVehicleCount': mostPilotedVehicleCount, - // 战斗统计 - 'yearlyKillCount': yearlyKillCount, - 'yearlyDeathCount': yearlyDeathCount, - // 账号统计 'accountCount': accountCount, 'mostPlayedAccount': mostPlayedAccount, 'mostPlayedAccountSessionCount': mostPlayedAccountSessionCount, + // 地点统计 + 'topLocations': topLocations.map((e) => {'location': e.key, 'count': e.value}).toList(), + + // 击杀统计 + 'yearlyKillCount': yearlyKillCount, + 'yearlyDeathCount': yearlyDeathCount, + 'yearlySelfKillCount': yearlySelfKillCount, + // 详细数据 - 'vehicleDestructionDetails': vehicleDestructionDetails, 'vehiclePilotedDetails': vehiclePilotedDetails, 'accountSessionDetails': accountSessionDetails, + 'locationDetails': locationDetails, }; } - /// 转换为 JSON 字符串 - String toJsonString() => jsonEncode(toJson()); - - /// 转换为 Base64 编码的 JSON 字符串 - String toJsonBase64() => base64Encode(utf8.encode(toJsonString())); - @override String toString() { return '''YearlyReportData( @@ -148,13 +159,15 @@ class YearlyReportData { yearlyFirstLaunchTime: $yearlyFirstLaunchTime, earliestPlayDate: $earliestPlayDate, latestPlayDate: $latestPlayDate, + longestSession: $longestSession (on $longestSessionDate), + shortestSession: $shortestSession (on $shortestSessionDate), + averageSessionTime: $averageSessionTime, yearlyVehicleDestructionCount: $yearlyVehicleDestructionCount, mostDestroyedVehicle: $mostDestroyedVehicle ($mostDestroyedVehicleCount), mostPilotedVehicle: $mostPilotedVehicle ($mostPilotedVehicleCount), - yearlyKillCount: $yearlyKillCount, - yearlyDeathCount: $yearlyDeathCount, accountCount: $accountCount, mostPlayedAccount: $mostPlayedAccount ($mostPlayedAccountSessionCount), + topLocations: ${topLocations.take(5).map((e) => '${e.key}: ${e.value}').join(', ')}, )'''; } } @@ -166,6 +179,7 @@ class _LogFileStats { bool hasCrash = false; int killCount = 0; int deathCount = 0; + int selfKillCount = 0; Set playerNames = {}; String? currentPlayerName; String? firstPlayerName; // 第一个检测到的玩家名,用于去重 @@ -176,9 +190,11 @@ class _LogFileStats { // 驾驶载具: 载具型号 (去除ID后) -> 次数 Map vehiclePiloted = {}; - // 年度内的时间记录 - List yearlyStartTimes = []; - List yearlyEndTimes = []; + // 地点访问: 地点名 -> 次数 + Map locationVisits = {}; + + // 年度内的会话记录 + List<_SessionInfo> yearlySessions = []; /// 生成用于去重的唯一标识 /// 基于启动时间和第一个玩家名生成 @@ -190,10 +206,20 @@ class _LogFileStats { } } +/// 单次游玩会话信息 +class _SessionInfo { + final DateTime startTime; + final DateTime endTime; + + _SessionInfo({required this.startTime, required this.endTime}); + + Duration get duration => endTime.difference(startTime); +} + /// 年度报告分析器 class YearlyReportAnalyzer { - // 复用 GameLogAnalyzer 中的正则表达式和方法 - static final _characterNamePattern = RegExp(r"name\s+([^-]+)"); + // 新版日志格式的正则表达式 + static final _characterNamePattern = RegExp(r'name\s+(\w+)\s+signedIn'); static final _vehicleDestructionPattern = RegExp( r"Vehicle\s+'([^']+)'.*?" // 载具型号 r"in zone\s+'([^']+)'.*?" // Zone @@ -206,6 +232,16 @@ class YearlyReportAnalyzer { r"to zone '([^']+)'", // 目标区域 ); + // Legacy 格式的正则表达式 (旧版日志) + static final _legacyCharacterNamePattern = RegExp(r"name\s+([^-]+)"); + static final _legacyActorDeathPattern = RegExp( + r"CActor::Kill: '([^']+)'.*?" // 受害者ID + r"in zone '([^']+)'.*?" // 死亡位置区域 + r"killed by '([^']+)'.*?" // 击杀者ID + r"with damage type '([^']+)'", // 伤害类型 + ); + static final _requestLocationInventoryPattern = RegExp(r"Player\[([^\]]+)\].*?Location\[([^\]]+)\]"); + /// 分析单个日志文件 static Future<_LogFileStats> _analyzeLogFile(File logFile, int targetYear) async { final stats = _LogFileStats(); @@ -231,21 +267,6 @@ class YearlyReportAnalyzer { // 更新结束时间 (最后一个有效时间) if (lineTime != null) { stats.endTime = lineTime; - - // 记录年度内的时间 - if (lineTime.year == targetYear) { - if (stats.yearlyStartTimes.isEmpty || - stats.yearlyStartTimes.last.difference(lineTime).abs().inMinutes > 30) { - // 新的会话开始 - stats.yearlyStartTimes.add(lineTime); - } - // 总是更新最后的结束时间 - if (stats.yearlyEndTimes.isEmpty) { - stats.yearlyEndTimes.add(lineTime); - } else { - stats.yearlyEndTimes[stats.yearlyEndTimes.length - 1] = lineTime; - } - } } // 检测崩溃 @@ -253,20 +274,21 @@ class YearlyReportAnalyzer { stats.hasCrash = true; } - // 检测玩家登录 - final nameMatch = _characterNamePattern.firstMatch(line); + // 检测玩家登录 (尝试新版格式,失败则用旧版) + var nameMatch = _characterNamePattern.firstMatch(line); + nameMatch ??= _legacyCharacterNamePattern.firstMatch(line); if (nameMatch != null) { final playerName = nameMatch.group(1)?.trim(); - if (playerName != null && playerName.isNotEmpty) { + if (playerName != null && playerName.isNotEmpty && !playerName.contains(' ')) { stats.currentPlayerName = playerName; stats.playerNames.add(playerName); - // 记录第一个玩家名用于去重 stats.firstPlayerName ??= playerName; } } - // 检测载具损毁 (仅年度内) + // 年度内的统计 if (lineTime != null && lineTime.year == targetYear) { + // 检测载具损毁 final destructionMatch = _vehicleDestructionPattern.firstMatch(line); if (destructionMatch != null) { final vehicleModel = destructionMatch.group(1); @@ -291,29 +313,86 @@ class YearlyReportAnalyzer { } } - // 检测死亡 - final deathMatch = _actorDeathPattern.firstMatch(line); + // 检测死亡 (新版格式) + var deathMatch = _actorDeathPattern.firstMatch(line); if (deathMatch != null) { final victimId = deathMatch.group(1)?.trim(); - if (victimId != null && stats.currentPlayerName != null && victimId == stats.currentPlayerName) { stats.deathCount++; } } + + // 检测死亡 (旧版格式 - Legacy) + final legacyDeathMatch = _legacyActorDeathPattern.firstMatch(line); + if (legacyDeathMatch != null) { + final victimId = legacyDeathMatch.group(1)?.trim(); + final killerId = legacyDeathMatch.group(3)?.trim(); + + if (victimId != null && stats.currentPlayerName != null) { + // 检测自杀 + if (victimId == killerId) { + if (victimId == stats.currentPlayerName) { + stats.selfKillCount++; + } + } else { + // 检测死亡 + if (victimId == stats.currentPlayerName) { + stats.deathCount++; + } + // 检测击杀 + if (killerId == stats.currentPlayerName) { + stats.killCount++; + } + } + } + } + + // 检测地点访问 (RequestLocationInventory) + final locationMatch = _requestLocationInventoryPattern.firstMatch(line); + if (locationMatch != null) { + final location = locationMatch.group(2)?.trim(); + if (location != null && location.isNotEmpty) { + // 清理地点名称,移除数字ID后缀 + final cleanLocation = _cleanLocationName(location); + stats.locationVisits[cleanLocation] = (stats.locationVisits[cleanLocation] ?? 0) + 1; + } + } } } + + // 记录会话信息 + if (stats.startTime != null && stats.endTime != null && stats.startTime!.year == targetYear) { + stats.yearlySessions.add(_SessionInfo(startTime: stats.startTime!, endTime: stats.endTime!)); + } } catch (e) { - dPrint('[YearlyReportAnalyzer] Error analyzing log file: $e'); + // Error handled silently in isolate } return stats; } + /// 清理地点名称,移除数字ID后缀 + static String _cleanLocationName(String location) { + // 移除末尾的数字ID (如 "_12345678") + final cleanPattern = RegExp(r'_\d{6,}$'); + return location.replaceAll(cleanPattern, ''); + } + /// 生成年度报告 /// /// [gameInstallPaths] 游戏安装路径列表 (完整路径,如 ["D:/Games/StarCitizen/LIVE", "D:/Games/StarCitizen/PTU"]) /// [targetYear] 目标年份 + /// + /// 该方法在独立 Isolate 中运行,避免阻塞 UI static Future generateReport(List gameInstallPaths, int targetYear) async { + // 在独立 Isolate 中运行以避免阻塞 UI + return await Isolate.run(() async { + return await _generateReportInIsolate(gameInstallPaths, targetYear); + }); + } + + /// 内部方法:在 Isolate 中执行的报告生成逻辑 + static Future _generateReportInIsolate(List gameInstallPaths, int targetYear) async { final List allLogFiles = []; // 从所有安装路径收集日志文件 @@ -322,7 +401,6 @@ class YearlyReportAnalyzer { // 检查安装目录是否存在 if (!await installDir.exists()) { - dPrint('[YearlyReportAnalyzer] Install path does not exist: $installPath'); continue; } @@ -344,10 +422,6 @@ class YearlyReportAnalyzer { } } - dPrint( - '[YearlyReportAnalyzer] Found ${allLogFiles.length} log files from ${gameInstallPaths.length} install paths', - ); - // 并发分析所有日志文件 final futures = allLogFiles.map((file) => _analyzeLogFile(file, targetYear)); final allStatsRaw = await Future.wait(futures); @@ -359,18 +433,13 @@ class YearlyReportAnalyzer { for (final stats in allStatsRaw) { final key = stats.uniqueKey; if (key == null) { - // 无法生成唯一标识的日志仍然保留 allStats.add(stats); } else if (!seenKeys.contains(key)) { seenKeys.add(key); allStats.add(stats); - } else { - dPrint('[YearlyReportAnalyzer] Skipping duplicate log: $key'); } } - dPrint('[YearlyReportAnalyzer] After deduplication: ${allStats.length} unique logs'); - // 合并统计数据 int totalLaunchCount = allStats.length; Duration totalPlayTime = Duration.zero; @@ -381,12 +450,23 @@ class YearlyReportAnalyzer { DateTime? yearlyFirstLaunchTime; DateTime? earliestPlayDate; DateTime? latestPlayDate; + + // 会话时长统计 + Duration? longestSession; + DateTime? longestSessionDate; + Duration? shortestSession; + DateTime? shortestSessionDate; + List allSessionDurations = []; + + // K/D 统计 int yearlyKillCount = 0; int yearlyDeathCount = 0; + int yearlySelfKillCount = 0; final Map vehicleDestructionDetails = {}; final Map vehiclePilotedDetails = {}; final Map accountSessionDetails = {}; + final Map locationDetails = {}; for (final stats in allStats) { // 累计游玩时长 @@ -397,46 +477,57 @@ class YearlyReportAnalyzer { // 崩溃统计 if (stats.hasCrash) { totalCrashCount++; - // 检查是否为年度内的崩溃 if (stats.endTime != null && stats.endTime!.year == targetYear) { yearlyCrashCount++; } } - // 年度统计 - for (int i = 0; i < stats.yearlyStartTimes.length; i++) { + // 年度会话统计 + for (final session in stats.yearlySessions) { yearlyLaunchCount++; - final startTime = stats.yearlyStartTimes[i]; - final endTime = i < stats.yearlyEndTimes.length ? stats.yearlyEndTimes[i] : startTime; - yearlyPlayTime += endTime.difference(startTime); + final sessionDuration = session.duration; + yearlyPlayTime += sessionDuration; + allSessionDurations.add(sessionDuration); // 年度第一次启动时间 - if (yearlyFirstLaunchTime == null || startTime.isBefore(yearlyFirstLaunchTime)) { - yearlyFirstLaunchTime = startTime; + if (yearlyFirstLaunchTime == null || session.startTime.isBefore(yearlyFirstLaunchTime)) { + yearlyFirstLaunchTime = session.startTime; } // 最早游玩的一天 (05:00及以后开始游戏) - if (startTime.hour >= 5) { - if (earliestPlayDate == null || _timeOfDayIsEarlier(startTime, earliestPlayDate)) { - earliestPlayDate = startTime; + if (session.startTime.hour >= 5) { + if (earliestPlayDate == null || _timeOfDayIsEarlier(session.startTime, earliestPlayDate)) { + earliestPlayDate = session.startTime; } } // 最晚游玩的一天 (04:00及以前结束游戏) - if (endTime.hour <= 4) { - if (latestPlayDate == null || _timeOfDayIsLater(endTime, latestPlayDate)) { - latestPlayDate = endTime; + if (session.endTime.hour <= 4) { + if (latestPlayDate == null || _timeOfDayIsLater(session.endTime, latestPlayDate)) { + latestPlayDate = session.endTime; + } + } + + // 最长游玩时长 + if (longestSession == null || sessionDuration > longestSession) { + longestSession = sessionDuration; + longestSessionDate = session.startTime; + } + + // 最短游玩时长 (超过5分钟的) + if (sessionDuration.inMinutes >= 5) { + if (shortestSession == null || sessionDuration < shortestSession) { + shortestSession = sessionDuration; + shortestSessionDate = session.startTime; } } } - // 累加战斗统计 - yearlyKillCount += stats.killCount; - yearlyDeathCount += stats.deathCount; - - // 合并载具损毁详情 + // 合并载具损毁详情 (过滤包含 PU 的载具) for (final entry in stats.vehicleDestruction.entries) { - vehicleDestructionDetails[entry.key] = (vehicleDestructionDetails[entry.key] ?? 0) + entry.value; + if (!entry.key.contains('PU_')) { + vehicleDestructionDetails[entry.key] = (vehicleDestructionDetails[entry.key] ?? 0) + entry.value; + } } // 合并驾驶载具详情 @@ -444,10 +535,36 @@ class YearlyReportAnalyzer { vehiclePilotedDetails[entry.key] = (vehiclePilotedDetails[entry.key] ?? 0) + entry.value; } + // 累计 K/D + yearlyKillCount += stats.killCount; + yearlyDeathCount += stats.deathCount; + yearlySelfKillCount += stats.selfKillCount; + // 合并账号会话详情 for (final playerName in stats.playerNames) { - accountSessionDetails[playerName] = (accountSessionDetails[playerName] ?? 0) + 1; + if (playerName.length > 16) continue; + String targetKey = playerName; + // 查找是否存在忽略大小写的相同 key + for (final key in accountSessionDetails.keys) { + if (key.toLowerCase() == playerName.toLowerCase()) { + targetKey = key; + break; + } + } + accountSessionDetails[targetKey] = (accountSessionDetails[targetKey] ?? 0) + 1; } + + // 合并地点访问详情 + for (final entry in stats.locationVisits.entries) { + locationDetails[entry.key] = (locationDetails[entry.key] ?? 0) + entry.value; + } + } + + // 计算平均游玩时长 + Duration? averageSessionTime; + if (allSessionDurations.isNotEmpty) { + final totalMs = allSessionDurations.fold(0, (sum, d) => sum + d.inMilliseconds); + averageSessionTime = Duration(milliseconds: totalMs ~/ allSessionDurations.length); } // 计算派生统计 @@ -480,6 +597,10 @@ class YearlyReportAnalyzer { } } + // 计算 Top 10 地点 + final sortedLocations = locationDetails.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + final topLocations = sortedLocations.take(10).toList(); + return YearlyReportData( totalLaunchCount: totalLaunchCount, totalPlayTime: totalPlayTime, @@ -490,19 +611,26 @@ class YearlyReportAnalyzer { yearlyFirstLaunchTime: yearlyFirstLaunchTime, earliestPlayDate: earliestPlayDate, latestPlayDate: latestPlayDate, + longestSession: longestSession, + longestSessionDate: longestSessionDate, + shortestSession: shortestSession, + shortestSessionDate: shortestSessionDate, + averageSessionTime: averageSessionTime, yearlyVehicleDestructionCount: yearlyVehicleDestructionCount, mostDestroyedVehicle: mostDestroyedVehicle, mostDestroyedVehicleCount: mostDestroyedVehicleCount, mostPilotedVehicle: mostPilotedVehicle, mostPilotedVehicleCount: mostPilotedVehicleCount, - yearlyKillCount: yearlyKillCount, - yearlyDeathCount: yearlyDeathCount, accountCount: accountSessionDetails.length, mostPlayedAccount: mostPlayedAccount, mostPlayedAccountSessionCount: mostPlayedAccountSessionCount, - vehicleDestructionDetails: vehicleDestructionDetails, + topLocations: topLocations, + yearlyKillCount: yearlyKillCount, + yearlyDeathCount: yearlyDeathCount, + yearlySelfKillCount: yearlySelfKillCount, vehiclePilotedDetails: vehiclePilotedDetails, accountSessionDetails: accountSessionDetails, + locationDetails: locationDetails, ); } diff --git a/lib/provider/dcb_viewer.g.dart b/lib/provider/dcb_viewer.g.dart index a5d3e03..531f6a0 100644 --- a/lib/provider/dcb_viewer.g.dart +++ b/lib/provider/dcb_viewer.g.dart @@ -41,7 +41,7 @@ final class DcbViewerModelProvider } } -String _$dcbViewerModelHash() => r'f0af2a7b4451f746288e2c9565a418af80f58835'; +String _$dcbViewerModelHash() => r'dfed59de5291e5a19cc481d0115fe91f5bcaf301'; abstract class _$DcbViewerModel extends $Notifier { DcbViewerState build(); diff --git a/lib/ui/home/dialogs/home_game_login_dialog_ui_model.g.dart b/lib/ui/home/dialogs/home_game_login_dialog_ui_model.g.dart index 2b72d98..71c979f 100644 --- a/lib/ui/home/dialogs/home_game_login_dialog_ui_model.g.dart +++ b/lib/ui/home/dialogs/home_game_login_dialog_ui_model.g.dart @@ -42,7 +42,7 @@ final class HomeGameLoginUIModelProvider } String _$homeGameLoginUIModelHash() => - r'217a57f797b37f3467be2e7711f220610e9e67d8'; + r'd81831f54c6b1e98ea8a1e94b5e6049fe552996f'; abstract class _$HomeGameLoginUIModel extends $Notifier { HomeGameLoginState build(); diff --git a/lib/ui/home/localization/localization_ui_model.g.dart b/lib/ui/home/localization/localization_ui_model.g.dart index ad9ffca..5719052 100644 --- a/lib/ui/home/localization/localization_ui_model.g.dart +++ b/lib/ui/home/localization/localization_ui_model.g.dart @@ -42,7 +42,7 @@ final class LocalizationUIModelProvider } String _$localizationUIModelHash() => - r'122f9f85da6e112165f4ff88667b45cf3cf3f43e'; + r'7b398d3b2ddd306ff8f328be39f28200fe8bf49e'; abstract class _$LocalizationUIModel extends $Notifier { LocalizationUIState build(); diff --git a/lib/ui/home/performance/performance_ui_model.g.dart b/lib/ui/home/performance/performance_ui_model.g.dart index 70dc3d2..b4423f6 100644 --- a/lib/ui/home/performance/performance_ui_model.g.dart +++ b/lib/ui/home/performance/performance_ui_model.g.dart @@ -42,7 +42,7 @@ final class HomePerformanceUIModelProvider } String _$homePerformanceUIModelHash() => - r'4c5c33fe7d85dc8f6bf0d019c1b870d285d594ff'; + r'ae4771c7804abb1e5ba89bb1687061612f96b46b'; abstract class _$HomePerformanceUIModel extends $Notifier { diff --git a/lib/ui/tools/log_analyze_ui/log_analyze_provider.dart b/lib/ui/tools/log_analyze_ui/log_analyze_provider.dart index d3f0594..108aa07 100644 --- a/lib/ui/tools/log_analyze_ui/log_analyze_provider.dart +++ b/lib/ui/tools/log_analyze_ui/log_analyze_provider.dart @@ -21,28 +21,84 @@ final Map logAnalyzeSearchTypeMap = { "request_location_inventory": S.current.log_analyzer_filter_local_inventory, }; +/// 日志文件信息 +class LogFileInfo { + final String path; + final String displayName; + final bool isCurrentLog; + + const LogFileInfo({required this.path, required this.displayName, required this.isCurrentLog}); +} + +/// 获取可用的日志文件列表 +Future> getAvailableLogFiles(String gameInstallPath) async { + final List logFiles = []; + + if (gameInstallPath.isEmpty) return logFiles; + + // 添加当前 Game.log + final currentLogFile = File('$gameInstallPath/Game.log'); + if (await currentLogFile.exists()) { + logFiles.add(LogFileInfo(path: currentLogFile.path, displayName: 'Game.log (当前)', isCurrentLog: true)); + } + + // 添加 logbackups 目录中的日志文件 + final logBackupsDir = Directory('$gameInstallPath/logbackups'); + if (await logBackupsDir.exists()) { + final entities = await logBackupsDir.list().toList(); + // 按文件名排序(通常包含时间戳,降序排列显示最新的在前) + entities.sort((a, b) => b.path.compareTo(a.path)); + + for (final entity in entities) { + if (entity is File && entity.path.endsWith('.log')) { + final fileName = entity.path.split(Platform.pathSeparator).last; + logFiles.add(LogFileInfo(path: entity.path, displayName: fileName, isCurrentLog: false)); + } + } + } + + return logFiles; +} + @riverpod class ToolsLogAnalyze extends _$ToolsLogAnalyze { @override - Future> build(String gameInstallPath, bool listSortReverse) async { - final logFile = File("$gameInstallPath/Game.log"); + Future> build( + String gameInstallPath, + bool listSortReverse, { + String? selectedLogFile, + }) async { + // 确定要分析的日志文件 + final String logFilePath; + if (selectedLogFile != null && selectedLogFile.isNotEmpty) { + logFilePath = selectedLogFile; + } else { + logFilePath = "$gameInstallPath/Game.log"; + } + + final logFile = File(logFilePath); debugPrint("[ToolsLogAnalyze] logFile: ${logFile.absolute.path}"); + if (gameInstallPath.isEmpty || !(await logFile.exists())) { return [const LogAnalyzeLineData(type: "error", title: "未找到日志文件")]; } + state = const AsyncData([]); - _launchLogAnalyze(logFile); + _launchLogAnalyze(logFile, selectedLogFile == null); return state.value ?? []; } - void _launchLogAnalyze(File logFile) async { + void _launchLogAnalyze(File logFile, bool enableWatch) async { // 使用新的 GameLogAnalyzer 工具类 final result = await GameLogAnalyzer.analyzeLogFile(logFile); final (results, _) = result; _setResult(results); - _startListenFile(logFile); + // 只有当前 Game.log 才需要监听变化 + if (enableWatch) { + _startListenFile(logFile); + } } // 避免重复调用 @@ -60,7 +116,7 @@ class ToolsLogAnalyze extends _$ToolsLogAnalyze { debugPrint("[ToolsLogAnalyze] logFile change: ${change.type}"); switch (change.type) { case ChangeType.MODIFY: - return _launchLogAnalyze(logFile); + return _launchLogAnalyze(logFile, true); case ChangeType.ADD: case ChangeType.REMOVE: ref.invalidateSelf(); diff --git a/lib/ui/tools/log_analyze_ui/log_analyze_provider.g.dart b/lib/ui/tools/log_analyze_ui/log_analyze_provider.g.dart index 4982d7e..1cee4a5 100644 --- a/lib/ui/tools/log_analyze_ui/log_analyze_provider.g.dart +++ b/lib/ui/tools/log_analyze_ui/log_analyze_provider.g.dart @@ -16,7 +16,7 @@ final class ToolsLogAnalyzeProvider extends $AsyncNotifierProvider> { const ToolsLogAnalyzeProvider._({ required ToolsLogAnalyzeFamily super.from, - required (String, bool) super.argument, + required (String, bool, {String? selectedLogFile}) super.argument, }) : super( retry: null, name: r'toolsLogAnalyzeProvider', @@ -50,7 +50,7 @@ final class ToolsLogAnalyzeProvider } } -String _$toolsLogAnalyzeHash() => r'4c1aea03394e5c5641b2eb40a31d37892bb978bf'; +String _$toolsLogAnalyzeHash() => r'7fa6e068a3ee33fbf1eb0c718035eececd625ece'; final class ToolsLogAnalyzeFamily extends $Family with @@ -59,7 +59,7 @@ final class ToolsLogAnalyzeFamily extends $Family AsyncValue>, List, FutureOr>, - (String, bool) + (String, bool, {String? selectedLogFile}) > { const ToolsLogAnalyzeFamily._() : super( @@ -70,11 +70,18 @@ final class ToolsLogAnalyzeFamily extends $Family isAutoDispose: true, ); - ToolsLogAnalyzeProvider call(String gameInstallPath, bool listSortReverse) => - ToolsLogAnalyzeProvider._( - argument: (gameInstallPath, listSortReverse), - from: this, - ); + ToolsLogAnalyzeProvider call( + String gameInstallPath, + bool listSortReverse, { + String? selectedLogFile, + }) => ToolsLogAnalyzeProvider._( + argument: ( + gameInstallPath, + listSortReverse, + selectedLogFile: selectedLogFile, + ), + from: this, + ); @override String toString() => r'toolsLogAnalyzeProvider'; @@ -82,18 +89,24 @@ final class ToolsLogAnalyzeFamily extends $Family abstract class _$ToolsLogAnalyze extends $AsyncNotifier> { - late final _$args = ref.$arg as (String, bool); + late final _$args = ref.$arg as (String, bool, {String? selectedLogFile}); String get gameInstallPath => _$args.$1; bool get listSortReverse => _$args.$2; + String? get selectedLogFile => _$args.selectedLogFile; FutureOr> build( String gameInstallPath, - bool listSortReverse, - ); + bool listSortReverse, { + String? selectedLogFile, + }); @$mustCallSuper @override void runBuild() { - final created = build(_$args.$1, _$args.$2); + final created = build( + _$args.$1, + _$args.$2, + selectedLogFile: _$args.selectedLogFile, + ); final ref = this.ref as $Ref< diff --git a/lib/ui/tools/log_analyze_ui/log_analyze_ui.dart b/lib/ui/tools/log_analyze_ui/log_analyze_ui.dart index c983dbc..9a493e8 100644 --- a/lib/ui/tools/log_analyze_ui/log_analyze_ui.dart +++ b/lib/ui/tools/log_analyze_ui/log_analyze_ui.dart @@ -16,7 +16,26 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final selectedPath = useState(appState.gameInstallPaths.firstOrNull); final listSortReverse = useState(false); - final provider = toolsLogAnalyzeProvider(selectedPath.value ?? "", listSortReverse.value); + final selectedLogFile = useState(null); // null 表示使用当前 Game.log + final availableLogFiles = useState>([]); + + // 加载可用的日志文件列表 + useEffect(() { + if (selectedPath.value != null) { + getAvailableLogFiles(selectedPath.value!).then((files) { + availableLogFiles.value = files; + // 重置选择为当前日志 + selectedLogFile.value = null; + }); + } + return null; + }, [selectedPath.value]); + + final provider = toolsLogAnalyzeProvider( + selectedPath.value ?? "", + listSortReverse.value, + selectedLogFile: selectedLogFile.value, + ); final logResp = ref.watch(provider); final searchText = useState(""); final searchType = useState(null); @@ -38,12 +57,12 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget { value: selectedPath.value, items: [ for (final path in appState.gameInstallPaths) - ComboBoxItem( - value: path, - child: Text(path), - ), + ComboBoxItem(value: path, child: Text(path)), ], - onChanged: (value) => selectedPath.value = value, + onChanged: (value) { + selectedPath.value = value; + selectedLogFile.value = null; // 重置日志文件选择 + }, placeholder: Text(S.current.log_analyzer_select_game_path), ), ), @@ -55,13 +74,50 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget { child: const Icon(FluentIcons.refresh), ), onPressed: () { + // 重新加载日志文件列表 + if (selectedPath.value != null) { + getAvailableLogFiles(selectedPath.value!).then((files) { + availableLogFiles.value = files; + }); + } ref.invalidate(provider); }, ), ], ), ), - SizedBox(height: 8), + const SizedBox(height: 8), + // 日志文件选择器 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + const Text("日志文件:"), + const SizedBox(width: 10), + Expanded( + child: ComboBox( + isExpanded: true, + value: selectedLogFile.value, + items: [ + for (final logFile in availableLogFiles.value) + ComboBoxItem( + value: logFile.isCurrentLog ? null : logFile.path, + child: Text( + logFile.displayName, + style: logFile.isCurrentLog ? const TextStyle(fontWeight: FontWeight.bold) : null, + ), + ), + ], + onChanged: (value) { + selectedLogFile.value = value; + }, + placeholder: const Text("选择日志文件"), + ), + ), + ], + ), + ), + const SizedBox(height: 8), // 搜索,筛选 Padding( padding: const EdgeInsets.symmetric(horizontal: 14), @@ -70,10 +126,7 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget { // 输入框 Expanded( child: TextFormBox( - prefix: Padding( - padding: const EdgeInsets.only(left: 12), - child: Icon(FluentIcons.search), - ), + prefix: Padding(padding: const EdgeInsets.only(left: 12), child: Icon(FluentIcons.search)), placeholder: S.current.log_analyzer_search_placeholder, onChanged: (value) { searchText.value = value.trim(); @@ -88,10 +141,7 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget { value: searchType.value, placeholder: Text(S.current.log_analyzer_filter_all), items: logAnalyzeSearchTypeMap.entries - .map((e) => ComboBoxItem( - value: e.key, - child: Text(e.value), - )) + .map((e) => ComboBoxItem(value: e.key, child: Text(e.value))) .toList(), onChanged: (value) { searchType.value = value; @@ -103,7 +153,9 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), child: Transform.rotate( - angle: listSortReverse.value ? 3.14 : 0, child: const Icon(FluentIcons.sort_lines)), + angle: listSortReverse.value ? 3.14 : 0, + child: const Icon(FluentIcons.sort_lines), + ), ), onPressed: () { listSortReverse.value = !listSortReverse.value; @@ -116,95 +168,79 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget { Container( margin: EdgeInsets.symmetric(vertical: 12, horizontal: 14), decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Colors.white.withValues(alpha: 0.1), - width: 1, - ), - ), + border: Border(bottom: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1)), ), ), // log analyze result if (!logResp.hasValue) - Expanded( - child: Center( - child: ProgressRing(), - )) + Expanded(child: Center(child: ProgressRing())) else Expanded( - child: ListView.builder( - controller: listCtrl, - itemCount: logResp.value!.length, - padding: const EdgeInsets.symmetric(horizontal: 14), - itemBuilder: (BuildContext context, int index) { - final item = logResp.value![index]; - if (searchText.value.isNotEmpty) { - // 搜索 - if (!item.toString().contains(searchText.value)) { - return const SizedBox.shrink(); + child: ListView.builder( + controller: listCtrl, + itemCount: logResp.value!.length, + padding: const EdgeInsets.symmetric(horizontal: 14), + itemBuilder: (BuildContext context, int index) { + final item = logResp.value![index]; + if (searchText.value.isNotEmpty) { + // 搜索 + if (!item.toString().contains(searchText.value)) { + return const SizedBox.shrink(); + } } - } - if (searchType.value != null) { - if (item.type != searchType.value) { - return const SizedBox.shrink(); + if (searchType.value != null) { + if (item.type != searchType.value) { + return const SizedBox.shrink(); + } } - } - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: SelectionArea( - child: Container( - decoration: BoxDecoration( - color: _getBackgroundColor(item.type), - borderRadius: BorderRadius.circular(8), - ), - padding: EdgeInsets.symmetric( - vertical: 8, - horizontal: 10, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _getIconWidget(item.type), - const SizedBox(width: 10), - Expanded( - child: Text.rich( - TextSpan(children: [ + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SelectionArea( + child: Container( + decoration: BoxDecoration( + color: _getBackgroundColor(item.type), + borderRadius: BorderRadius.circular(8), + ), + padding: EdgeInsets.symmetric(vertical: 8, horizontal: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _getIconWidget(item.type), + const SizedBox(width: 10), + Expanded( + child: Text.rich( TextSpan( - text: item.title, + children: [ + TextSpan(text: item.title), + if (item.dateTime != null) + TextSpan( + text: " (${item.dateTime})", + style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12), + ), + ], ), - if (item.dateTime != null) - TextSpan( - text: " (${item.dateTime})", - style: TextStyle( - color: Colors.white.withValues(alpha: 0.5), - fontSize: 12, - ), - ), - ]), + ), ), - ), - ], - ), - if (item.data != null) - Container( - margin: EdgeInsets.only(top: 8), - child: Text( - item.data!, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.8), - fontSize: 13, - ), - ), + ], ), - ], + if (item.data != null) + Container( + margin: EdgeInsets.only(top: 8), + child: Text( + item.data!, + style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 13), + ), + ), + ], + ), ), ), - ), - ); - }, - )) + ); + }, + ), + ), ], ), ); diff --git a/lib/ui/tools/tools_ui_model.dart b/lib/ui/tools/tools_ui_model.dart index 8d968dc..69532eb 100644 --- a/lib/ui/tools/tools_ui_model.dart +++ b/lib/ui/tools/tools_ui_model.dart @@ -25,6 +25,7 @@ import 'package:xml/xml.dart'; import 'dialogs/hosts_booster_dialog_ui.dart'; import 'dialogs/rsi_launcher_enhance_dialog_ui.dart'; +import 'yearly_report_ui/yearly_report_ui.dart'; part 'tools_ui_model.g.dart'; @@ -80,6 +81,23 @@ class ToolsUIModel extends _$ToolsUIModel { ), ]; + // 2025 年度报告入口 - 2026年1月20日前显示 + final deadline = DateTime(2026, 1, 20); + if (DateTime.now().isBefore(deadline)) { + items.insert( + 0, + ToolsItemData( + "yearly_report", + "2025 年度报告(限时)", + "查看您在2025年的星际公民游玩统计,数据来自本地 log ,请确保在常用电脑上查看。", + const Icon(FontAwesomeIcons.star, size: 22), + onTap: () async { + _openYearlyReport(context); + }, + ), + ); + } + if (!context.mounted) return; items.add(await _addP4kCard(context)); items.addAll([ @@ -747,6 +765,17 @@ class ToolsUIModel extends _$ToolsUIModel { appGlobalState, ); } + + void _openYearlyReport(BuildContext context) { + if (state.scInstallPaths.isEmpty) { + showToast(context, S.current.tools_action_info_valid_game_directory_needed); + return; + } + + Navigator.of( + context, + ).push(FluentPageRoute(builder: (context) => YearlyReportUI(gameInstallPaths: state.scInstallPaths))); + } } /// 图形渲染器切换对话框 diff --git a/lib/ui/tools/tools_ui_model.g.dart b/lib/ui/tools/tools_ui_model.g.dart index 74accce..f0eb719 100644 --- a/lib/ui/tools/tools_ui_model.g.dart +++ b/lib/ui/tools/tools_ui_model.g.dart @@ -41,7 +41,7 @@ final class ToolsUIModelProvider } } -String _$toolsUIModelHash() => r'b0fefd36bd8f1e23fdd6123d487f73d78e40ad06'; +String _$toolsUIModelHash() => r'a801ad7f4ac2a45a2fa6872c1c004b83d09a3dca'; abstract class _$ToolsUIModel extends $Notifier { ToolsUIState build(); diff --git a/lib/ui/tools/yearly_report_ui/yearly_report_ui.dart b/lib/ui/tools/yearly_report_ui/yearly_report_ui.dart new file mode 100644 index 0000000..b3da333 --- /dev/null +++ b/lib/ui/tools/yearly_report_ui/yearly_report_ui.dart @@ -0,0 +1,1415 @@ +import 'package:animate_do/animate_do.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:starcitizen_doctor/common/helper/yearly_report_analyzer.dart'; +import 'package:starcitizen_doctor/widgets/widgets.dart'; + +class YearlyReportUI extends HookConsumerWidget { + final List gameInstallPaths; + + const YearlyReportUI({super.key, required this.gameInstallPaths}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reportData = useState(null); + final isLoading = useState(true); + final currentPage = useState(0); + final loadingProgress = useState(0.0); + final pageController = usePageController(); + + // 加载报告数据 + useEffect(() { + _loadReport(reportData, isLoading, loadingProgress); + return null; + }, const []); + + return makeDefaultPage( + context, + title: "2025 年度报告", + useBodyContainer: true, + content: Column( + children: [ + Expanded( + child: isLoading.value + ? _buildLoadingPage(context, loadingProgress.value) + : reportData.value == null + ? _buildErrorPage(context) + : _buildReportPages(context, reportData.value!, currentPage, pageController), + ), + // 底部提示 + _buildDisclaimer(context), + ], + ), + ); + } + + Future _loadReport( + ValueNotifier reportData, + ValueNotifier isLoading, + ValueNotifier loadingProgress, + ) async { + try { + // 启动假进度条动画,缓慢前进到 90% + bool isGenerating = true; + Future progressAnimation() async { + while (isGenerating && loadingProgress.value < 0.9) { + await Future.delayed(const Duration(milliseconds: 100)); + if (isGenerating) { + // 缓慢增加进度,越接近 90% 越慢 + final remaining = 0.9 - loadingProgress.value; + loadingProgress.value += remaining * 0.02; + } + } + } + + // 同时启动进度动画和实际加载 + final progressFuture = progressAnimation(); + final report = await YearlyReportAnalyzer.generateReport(gameInstallPaths, 2025); + + // 停止假进度动画 + isGenerating = false; + await progressFuture; + + // 快速完成到 100% + while (loadingProgress.value < 1.0) { + await Future.delayed(const Duration(milliseconds: 20)); + loadingProgress.value = (loadingProgress.value + 0.05).clamp(0.0, 1.0); + } + await Future.delayed(const Duration(milliseconds: 300)); + + reportData.value = report; + isLoading.value = false; + } catch (e) { + isLoading.value = false; + } + } + + Widget _buildLoadingPage(BuildContext context, double progress) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 动画图标 + SpinPerfect( + infinite: true, + duration: const Duration(seconds: 3), + child: FadeIn(child: Icon(FontAwesomeIcons.rocket, size: 80, color: FluentTheme.of(context).accentColor)), + ), + const SizedBox(height: 40), + FadeInUp( + delay: const Duration(milliseconds: 300), + child: Text("正在生成您的年度报告...", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 16), + FadeInUp( + delay: const Duration(milliseconds: 500), + child: Text("正在分析游戏日志数据", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .6))), + ), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 700), + child: SizedBox(width: 300, child: ProgressBar(value: progress * 100)), + ), + ], + ), + ); + } + + Widget _buildErrorPage(BuildContext context) { + return Center( + child: FadeInUp( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(FluentIcons.error, size: 80, color: Colors.red), + const SizedBox(height: 24), + Text("无法生成年度报告", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Text("请确保游戏目录正确且存在日志文件", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .6))), + ], + ), + ), + ); + } + + Widget _buildReportPages( + BuildContext context, + YearlyReportData data, + ValueNotifier currentPage, + PageController pageController, + ) { + final pages = _buildPageList(context, data); + + return Stack( + children: [ + // 页面内容 - 上下翻页 + PageView.builder( + scrollDirection: Axis.vertical, + controller: pageController, + onPageChanged: (index) => currentPage.value = index, + itemCount: pages.length, + itemBuilder: (context, index) => pages[index], + ), + // 顶部导航按钮 + if (currentPage.value > 0) + Positioned( + top: 12, + left: 0, + right: 0, + child: Center(child: _makeNavButton(pageController, currentPage.value - 1, true)), + ), + // 底部导航按钮 + if (currentPage.value < pages.length - 1) + Positioned( + bottom: 12, + left: 0, + right: 0, + child: Center(child: _makeNavButton(pageController, currentPage.value + 1, false)), + ), + // 页面指示器 + Positioned( + right: 16, + top: 0, + bottom: 0, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: List.generate( + pages.length, + (index) => GestureDetector( + onTap: () => pageController.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ), + child: Container( + width: 8, + height: currentPage.value == index ? 24 : 8, + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: currentPage.value == index + ? FluentTheme.of(context).accentColor + : Colors.white.withValues(alpha: .3), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + ), + ), + ], + ); + } + + Widget _makeNavButton(PageController pageCtrl, int pageIndex, bool isUp) { + return Bounce( + child: IconButton( + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(isUp ? FluentIcons.chevron_up : FluentIcons.chevron_down, size: 12), + const SizedBox(width: 8), + Text(isUp ? "上一页" : "继续查看"), + ], + ), + onPressed: () => + pageCtrl.animateToPage(pageIndex, duration: const Duration(milliseconds: 300), curve: Curves.ease), + ), + ); + } + + List _buildPageList(BuildContext context, YearlyReportData data) { + return [ + // 欢迎页 + _buildWelcomePage(context), + // 启动次数 + _buildLaunchCountPage(context, data), + // 游玩时长 + _buildPlayTimePage(context, data), + // 游玩时长详情 + _buildSessionStatsPage(context, data), + // 崩溃次数 + _buildCrashCountPage(context, data), + // 击杀统计 (K/D) + _buildKillDeathPage(context, data), + // 最早游玩 + _buildEarliestPlayPage(context, data), + // 最晚游玩 + _buildLatestPlayPage(context, data), + // 炸船统计 + _buildVehicleDestructionPage(context, data), + // 载具驾驶统计 + _buildVehiclePilotedPage(context, data), + // 地点统计 + _buildLocationStatsPage(context, data), + // 账号统计 + _buildAccountStatsPage(context, data), + // 感谢页 + _buildSummaryPage(context, data), + // 数据大总结页 + _buildDataSummaryPage(context, data), + ]; + } + + Widget _buildWelcomePage(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn( + duration: const Duration(milliseconds: 600), + child: Icon(FontAwesomeIcons.star, size: 80, color: Colors.yellow), + ), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 300), + child: Text("2025 年度报告", style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 16), + FadeInUp( + delay: const Duration(milliseconds: 500), + child: Text("回顾您在星际公民中的精彩时刻", style: TextStyle(fontSize: 18, color: Colors.white.withValues(alpha: .8))), + ), + const SizedBox(height: 48), + FadeInUp( + delay: const Duration(milliseconds: 700), + child: Pulse( + infinite: true, + duration: const Duration(seconds: 2), + child: Text("向下滚动或点击下方按钮开始", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .5))), + ), + ), + ], + ), + ); + } + + Widget _buildLaunchCountPage(BuildContext context, YearlyReportData data) { + return _AnimatedStatPage( + icon: FontAwesomeIcons.play, + iconColor: Colors.green, + title: "游戏启动次数", + description: "今年您启动了游戏", + mainValue: "${data.yearlyLaunchCount}", + mainUnit: "次", + secondaryLabel: "累计启动", + secondaryValue: "${data.totalLaunchCount} 次", + ); + } + + Widget _buildPlayTimePage(BuildContext context, YearlyReportData data) { + final yearlyHours = data.yearlyPlayTime.inMinutes / 60; + final totalHours = data.totalPlayTime.inMinutes / 60; + + return _AnimatedStatPage( + icon: FontAwesomeIcons.clock, + iconColor: Colors.blue, + title: "游玩时长", + description: "今年您在宇宙中遨游了", + mainValue: yearlyHours.toStringAsFixed(1), + mainUnit: "小时", + secondaryLabel: "累计游玩", + secondaryValue: "${totalHours.toStringAsFixed(1)} 小时", + ); + } + + Widget _buildCrashCountPage(BuildContext context, YearlyReportData data) { + return _AnimatedStatPage( + icon: FontAwesomeIcons.bug, + iconColor: Colors.orange, + title: "游戏崩溃次数", + description: "今年游戏不太稳定的时刻", + mainValue: "${data.yearlyCrashCount}", + mainUnit: "次", + secondaryLabel: "累计崩溃", + secondaryValue: "${data.totalCrashCount} 次", + extraNote: data.yearlyCrashCount > 10 ? "希望明年能更稳定!" : "运气不错!", + ); + } + + Widget _buildKillDeathPage(BuildContext context, YearlyReportData data) { + final totalKD = data.yearlyKillCount + data.yearlyDeathCount; + final kdRatio = data.yearlyDeathCount > 0 + ? (data.yearlyKillCount / data.yearlyDeathCount).toStringAsFixed(2) + : data.yearlyKillCount > 0 + ? "∞" + : "0.00"; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn(child: Icon(FontAwesomeIcons.crosshairs, size: 64, color: Colors.red)), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 200), + child: Text("击杀统计", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 32), + // K/D 比率大显示 + if (totalKD > 0) + FadeInUp( + delay: const Duration(milliseconds: 400), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text("K/D", style: TextStyle(fontSize: 24, color: Colors.white.withValues(alpha: .6))), + const SizedBox(width: 16), + Text( + kdRatio, + style: TextStyle( + fontSize: 64, + fontWeight: FontWeight.bold, + color: double.tryParse(kdRatio) != null && double.parse(kdRatio) >= 1.0 + ? Colors.green + : Colors.red, + ), + ), + ], + ), + ), + const SizedBox(height: 32), + // 详细数据 + FadeInUp( + delay: const Duration(milliseconds: 600), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Icon(FontAwesomeIcons.skull, size: 24, color: Colors.green), + const SizedBox(height: 8), + Text("击杀", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .6))), + const SizedBox(height: 4), + Text( + "${data.yearlyKillCount}", + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.green), + ), + ], + ), + ), + const SizedBox(width: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Icon(FontAwesomeIcons.skullCrossbones, size: 24, color: Colors.red), + const SizedBox(height: 8), + Text("死亡", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .6))), + const SizedBox(height: 4), + Text( + "${data.yearlyDeathCount}", + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.red), + ), + ], + ), + ), + const SizedBox(width: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Icon(FontAwesomeIcons.personFalling, size: 24, color: Colors.orange), + const SizedBox(height: 8), + Text("自杀", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .6))), + const SizedBox(height: 4), + Text( + "${data.yearlySelfKillCount}", + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.orange), + ), + ], + ), + ), + ], + ), + ), + if (totalKD == 0) + FadeInUp( + delay: const Duration(milliseconds: 400), + child: Text("今年没有检测到击杀/死亡记录", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .6))), + ), + ], + ), + ); + } + + Widget _buildEarliestPlayPage(BuildContext context, YearlyReportData data) { + final time = data.earliestPlayDate; + final timeStr = time != null + ? "${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}" + : "暂无数据"; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn(child: Icon(FontAwesomeIcons.sun, size: 64, color: Colors.orange)), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 200), + child: Text("最早的一次游玩", style: TextStyle(fontSize: 24, color: Colors.white.withValues(alpha: .8))), + ), + const SizedBox(height: 16), + FadeInUp( + delay: const Duration(milliseconds: 400), + child: Text(timeStr, style: TextStyle(fontSize: 72, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 16), + if (time != null) + FadeInUp( + delay: const Duration(milliseconds: 600), + child: Text( + "您在清晨 ${time.month} 月 ${time.day} 日开始了星际之旅", + style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .6)), + ), + ), + ], + ), + ); + } + + Widget _buildLatestPlayPage(BuildContext context, YearlyReportData data) { + final time = data.latestPlayDate; + final timeStr = time != null + ? "${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}" + : "暂无数据"; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn(child: Icon(FontAwesomeIcons.moon, size: 64, color: Colors.purple)), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 200), + child: Text("最晚的一次游玩", style: TextStyle(fontSize: 24, color: Colors.white.withValues(alpha: .8))), + ), + const SizedBox(height: 16), + FadeInUp( + delay: const Duration(milliseconds: 400), + child: Text(timeStr, style: TextStyle(fontSize: 72, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 16), + if (time != null) + FadeInUp( + delay: const Duration(milliseconds: 600), + child: Text( + "深夜 ${time.month} 月 ${time.day} 日还在探索宇宙", + style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .6)), + ), + ), + ], + ), + ); + } + + Widget _buildVehicleDestructionPage(BuildContext context, YearlyReportData data) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn(child: Icon(FontAwesomeIcons.explosion, size: 64, color: Colors.red)), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 200), + child: Text("载具损毁统计", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 24), + FadeInUp( + delay: const Duration(milliseconds: 400), + child: Text("今年您共炸了", style: TextStyle(fontSize: 18, color: Colors.white.withValues(alpha: .7))), + ), + const SizedBox(height: 8), + FadeInUp( + delay: const Duration(milliseconds: 500), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "${data.yearlyVehicleDestructionCount}", + style: TextStyle(fontSize: 64, fontWeight: FontWeight.bold, color: Colors.red), + ), + Padding( + padding: const EdgeInsets.only(bottom: 10, left: 8), + child: Text("艘船", style: TextStyle(fontSize: 24)), + ), + ], + ), + ), + const SizedBox(height: 32), + if (data.mostDestroyedVehicle != null) + FadeInUp( + delay: const Duration(milliseconds: 700), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor.withValues(alpha: .1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Text("炸的最多的船", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .9))), + const SizedBox(height: 8), + Text(data.mostDestroyedVehicle!, style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text( + "炸了 ${data.mostDestroyedVehicleCount} 次", + style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .8)), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildVehiclePilotedPage(BuildContext context, YearlyReportData data) { + final showDetails = useState(false); + final scrollController = useScrollController(); + final sortedVehicles = data.vehiclePilotedDetails.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn(child: Icon(FontAwesomeIcons.shuttleSpace, size: 64, color: Colors.teal)), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 200), + child: Text("载具驾驶统计", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 32), + if (data.mostPilotedVehicle != null) + FadeInUp( + delay: const Duration(milliseconds: 400), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor.withValues(alpha: .1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Text("最常驾驶的载具", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .9))), + const SizedBox(height: 12), + Text(data.mostPilotedVehicle!, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + Text( + "驾驶了 ${data.mostPilotedVehicleCount} 次", + style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .8)), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + // 展开查看全部按钮 + if (sortedVehicles.length > 1) + FadeInUp( + delay: const Duration(milliseconds: 600), + child: Column( + children: [ + IconButton( + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(showDetails.value ? FluentIcons.chevron_up : FluentIcons.chevron_down, size: 12), + const SizedBox(width: 8), + Text(showDetails.value ? "收起详情" : "查看全部 ${sortedVehicles.length} 个载具"), + ], + ), + onPressed: () => showDetails.value = !showDetails.value, + ), + if (showDetails.value) + Container( + margin: const EdgeInsets.only(top: 16), + height: 120, + width: double.infinity, + child: Center( + child: Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + final newOffset = scrollController.offset + event.scrollDelta.dy; + if (newOffset >= scrollController.position.minScrollExtent && + newOffset <= scrollController.position.maxScrollExtent) { + scrollController.jumpTo(newOffset); + } + } + }, + child: SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: sortedVehicles.map((vehicle) { + return Container( + width: 140, + margin: const EdgeInsets.symmetric(horizontal: 6), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor.withValues(alpha: .1), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + vehicle.key, + style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .9)), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Text( + "${vehicle.value}", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + "次", + style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: .6)), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildAccountStatsPage(BuildContext context, YearlyReportData data) { + final showDetails = useState(false); + final scrollController = useScrollController(); + final sortedAccounts = data.accountSessionDetails.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn(child: Icon(FontAwesomeIcons.userAstronaut, size: 64, color: Colors.blue)), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 200), + child: Text("账号统计", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 32), + if (data.mostPlayedAccount != null) + FadeInUp( + delay: const Duration(milliseconds: 400), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor.withValues(alpha: .1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Text("最常使用的账号", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .9))), + const SizedBox(height: 12), + Text(data.mostPlayedAccount!, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + Text( + "登录了 ${data.mostPlayedAccountSessionCount} 次", + style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .8)), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + FadeInUp( + delay: const Duration(milliseconds: 600), + child: Text( + "共检测到 ${data.accountCount} 个账号", + style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .5)), + ), + ), + // 展开查看全部按钮 + if (sortedAccounts.length > 1) + FadeInUp( + delay: const Duration(milliseconds: 700), + child: Column( + children: [ + const SizedBox(height: 16), + IconButton( + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(showDetails.value ? FluentIcons.chevron_up : FluentIcons.chevron_down, size: 12), + const SizedBox(width: 8), + Text(showDetails.value ? "收起详情" : "查看全部账号"), + ], + ), + onPressed: () => showDetails.value = !showDetails.value, + ), + if (showDetails.value) + Container( + margin: const EdgeInsets.only(top: 12), + height: 100, + width: double.infinity, + child: Center( + child: Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + final newOffset = scrollController.offset + event.scrollDelta.dy; + if (newOffset >= scrollController.position.minScrollExtent && + newOffset <= scrollController.position.maxScrollExtent) { + scrollController.jumpTo(newOffset); + } + } + }, + child: SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: sortedAccounts.map((account) { + return Container( + width: 120, + margin: const EdgeInsets.symmetric(horizontal: 6), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor.withValues(alpha: .1), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + account.key, + style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .9)), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Text( + "${account.value}", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + "次", + style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: .6)), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 格式化时长 + String _formatDuration(Duration? duration) { + if (duration == null) return "暂无数据"; + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + if (hours > 0) { + return "$hours 小时 $minutes 分钟"; + } + return "$minutes 分钟"; + } + + Widget _buildSessionStatsPage(BuildContext context, YearlyReportData data) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn(child: Icon(FontAwesomeIcons.stopwatch, size: 64, color: Colors.teal)), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 200), + child: Text("游玩时长详情", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 32), + // 平均时长 + FadeInUp( + delay: const Duration(milliseconds: 400), + child: Container( + padding: const EdgeInsets.all(20), + margin: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor.withValues(alpha: .1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(FontAwesomeIcons.chartLine, size: 20, color: Colors.blue), + const SizedBox(width: 12), + Text("平均每次游玩", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .9))), + ], + ), + const SizedBox(height: 8), + Text( + _formatDuration(data.averageSessionTime), + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + // 最长游玩 + FadeInUp( + delay: const Duration(milliseconds: 600), + child: Container( + padding: const EdgeInsets.all(20), + margin: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor.withValues(alpha: .1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(FontAwesomeIcons.arrowUp, size: 20, color: Colors.green), + const SizedBox(width: 12), + Text("最长一次游玩", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .9))), + ], + ), + const SizedBox(height: 8), + Text( + _formatDuration(data.longestSession), + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.green), + ), + if (data.longestSessionDate != null) + Text( + "${data.longestSessionDate!.month} 月 ${data.longestSessionDate!.day} 日", + style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .7)), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + // 最短游玩 + FadeInUp( + delay: const Duration(milliseconds: 800), + child: Container( + padding: const EdgeInsets.all(20), + margin: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor.withValues(alpha: .1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(FontAwesomeIcons.arrowDown, size: 20, color: Colors.orange), + const SizedBox(width: 12), + Text("最短一次游玩", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .9))), + ], + ), + const SizedBox(height: 8), + Text( + _formatDuration(data.shortestSession), + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.orange), + ), + if (data.shortestSessionDate != null) + Text( + "${data.shortestSessionDate!.month} 月 ${data.shortestSessionDate!.day} 日", + style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .7)), + ), + Text("(仅统计超过 5 分钟的游戏)", style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .6))), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLocationStatsPage(BuildContext context, YearlyReportData data) { + if (data.topLocations.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn(child: Icon(FontAwesomeIcons.locationDot, size: 64, color: Colors.grey)), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 200), + child: Text("地点统计", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 16), + FadeInUp( + delay: const Duration(milliseconds: 400), + child: Text("暂无地点访问记录", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .6))), + ), + ], + ), + ); + } + + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn(child: Icon(FontAwesomeIcons.locationDot, size: 64, color: Colors.red)), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 200), + child: Text("常去的地点", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 16), + FadeInUp( + delay: const Duration(milliseconds: 300), + child: Text("基于库存查看记录统计", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .6))), + ), + const SizedBox(height: 24), + // Top 地点列表 + ...data.topLocations.asMap().entries.map((entry) { + final index = entry.key; + final location = entry.value; + final isTop3 = index < 3; + + return FadeInUp( + delay: Duration(milliseconds: 400 + index * 100), + child: Container( + width: 350, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: isTop3 + ? FluentTheme.of(context).accentColor.withValues(alpha: .2 - index * 0.05) + : FluentTheme.of(context).cardColor.withValues(alpha: .1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isTop3 ? Colors.yellow.withValues(alpha: 1 - index * 0.3) : Colors.grey, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + "${index + 1}", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: isTop3 ? Colors.black : Colors.white, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text(location.key, style: TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis), + ), + Text( + "${location.value} 次", + style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .6)), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ); + } + + Widget _buildSummaryPage(BuildContext context, YearlyReportData data) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn(child: Icon(FontAwesomeIcons.trophy, size: 80, color: Colors.yellow)), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 300), + child: Text("感谢您的陪伴", style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 16), + FadeInUp( + delay: const Duration(milliseconds: 500), + child: Text( + "2025 年,我们一起在星际公民中\n创造了无数精彩回忆", + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, color: Colors.white.withValues(alpha: .8)), + ), + ), + const SizedBox(height: 32), + FadeInUp( + delay: const Duration(milliseconds: 700), + child: Text("期待 2026 年继续与您相伴!", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .6))), + ), + ], + ), + ); + } + + Widget _buildDataSummaryPage(BuildContext context, YearlyReportData data) { + final yearlyHours = data.yearlyPlayTime.inMinutes / 60; + final kdRatio = data.yearlyDeathCount > 0 + ? (data.yearlyKillCount / data.yearlyDeathCount).toStringAsFixed(2) + : data.yearlyKillCount > 0 + ? "∞" + : "-"; + + // 构建数据项列表 + final dataItems = <_SummaryGridItem>[ + _SummaryGridItem("启动游戏", "${data.yearlyLaunchCount}", "次", FontAwesomeIcons.play, Colors.green), + _SummaryGridItem("游玩时长", yearlyHours.toStringAsFixed(1), "小时", FontAwesomeIcons.clock, Colors.blue), + _SummaryGridItem("游戏崩溃", "${data.yearlyCrashCount}", "次", FontAwesomeIcons.bug, Colors.orange), + _SummaryGridItem("击杀玩家", "${data.yearlyKillCount}", "次", FontAwesomeIcons.crosshairs, Colors.green), + _SummaryGridItem("意外死亡", "${data.yearlyDeathCount}", "次", FontAwesomeIcons.skull, Colors.red), + _SummaryGridItem("KD 比率", kdRatio, "", FontAwesomeIcons.chartLine, Colors.teal), + _SummaryGridItem("载具损毁", "${data.yearlyVehicleDestructionCount}", "次", FontAwesomeIcons.explosion, Colors.red), + if (data.longestSession != null) + _SummaryGridItem( + "最长在线", + (data.longestSession!.inMinutes / 60).toStringAsFixed(1), + "小时", + FontAwesomeIcons.hourglassHalf, + Colors.purple, + ), + if (data.topLocations.isNotEmpty) + _SummaryGridItem( + "最常去", + data.topLocations.first.key.length > 6 + ? "${data.topLocations.first.key.substring(0, 5)}..." + : data.topLocations.first.key, + "", + FontAwesomeIcons.locationDot, + Colors.grey, + ), + if (data.earliestPlayDate != null) + _SummaryGridItem( + "最早时刻", + "${data.earliestPlayDate!.hour.toString().padLeft(2, '0')}:${data.earliestPlayDate!.minute.toString().padLeft(2, '0')}", + "", + FontAwesomeIcons.sun, + Colors.orange, + ), + if (data.latestPlayDate != null) + _SummaryGridItem( + "最晚时刻", + "${data.latestPlayDate!.hour.toString().padLeft(2, '0')}:${data.latestPlayDate!.minute.toString().padLeft(2, '0')}", + "", + FontAwesomeIcons.moon, + Colors.purple, + ), + _SummaryGridItem("重开次数", "${data.yearlySelfKillCount}", "次", FontAwesomeIcons.personFalling, Colors.grey), + ]; + + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + child: Column( + children: [ + // 标题 + FadeInDown( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(FontAwesomeIcons.star, size: 20, color: Colors.yellow), + const SizedBox(width: 12), + Text( + "2025 年度报告", + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white), + ), + const SizedBox(width: 12), + Icon(FontAwesomeIcons.star, size: 20, color: Colors.yellow), + ], + ), + ), + const SizedBox(height: 16), + // 主账号 + if (data.mostPlayedAccount != null) + FadeInUp( + delay: const Duration(milliseconds: 100), + child: Text( + data.mostPlayedAccount!, + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + const SizedBox(height: 20), + // 数据网格 + FadeInUp( + delay: const Duration(milliseconds: 200), + child: Container( + constraints: const BoxConstraints(maxWidth: 400), + child: MasonryGridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + itemCount: dataItems.length, + itemBuilder: (context, index) { + final item = dataItems[index]; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor.withValues(alpha: .1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: .05)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(item.icon, size: 14, color: item.color.withValues(alpha: .8)), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: Text( + item.value, + style: TextStyle( + fontSize: 34, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1.0, + ), + textAlign: TextAlign.center, + ), + ), + if (item.unit.isNotEmpty) ...[ + const SizedBox(width: 2), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + item.unit, + style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: .6)), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + item.label, + style: TextStyle(fontSize: 11, color: Colors.white.withValues(alpha: .5)), + textAlign: TextAlign.center, + ), + ], + ), + ); + }, + ), + ), + ), + const SizedBox(height: 16), + // 最爱载具 + if (data.mostPilotedVehicle != null) + FadeInUp( + delay: const Duration(milliseconds: 400), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor.withValues(alpha: .1), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(FontAwesomeIcons.shuttleSpace, size: 14, color: Colors.teal.withValues(alpha: .7)), + const SizedBox(width: 10), + Text( + "最爱: ${data.mostPilotedVehicle}", + style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: .8)), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + // 底部标识 + FadeInUp( + delay: const Duration(milliseconds: 600), + child: Text( + "SC汉化盒子 · 2025年度报告", + style: TextStyle(fontSize: 11, color: Colors.white.withValues(alpha: .3)), + ), + ), + ], + ), + ), + ); + } + + Widget _buildDisclaimer(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 24), + decoration: BoxDecoration(color: FluentTheme.of(context).cardColor.withValues(alpha: .3)), + child: Text( + "数据使用您的本地日志生成,不会发送到任何第三方。因跨版本 Log 改动较大,数据可能不完整,仅供娱乐。", + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11, color: Colors.white.withValues(alpha: .5)), + ), + ); + } +} + +/// 总结网格项数据 +class _SummaryGridItem { + final String label; + final String value; + final String unit; + final IconData icon; + final Color color; + + const _SummaryGridItem(this.label, this.value, this.unit, this.icon, this.color); +} + +/// 动画统计页面 +class _AnimatedStatPage extends HookWidget { + final IconData icon; + final Color iconColor; + final String title; + final String description; + final String mainValue; + final String mainUnit; + final String secondaryLabel; + final String secondaryValue; + final String? extraNote; + + const _AnimatedStatPage({ + required this.icon, + required this.iconColor, + required this.title, + required this.description, + required this.mainValue, + required this.mainUnit, + required this.secondaryLabel, + required this.secondaryValue, + this.extraNote, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ZoomIn(child: Icon(icon, size: 64, color: iconColor)), + const SizedBox(height: 24), + FadeInUp( + delay: const Duration(milliseconds: 200), + child: Text(title, style: TextStyle(fontSize: 24, color: Colors.white.withValues(alpha: .8))), + ), + const SizedBox(height: 8), + FadeInUp( + delay: const Duration(milliseconds: 300), + child: Text(description, style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .6))), + ), + const SizedBox(height: 24), + FadeInUp( + delay: const Duration(milliseconds: 400), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(mainValue, style: TextStyle(fontSize: 72, fontWeight: FontWeight.bold)), + Padding( + padding: const EdgeInsets.only(bottom: 12, left: 8), + child: Text(mainUnit, style: TextStyle(fontSize: 24, color: Colors.white.withValues(alpha: .7))), + ), + ], + ), + ), + const SizedBox(height: 24), + SlideInRight( + delay: const Duration(milliseconds: 800), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(secondaryLabel, style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .6))), + const SizedBox(width: 12), + Text(secondaryValue, style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ], + ), + ), + ), + if (extraNote != null) ...[ + const SizedBox(height: 16), + FadeInUp( + delay: const Duration(milliseconds: 1000), + child: Text( + extraNote!, + style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .5), fontStyle: FontStyle.italic), + ), + ), + ], + ], + ), + ); + } +}