feat: YearlyReportUI Update

This commit is contained in:
xkeyC 2025-12-17 16:49:58 +08:00
parent 6ec973144e
commit 70c47a8b85
2 changed files with 842 additions and 327 deletions

View File

@ -45,6 +45,20 @@ class YearlyReportData {
final int yearlyDeathCount; // final int yearlyDeathCount; //
final int yearlySelfKillCount; // final int yearlySelfKillCount; //
//
final int? mostPlayedMonth; // (1-12)
final int mostPlayedMonthCount; //
final int? leastPlayedMonth; // (1-12, )
final int leastPlayedMonthCount; //
// /线
final int longestPlayStreak; //
final DateTime? playStreakStartDate; //
final DateTime? playStreakEndDate; //
final int longestOfflineStreak; // 线
final DateTime? offlineStreakStartDate; // 线
final DateTime? offlineStreakEndDate; // 线
// () // ()
final Map<String, int> vehiclePilotedDetails; // final Map<String, int> vehiclePilotedDetails; //
final Map<String, int> accountSessionDetails; // final Map<String, int> accountSessionDetails; //
@ -77,6 +91,16 @@ class YearlyReportData {
required this.yearlyKillCount, required this.yearlyKillCount,
required this.yearlyDeathCount, required this.yearlyDeathCount,
required this.yearlySelfKillCount, required this.yearlySelfKillCount,
this.mostPlayedMonth,
required this.mostPlayedMonthCount,
this.leastPlayedMonth,
required this.leastPlayedMonthCount,
required this.longestPlayStreak,
this.playStreakStartDate,
this.playStreakEndDate,
required this.longestOfflineStreak,
this.offlineStreakStartDate,
this.offlineStreakEndDate,
required this.vehiclePilotedDetails, required this.vehiclePilotedDetails,
required this.accountSessionDetails, required this.accountSessionDetails,
required this.locationDetails, required this.locationDetails,
@ -140,6 +164,20 @@ class YearlyReportData {
'yearlyDeathCount': yearlyDeathCount, 'yearlyDeathCount': yearlyDeathCount,
'yearlySelfKillCount': yearlySelfKillCount, 'yearlySelfKillCount': yearlySelfKillCount,
//
'mostPlayedMonth': mostPlayedMonth,
'mostPlayedMonthCount': mostPlayedMonthCount,
'leastPlayedMonth': leastPlayedMonth,
'leastPlayedMonthCount': leastPlayedMonthCount,
// /线
'longestPlayStreak': longestPlayStreak,
'playStreakStartDateUtc': _toUtcTimestamp(playStreakStartDate),
'playStreakEndDateUtc': _toUtcTimestamp(playStreakEndDate),
'longestOfflineStreak': longestOfflineStreak,
'offlineStreakStartDateUtc': _toUtcTimestamp(offlineStreakStartDate),
'offlineStreakEndDateUtc': _toUtcTimestamp(offlineStreakEndDate),
// //
'vehiclePilotedDetails': vehiclePilotedDetails, 'vehiclePilotedDetails': vehiclePilotedDetails,
'accountSessionDetails': accountSessionDetails, 'accountSessionDetails': accountSessionDetails,
@ -193,6 +231,9 @@ class _LogFileStats {
// 访: -> // 访: ->
Map<String, int> locationVisits = {}; Map<String, int> locationVisits = {};
// ( 2s )
DateTime? _lastDeathTime;
// //
List<_SessionInfo> yearlySessions = []; List<_SessionInfo> yearlySessions = [];
@ -279,9 +320,17 @@ class YearlyReportAnalyzer {
nameMatch ??= _legacyCharacterNamePattern.firstMatch(line); nameMatch ??= _legacyCharacterNamePattern.firstMatch(line);
if (nameMatch != null) { if (nameMatch != null) {
final playerName = nameMatch.group(1)?.trim(); final playerName = nameMatch.group(1)?.trim();
if (playerName != null && playerName.isNotEmpty && !playerName.contains(' ')) { if (playerName != null &&
playerName.isNotEmpty &&
!playerName.contains(' ') &&
!playerName.contains('/') &&
!playerName.contains(r'\') &&
!playerName.contains('.')) {
stats.currentPlayerName = playerName; stats.currentPlayerName = playerName;
// ()
if (!stats.playerNames.any((n) => n.toLowerCase() == playerName.toLowerCase())) {
stats.playerNames.add(playerName); stats.playerNames.add(playerName);
}
stats.firstPlayerName ??= playerName; stats.firstPlayerName ??= playerName;
} }
} }
@ -318,7 +367,11 @@ class YearlyReportAnalyzer {
if (deathMatch != null) { if (deathMatch != null) {
final victimId = deathMatch.group(1)?.trim(); final victimId = deathMatch.group(1)?.trim();
if (victimId != null && stats.currentPlayerName != null && victimId == stats.currentPlayerName) { if (victimId != null && stats.currentPlayerName != null && victimId == stats.currentPlayerName) {
// (2)
if (stats._lastDeathTime == null || lineTime.difference(stats._lastDeathTime!).abs().inSeconds > 2) {
stats.deathCount++; stats.deathCount++;
stats._lastDeathTime = lineTime;
}
} }
} }
@ -329,17 +382,34 @@ class YearlyReportAnalyzer {
final killerId = legacyDeathMatch.group(3)?.trim(); final killerId = legacyDeathMatch.group(3)?.trim();
if (victimId != null && stats.currentPlayerName != null) { if (victimId != null && stats.currentPlayerName != null) {
bool isRecent =
stats._lastDeathTime != null && lineTime.difference(stats._lastDeathTime!).abs().inSeconds <= 2;
// //
if (victimId == killerId) { if (victimId == killerId) {
if (victimId == stats.currentPlayerName) { if (victimId == stats.currentPlayerName) {
if (isRecent) {
// ()
// deathCount++退 selfKillCount
if (stats.deathCount > 0) stats.deathCount--;
stats.selfKillCount++; stats.selfKillCount++;
//
stats._lastDeathTime = lineTime;
} else {
stats.selfKillCount++;
stats._lastDeathTime = lineTime;
}
} }
} else { } else {
// // ()
if (victimId == stats.currentPlayerName) { if (victimId == stats.currentPlayerName) {
// ()
if (!isRecent) {
stats.deathCount++; stats.deathCount++;
stats._lastDeathTime = lineTime;
} }
// }
// ()
if (killerId == stats.currentPlayerName) { if (killerId == stats.currentPlayerName) {
stats.killCount++; stats.killCount++;
} }
@ -601,6 +671,90 @@ class YearlyReportAnalyzer {
final sortedLocations = locationDetails.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); final sortedLocations = locationDetails.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
final topLocations = sortedLocations.take(10).toList(); final topLocations = sortedLocations.take(10).toList();
//
final Map<int, int> monthlyPlayCount = {};
final Set<DateTime> playDates = {}; // ()
for (final stats in allStats) {
for (final session in stats.yearlySessions) {
final month = session.startTime.month;
monthlyPlayCount[month] = (monthlyPlayCount[month] ?? 0) + 1;
// ()
playDates.add(DateTime(session.startTime.year, session.startTime.month, session.startTime.day));
}
}
int? mostPlayedMonth;
int mostPlayedMonthCount = 0;
int? leastPlayedMonth;
int leastPlayedMonthCount = 0;
if (monthlyPlayCount.isNotEmpty) {
//
for (final entry in monthlyPlayCount.entries) {
if (entry.value > mostPlayedMonthCount) {
mostPlayedMonth = entry.key;
mostPlayedMonthCount = entry.value;
}
}
// ()
leastPlayedMonthCount = monthlyPlayCount.values.first;
for (final entry in monthlyPlayCount.entries) {
if (entry.value <= leastPlayedMonthCount) {
leastPlayedMonth = entry.key;
leastPlayedMonthCount = entry.value;
}
}
}
// 线
int longestPlayStreak = 0;
DateTime? playStreakStartDate;
DateTime? playStreakEndDate;
int longestOfflineStreak = 0;
DateTime? offlineStreakStartDate;
DateTime? offlineStreakEndDate;
if (playDates.isNotEmpty) {
//
final sortedDates = playDates.toList()..sort();
//
int currentStreak = 1;
DateTime streakStart = sortedDates.first;
for (int i = 1; i < sortedDates.length; i++) {
final diff = sortedDates[i].difference(sortedDates[i - 1]).inDays;
if (diff == 1) {
currentStreak++;
} else {
if (currentStreak > longestPlayStreak) {
longestPlayStreak = currentStreak;
playStreakStartDate = streakStart;
playStreakEndDate = sortedDates[i - 1];
}
currentStreak = 1;
streakStart = sortedDates[i];
}
}
//
if (currentStreak > longestPlayStreak) {
longestPlayStreak = currentStreak;
playStreakStartDate = streakStart;
playStreakEndDate = sortedDates.last;
}
// 线 ()
for (int i = 1; i < sortedDates.length; i++) {
final gapDays = sortedDates[i].difference(sortedDates[i - 1]).inDays - 1;
if (gapDays > longestOfflineStreak) {
longestOfflineStreak = gapDays;
offlineStreakStartDate = sortedDates[i - 1].add(const Duration(days: 1));
offlineStreakEndDate = sortedDates[i].subtract(const Duration(days: 1));
}
}
}
return YearlyReportData( return YearlyReportData(
totalLaunchCount: totalLaunchCount, totalLaunchCount: totalLaunchCount,
totalPlayTime: totalPlayTime, totalPlayTime: totalPlayTime,
@ -628,6 +782,16 @@ class YearlyReportAnalyzer {
yearlyKillCount: yearlyKillCount, yearlyKillCount: yearlyKillCount,
yearlyDeathCount: yearlyDeathCount, yearlyDeathCount: yearlyDeathCount,
yearlySelfKillCount: yearlySelfKillCount, yearlySelfKillCount: yearlySelfKillCount,
mostPlayedMonth: mostPlayedMonth,
mostPlayedMonthCount: mostPlayedMonthCount,
leastPlayedMonth: leastPlayedMonth,
leastPlayedMonthCount: leastPlayedMonthCount,
longestPlayStreak: longestPlayStreak,
playStreakStartDate: playStreakStartDate,
playStreakEndDate: playStreakEndDate,
longestOfflineStreak: longestOfflineStreak,
offlineStreakStartDate: offlineStreakStartDate,
offlineStreakEndDate: offlineStreakEndDate,
vehiclePilotedDetails: vehiclePilotedDetails, vehiclePilotedDetails: vehiclePilotedDetails,
accountSessionDetails: accountSessionDetails, accountSessionDetails: accountSessionDetails,
locationDetails: locationDetails, locationDetails: locationDetails,

View File

@ -29,7 +29,7 @@ class YearlyReportUI extends HookConsumerWidget {
return makeDefaultPage( return makeDefaultPage(
context, context,
title: "2025 年度报告", title: "星际公民 2025 年度报告",
useBodyContainer: true, useBodyContainer: true,
content: Column( content: Column(
children: [ children: [
@ -233,6 +233,10 @@ class YearlyReportUI extends HookConsumerWidget {
_buildPlayTimePage(context, data), _buildPlayTimePage(context, data),
// //
_buildSessionStatsPage(context, data), _buildSessionStatsPage(context, data),
//
_buildMonthlyStatsPage(context, data),
// /线
_buildStreakStatsPage(context, data),
// //
_buildCrashCountPage(context, data), _buildCrashCountPage(context, data),
// (K/D) // (K/D)
@ -375,13 +379,15 @@ class YearlyReportUI extends HookConsumerWidget {
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
// // -
FadeInUp( FadeInUp(
delay: const Duration(milliseconds: 600), delay: const Duration(milliseconds: 600),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( SizedBox(
width: 100,
child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor, color: FluentTheme.of(context).cardColor,
@ -400,8 +406,11 @@ class YearlyReportUI extends HookConsumerWidget {
], ],
), ),
), ),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Container( SizedBox(
width: 100,
child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor, color: FluentTheme.of(context).cardColor,
@ -420,8 +429,11 @@ class YearlyReportUI extends HookConsumerWidget {
], ],
), ),
), ),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Container( SizedBox(
width: 100,
child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor, color: FluentTheme.of(context).cardColor,
@ -440,6 +452,7 @@ class YearlyReportUI extends HookConsumerWidget {
], ],
), ),
), ),
),
], ],
), ),
), ),
@ -653,15 +666,22 @@ class YearlyReportUI extends HookConsumerWidget {
onPointerSignal: (event) { onPointerSignal: (event) {
if (event is PointerScrollEvent) { if (event is PointerScrollEvent) {
final newOffset = scrollController.offset + event.scrollDelta.dy; final newOffset = scrollController.offset + event.scrollDelta.dy;
if (newOffset >= scrollController.position.minScrollExtent && final canScroll =
newOffset <= scrollController.position.maxScrollExtent) { newOffset >= scrollController.position.minScrollExtent &&
newOffset <= scrollController.position.maxScrollExtent;
if (canScroll) {
scrollController.jumpTo(newOffset); scrollController.jumpTo(newOffset);
} }
//
} }
}, },
behavior: HitTestBehavior.opaque,
child: NotificationListener<ScrollNotification>(
onNotification: (_) => true, //
child: SingleChildScrollView( child: SingleChildScrollView(
controller: scrollController, controller: scrollController,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: sortedVehicles.map((vehicle) { children: sortedVehicles.map((vehicle) {
@ -705,6 +725,7 @@ class YearlyReportUI extends HookConsumerWidget {
), ),
), ),
), ),
),
], ],
), ),
), ),
@ -873,102 +894,111 @@ class YearlyReportUI extends HookConsumerWidget {
child: Text("游玩时长详情", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), child: Text("游玩时长详情", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
// //
FadeInUp( FadeInUp(
delay: const Duration(milliseconds: 400), delay: const Duration(milliseconds: 400),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: IntrinsicHeight(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
//
Flexible(
child: Container( child: Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(16),
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor.withValues(alpha: .1), color: FluentTheme.of(context).cardColor.withValues(alpha: .1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(FontAwesomeIcons.chartLine, size: 20, color: Colors.blue), Icon(FontAwesomeIcons.chartLine, size: 16, color: Colors.blue),
const SizedBox(width: 12), const SizedBox(width: 8),
Text("平均每次游玩", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .9))), Text("平均", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .9))),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_formatDuration(data.averageSessionTime), _formatDuration(data.averageSessionTime),
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
), ),
], ],
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(width: 12),
// //
FadeInUp( Flexible(
delay: const Duration(milliseconds: 600),
child: Container( child: Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(16),
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor.withValues(alpha: .1), color: FluentTheme.of(context).cardColor.withValues(alpha: .1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(FontAwesomeIcons.arrowUp, size: 20, color: Colors.green), Icon(FontAwesomeIcons.arrowUp, size: 16, color: Colors.green),
const SizedBox(width: 12), const SizedBox(width: 8),
Text("最长一次游玩", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .9))), Text("最长", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .9))),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_formatDuration(data.longestSession), _formatDuration(data.longestSession),
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.green), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.green),
textAlign: TextAlign.center,
), ),
if (data.longestSessionDate != null) if (data.longestSessionDate != null)
Text( Text(
"${data.longestSessionDate!.month}${data.longestSessionDate!.day}", "${data.longestSessionDate!.month}${data.longestSessionDate!.day}",
style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .7)), style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .6)),
), ),
], ],
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(width: 12),
// //
FadeInUp( Flexible(
delay: const Duration(milliseconds: 800),
child: Container( child: Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(16),
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor.withValues(alpha: .1), color: FluentTheme.of(context).cardColor.withValues(alpha: .1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(FontAwesomeIcons.arrowDown, size: 20, color: Colors.orange), Icon(FontAwesomeIcons.arrowDown, size: 16, color: Colors.orange),
const SizedBox(width: 12), const SizedBox(width: 8),
Text("最短一次游玩", style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .9))), Text("最短", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .9))),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_formatDuration(data.shortestSession), _formatDuration(data.shortestSession),
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.orange), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.orange),
textAlign: TextAlign.center,
), ),
if (data.shortestSessionDate != null) if (data.shortestSessionDate != null)
Text( Text(
"${data.shortestSessionDate!.month}${data.shortestSessionDate!.day}", "${data.shortestSessionDate!.month}${data.shortestSessionDate!.day}",
style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .7)), style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .6)),
), ),
Text("(仅统计超过 5 分钟的游戏)", style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .6))),
], ],
), ),
), ),
@ -976,10 +1006,233 @@ class YearlyReportUI extends HookConsumerWidget {
], ],
), ),
), ),
),
),
const SizedBox(height: 16),
FadeInUp(
delay: const Duration(milliseconds: 600),
child: Text(
"(最短仅统计超过 5 分钟的游戏)",
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .5)),
),
),
],
),
),
);
}
///
String _getMonthName(int month) {
return "$month月";
}
Widget _buildMonthlyStatsPage(BuildContext context, YearlyReportData data) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ZoomIn(child: Icon(FontAwesomeIcons.calendarDays, 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),
//
FadeInUp(
delay: const Duration(milliseconds: 400),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//
if (data.mostPlayedMonth != null)
Container(
padding: const EdgeInsets.all(20),
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor.withValues(alpha: .1),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(FontAwesomeIcons.fire, size: 18, color: Colors.orange),
const SizedBox(width: 10),
Text("游玩最多", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .9))),
],
),
const SizedBox(height: 12),
Text(
_getMonthName(data.mostPlayedMonth!),
style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.orange),
),
Text(
"启动了 ${data.mostPlayedMonthCount}",
style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .7)),
),
],
),
),
//
if (data.leastPlayedMonth != null && data.leastPlayedMonth != data.mostPlayedMonth)
Container(
padding: const EdgeInsets.all(20),
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor.withValues(alpha: .1),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(FontAwesomeIcons.snowflake, size: 18, color: Colors.teal),
const SizedBox(width: 10),
Text("游玩最少", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .9))),
],
),
const SizedBox(height: 12),
Text(
_getMonthName(data.leastPlayedMonth!),
style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.teal),
),
Text(
"仅启动 ${data.leastPlayedMonthCount}",
style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .7)),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildStreakStatsPage(BuildContext context, YearlyReportData data) {
String formatDateRange(DateTime? start, DateTime? end) {
if (start == null || end == null) return "";
return "${start.month}${start.day}日 - ${end.month}${end.day}";
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ZoomIn(child: Icon(FontAwesomeIcons.fire, 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),
//
FadeInUp(
delay: const Duration(milliseconds: 400),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//
if (data.longestPlayStreak > 0)
Container(
padding: const EdgeInsets.all(20),
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor.withValues(alpha: .1),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(FontAwesomeIcons.gamepad, size: 18, color: Colors.green),
const SizedBox(width: 10),
Text("连续游玩", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .9))),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"${data.longestPlayStreak}",
style: TextStyle(fontSize: 42, fontWeight: FontWeight.bold, color: Colors.green),
),
Padding(
padding: const EdgeInsets.only(bottom: 6, left: 4),
child: Text("", style: TextStyle(fontSize: 18, color: Colors.green)),
),
],
),
if (data.playStreakStartDate != null)
Text(
formatDateRange(data.playStreakStartDate, data.playStreakEndDate),
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .6)),
),
],
),
),
// 线
if (data.longestOfflineStreak > 0)
Container(
padding: const EdgeInsets.all(20),
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor.withValues(alpha: .1),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(FontAwesomeIcons.bed, size: 18, color: Colors.grey),
const SizedBox(width: 10),
Text("连续离线", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .9))),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"${data.longestOfflineStreak}",
style: TextStyle(fontSize: 42, fontWeight: FontWeight.bold, color: Colors.grey),
),
Padding(
padding: const EdgeInsets.only(bottom: 6, left: 4),
child: Text("", style: TextStyle(fontSize: 18, color: Colors.grey)),
),
],
),
if (data.offlineStreakStartDate != null)
Text(
formatDateRange(data.offlineStreakStartDate, data.offlineStreakEndDate),
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .6)),
),
],
),
),
],
),
),
],
),
); );
} }
Widget _buildLocationStatsPage(BuildContext context, YearlyReportData data) { Widget _buildLocationStatsPage(BuildContext context, YearlyReportData data) {
final scrollController = ScrollController();
if (data.topLocations.isEmpty) { if (data.topLocations.isEmpty) {
return Center( return Center(
child: Column( child: Column(
@ -1001,9 +1254,15 @@ class YearlyReportUI extends HookConsumerWidget {
); );
} }
// 3
final locations = data.topLocations;
final rowCount = 3;
final List<List<MapEntry<String, int>>> rows = List.generate(rowCount, (_) => []);
for (int i = 0; i < locations.length; i++) {
rows[i % rowCount].add(locations[i]);
}
return Center( return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -1019,59 +1278,113 @@ class YearlyReportUI extends HookConsumerWidget {
child: Text("基于库存查看记录统计", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .6))), child: Text("基于库存查看记录统计", style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .6))),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Top //
...data.topLocations.asMap().entries.map((entry) { FadeInUp(
final index = entry.key; delay: const Duration(milliseconds: 400),
final location = entry.value; child: SizedBox(
final isTop3 = index < 3; height: 180,
width: double.infinity,
return FadeInUp( child: GestureDetector(
delay: Duration(milliseconds: 400 + index * 100), onVerticalDragUpdate: (_) {}, //
child: Container( child: Listener(
width: 350, onPointerSignal: (event) {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), if (event is PointerScrollEvent) {
margin: const EdgeInsets.symmetric(vertical: 4), //
GestureBinding.instance.pointerSignalResolver.register(event, (event) {
final scrollEvent = event as PointerScrollEvent;
final newOffset = scrollController.offset + scrollEvent.scrollDelta.dy;
final clampedOffset = newOffset.clamp(
scrollController.position.minScrollExtent,
scrollController.position.maxScrollExtent,
);
scrollController.jumpTo(clampedOffset);
});
}
},
child: Center(
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: rows.asMap().entries.map((rowEntry) {
final rowIndex = rowEntry.key;
final rowLocations = rowEntry.value;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: rowLocations.asMap().entries.map((locEntry) {
final actualIndex = locEntry.key * rowCount + rowIndex;
final location = locEntry.value;
final isTop3 = actualIndex < 3;
return Container(
width: 200,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isTop3 color: isTop3
? FluentTheme.of(context).accentColor.withValues(alpha: .2 - index * 0.05) ? FluentTheme.of(context).accentColor.withValues(alpha: .2 - actualIndex * 0.05)
: FluentTheme.of(context).cardColor.withValues(alpha: .1), : FluentTheme.of(context).cardColor.withValues(alpha: .1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
children: [ children: [
Container( Container(
width: 28, width: 22,
height: 28, height: 22,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isTop3 ? Colors.yellow.withValues(alpha: 1 - index * 0.3) : Colors.grey, color: isTop3
? Colors.yellow.withValues(alpha: 1 - actualIndex * 0.3)
: Colors.grey,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Center( child: Center(
child: Text( child: Text(
"${index + 1}", "${actualIndex + 1}",
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isTop3 ? Colors.black : Colors.white, color: isTop3 ? Colors.black : Colors.white,
), ),
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 8),
Expanded( Expanded(
child: Text(location.key, style: TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
location.key,
style: TextStyle(fontSize: 11),
overflow: TextOverflow.ellipsis,
maxLines: 1,
), ),
Text( Text(
"${location.value}", "${location.value}",
style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .6)), style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: .5)),
), ),
], ],
), ),
), ),
],
),
); );
}), }).toList(),
],
), ),
);
}).toList(),
),
),
),
),
),
),
),
],
), ),
); );
} }
@ -1108,21 +1421,36 @@ class YearlyReportUI extends HookConsumerWidget {
Widget _buildDataSummaryPage(BuildContext context, YearlyReportData data) { Widget _buildDataSummaryPage(BuildContext context, YearlyReportData data) {
final yearlyHours = data.yearlyPlayTime.inMinutes / 60; final yearlyHours = data.yearlyPlayTime.inMinutes / 60;
final kdRatio = data.yearlyDeathCount > 0
? (data.yearlyKillCount / data.yearlyDeathCount).toStringAsFixed(2)
: data.yearlyKillCount > 0
? ""
: "-";
// //
final dataItems = <_SummaryGridItem>[ final dataItems = <_SummaryGridItem>[
_SummaryGridItem("启动游戏", "${data.yearlyLaunchCount}", "", FontAwesomeIcons.play, Colors.green), _SummaryGridItem("启动游戏", "${data.yearlyLaunchCount}", "", FontAwesomeIcons.play, Colors.green, isWide: false),
_SummaryGridItem("游玩时长", yearlyHours.toStringAsFixed(1), "小时", FontAwesomeIcons.clock, Colors.blue), _SummaryGridItem(
_SummaryGridItem("游戏崩溃", "${data.yearlyCrashCount}", "", FontAwesomeIcons.bug, Colors.orange), "游玩时长",
_SummaryGridItem("击杀玩家", "${data.yearlyKillCount}", "", FontAwesomeIcons.crosshairs, Colors.green), yearlyHours.toStringAsFixed(1),
_SummaryGridItem("意外死亡", "${data.yearlyDeathCount}", "", FontAwesomeIcons.skull, Colors.red), "小时",
_SummaryGridItem("KD 比率", kdRatio, "", FontAwesomeIcons.chartLine, Colors.teal), FontAwesomeIcons.clock,
_SummaryGridItem("载具损毁", "${data.yearlyVehicleDestructionCount}", "", FontAwesomeIcons.explosion, Colors.red), Colors.blue,
isWide: false,
),
_SummaryGridItem("游戏崩溃", "${data.yearlyCrashCount}", "", FontAwesomeIcons.bug, Colors.orange, isWide: false),
_SummaryGridItem(
"击杀",
"${data.yearlyKillCount}",
"",
FontAwesomeIcons.crosshairs,
Colors.green,
isWide: false,
),
_SummaryGridItem("死亡", "${data.yearlyDeathCount}", "", FontAwesomeIcons.skull, Colors.red, isWide: false),
_SummaryGridItem(
"载具损毁",
"${data.yearlyVehicleDestructionCount}",
"",
FontAwesomeIcons.explosion,
Colors.red,
isWide: false,
),
if (data.longestSession != null) if (data.longestSession != null)
_SummaryGridItem( _SummaryGridItem(
"最长在线", "最长在线",
@ -1130,17 +1458,9 @@ class YearlyReportUI extends HookConsumerWidget {
"小时", "小时",
FontAwesomeIcons.hourglassHalf, FontAwesomeIcons.hourglassHalf,
Colors.purple, Colors.purple,
isWide: false,
), ),
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) if (data.earliestPlayDate != null)
_SummaryGridItem( _SummaryGridItem(
"最早时刻", "最早时刻",
@ -1148,6 +1468,7 @@ class YearlyReportUI extends HookConsumerWidget {
"", "",
FontAwesomeIcons.sun, FontAwesomeIcons.sun,
Colors.orange, Colors.orange,
isWide: false,
), ),
if (data.latestPlayDate != null) if (data.latestPlayDate != null)
_SummaryGridItem( _SummaryGridItem(
@ -1156,8 +1477,57 @@ class YearlyReportUI extends HookConsumerWidget {
"", "",
FontAwesomeIcons.moon, FontAwesomeIcons.moon,
Colors.purple, Colors.purple,
isWide: false,
),
_SummaryGridItem(
"重开次数",
"${data.yearlySelfKillCount}",
"",
FontAwesomeIcons.personFalling,
Colors.grey,
isWide: false,
),
//
if (data.mostPlayedMonth != null)
_SummaryGridItem(
"最热月",
_getMonthName(data.mostPlayedMonth!),
"",
FontAwesomeIcons.fire,
Colors.orange,
isWide: false,
),
// /线
if (data.longestPlayStreak > 0)
_SummaryGridItem(
"连续游玩",
"${data.longestPlayStreak}",
"",
FontAwesomeIcons.gamepad,
Colors.green,
isWide: false,
),
if (data.longestOfflineStreak > 0)
_SummaryGridItem("连续离线", "${data.longestOfflineStreak}", "", FontAwesomeIcons.bed, Colors.grey, isWide: false),
//
if (data.topLocations.isNotEmpty)
_SummaryGridItem(
"常去位置",
data.topLocations.first.key,
"",
FontAwesomeIcons.locationDot,
Colors.red,
isWide: true, // 使
),
if (data.mostPilotedVehicle != null)
_SummaryGridItem(
"最爱载具",
data.mostPilotedVehicle!,
"",
FontAwesomeIcons.shuttleSpace,
Colors.teal,
isWide: true, // 使
), ),
_SummaryGridItem("重开次数", "${data.yearlySelfKillCount}", "", FontAwesomeIcons.personFalling, Colors.grey),
]; ];
return Center( return Center(
@ -1173,7 +1543,7 @@ class YearlyReportUI extends HookConsumerWidget {
Icon(FontAwesomeIcons.star, size: 20, color: Colors.yellow), Icon(FontAwesomeIcons.star, size: 20, color: Colors.yellow),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
"2025 年度报告", "星际公民 2025 年度报告",
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white), style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@ -1195,19 +1565,21 @@ class YearlyReportUI extends HookConsumerWidget {
// //
FadeInUp( FadeInUp(
delay: const Duration(milliseconds: 200), delay: const Duration(milliseconds: 200),
child: Container( child: Padding(
constraints: const BoxConstraints(maxWidth: 400), padding: const EdgeInsets.symmetric(horizontal: 24),
child: MasonryGridView.count( child: MasonryGridView.count(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3, crossAxisCount: 5,
mainAxisSpacing: 8, mainAxisSpacing: 12,
crossAxisSpacing: 8, crossAxisSpacing: 12,
itemCount: dataItems.length, itemCount: dataItems.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = dataItems[index]; final item = dataItems[index];
final isSmallFont = item.isWide; // 使
return Container( return Container(
padding: const EdgeInsets.all(12), height: 150,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor.withValues(alpha: .1), color: FluentTheme.of(context).cardColor.withValues(alpha: .1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -1216,8 +1588,8 @@ class YearlyReportUI extends HookConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(item.icon, size: 14, color: item.color.withValues(alpha: .8)), Icon(item.icon, size: 20, color: item.color.withValues(alpha: .8)),
const SizedBox(height: 8), const SizedBox(height: 10),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
@ -1226,30 +1598,32 @@ class YearlyReportUI extends HookConsumerWidget {
child: Text( child: Text(
item.value, item.value,
style: TextStyle( style: TextStyle(
fontSize: 34, fontSize: isSmallFont ? 16 : 42,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: Colors.white,
height: 1.0, height: 1.0,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: isSmallFont ? 2 : null,
overflow: isSmallFont ? TextOverflow.ellipsis : null,
), ),
), ),
if (item.unit.isNotEmpty) ...[ if (item.unit.isNotEmpty) ...[
const SizedBox(width: 2), const SizedBox(width: 4),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 4),
child: Text( child: Text(
item.unit, item.unit,
style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: .6)), style: TextStyle(fontSize: 16, color: Colors.white.withValues(alpha: .6)),
), ),
), ),
], ],
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 6),
Text( Text(
item.label, item.label,
style: TextStyle(fontSize: 11, color: Colors.white.withValues(alpha: .5)), style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .5)),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@ -1259,37 +1633,13 @@ class YearlyReportUI extends HookConsumerWidget {
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 20),
//
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( FadeInUp(
delay: const Duration(milliseconds: 600), delay: const Duration(milliseconds: 400),
child: Text( child: Text(
"SC汉化盒子 · 2025年度报告", "由 SC 汉化盒子为您呈现",
style: TextStyle(fontSize: 11, color: Colors.white.withValues(alpha: .3)), style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .3)),
), ),
), ),
], ],
@ -1302,11 +1652,11 @@ class YearlyReportUI extends HookConsumerWidget {
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 24), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 24),
decoration: BoxDecoration(color: FluentTheme.of(context).cardColor.withValues(alpha: .3)), decoration: BoxDecoration(color: FluentTheme.of(context).cardColor.withValues(alpha: .15)),
child: Text( child: Text(
"数据使用您的本地日志生成,不会发送到任何第三方。因跨版本 Log 改动较大,数据可能不完整,仅供娱乐。", "数据使用您的本地日志生成,不会发送到任何第三方。因跨版本 Log 改动较大,数据可能不完整,仅供娱乐。",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: Colors.white.withValues(alpha: .5)), style: TextStyle(fontSize: 11, color: Colors.white.withValues(alpha: .7)),
), ),
); );
} }
@ -1319,8 +1669,9 @@ class _SummaryGridItem {
final String unit; final String unit;
final IconData icon; final IconData icon;
final Color color; final Color color;
final bool isWide;
const _SummaryGridItem(this.label, this.value, this.unit, this.icon, this.color); const _SummaryGridItem(this.label, this.value, this.unit, this.icon, this.color, {this.isWide = false});
} }
/// ///