feat: init YearlyReportUI

This commit is contained in:
xkeyC 2025-12-17 12:26:57 +08:00
parent 9a28257f4a
commit 6ec973144e
12 changed files with 1914 additions and 221 deletions

View File

@ -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]

View File

@ -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<MapEntry<String, int>> topLocations; // Top 访
// (K/D)
final int yearlyKillCount; //
final int yearlyDeathCount; //
final int yearlySelfKillCount; //
// ()
final Map<String, int> vehicleDestructionDetails; //
final Map<String, int> vehiclePilotedDetails; //
final Map<String, int> accountSessionDetails; //
final Map<String, int> 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<String, dynamic> 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<String> playerNames = {};
String? currentPlayerName;
String? firstPlayerName; //
@ -176,9 +190,11 @@ class _LogFileStats {
// : (ID后) ->
Map<String, int> vehiclePiloted = {};
//
List<DateTime> yearlyStartTimes = [];
List<DateTime> yearlyEndTimes = [];
// 访: ->
Map<String, int> 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<YearlyReportData> generateReport(List<String> gameInstallPaths, int targetYear) async {
// Isolate UI
return await Isolate.run(() async {
return await _generateReportInIsolate(gameInstallPaths, targetYear);
});
}
/// Isolate
static Future<YearlyReportData> _generateReportInIsolate(List<String> gameInstallPaths, int targetYear) async {
final List<File> 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<Duration> allSessionDurations = [];
// K/D
int yearlyKillCount = 0;
int yearlyDeathCount = 0;
int yearlySelfKillCount = 0;
final Map<String, int> vehicleDestructionDetails = {};
final Map<String, int> vehiclePilotedDetails = {};
final Map<String, int> accountSessionDetails = {};
final Map<String, int> 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<int>(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,
);
}

View File

@ -41,7 +41,7 @@ final class DcbViewerModelProvider
}
}
String _$dcbViewerModelHash() => r'f0af2a7b4451f746288e2c9565a418af80f58835';
String _$dcbViewerModelHash() => r'dfed59de5291e5a19cc481d0115fe91f5bcaf301';
abstract class _$DcbViewerModel extends $Notifier<DcbViewerState> {
DcbViewerState build();

View File

@ -42,7 +42,7 @@ final class HomeGameLoginUIModelProvider
}
String _$homeGameLoginUIModelHash() =>
r'217a57f797b37f3467be2e7711f220610e9e67d8';
r'd81831f54c6b1e98ea8a1e94b5e6049fe552996f';
abstract class _$HomeGameLoginUIModel extends $Notifier<HomeGameLoginState> {
HomeGameLoginState build();

View File

@ -42,7 +42,7 @@ final class LocalizationUIModelProvider
}
String _$localizationUIModelHash() =>
r'122f9f85da6e112165f4ff88667b45cf3cf3f43e';
r'7b398d3b2ddd306ff8f328be39f28200fe8bf49e';
abstract class _$LocalizationUIModel extends $Notifier<LocalizationUIState> {
LocalizationUIState build();

View File

@ -42,7 +42,7 @@ final class HomePerformanceUIModelProvider
}
String _$homePerformanceUIModelHash() =>
r'4c5c33fe7d85dc8f6bf0d019c1b870d285d594ff';
r'ae4771c7804abb1e5ba89bb1687061612f96b46b';
abstract class _$HomePerformanceUIModel
extends $Notifier<HomePerformanceUIState> {

View File

@ -21,28 +21,84 @@ final Map<String?, String> 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<List<LogFileInfo>> getAvailableLogFiles(String gameInstallPath) async {
final List<LogFileInfo> 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<List<LogAnalyzeLineData>> build(String gameInstallPath, bool listSortReverse) async {
final logFile = File("$gameInstallPath/Game.log");
Future<List<LogAnalyzeLineData>> 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();

View File

@ -16,7 +16,7 @@ final class ToolsLogAnalyzeProvider
extends $AsyncNotifierProvider<ToolsLogAnalyze, List<LogAnalyzeLineData>> {
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<LogAnalyzeLineData>>,
List<LogAnalyzeLineData>,
FutureOr<List<LogAnalyzeLineData>>,
(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<List<LogAnalyzeLineData>> {
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<List<LogAnalyzeLineData>> 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<

View File

@ -16,7 +16,26 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final selectedPath = useState<String?>(appState.gameInstallPaths.firstOrNull);
final listSortReverse = useState<bool>(false);
final provider = toolsLogAnalyzeProvider(selectedPath.value ?? "", listSortReverse.value);
final selectedLogFile = useState<String?>(null); // null 使 Game.log
final availableLogFiles = useState<List<LogFileInfo>>([]);
//
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<String>("");
final searchType = useState<String?>(null);
@ -38,12 +57,12 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
value: selectedPath.value,
items: [
for (final path in appState.gameInstallPaths)
ComboBoxItem<String>(
value: path,
child: Text(path),
),
ComboBoxItem<String>(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<String?>(
isExpanded: true,
value: selectedLogFile.value,
items: [
for (final logFile in availableLogFiles.value)
ComboBoxItem<String?>(
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<String>(
value: e.key,
child: Text(e.value),
))
.map((e) => ComboBoxItem<String>(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),
),
),
],
),
),
),
),
);
},
))
);
},
),
),
],
),
);

View File

@ -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 - 2026120
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)));
}
}
///

View File

@ -41,7 +41,7 @@ final class ToolsUIModelProvider
}
}
String _$toolsUIModelHash() => r'b0fefd36bd8f1e23fdd6123d487f73d78e40ad06';
String _$toolsUIModelHash() => r'a801ad7f4ac2a45a2fa6872c1c004b83d09a3dca';
abstract class _$ToolsUIModel extends $Notifier<ToolsUIState> {
ToolsUIState build();

File diff suppressed because it is too large Load Diff