积分、成就系统
This commit is contained in:
581
lib/screens/session_detail_screen.dart
Normal file
581
lib/screens/session_detail_screen.dart
Normal file
@@ -0,0 +1,581 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_text_styles.dart';
|
||||
import '../models/focus_session.dart';
|
||||
import '../models/achievement_config.dart';
|
||||
import '../services/points_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/encouragement_service.dart';
|
||||
import '../services/di.dart';
|
||||
|
||||
/// Session Detail Screen - Shows detailed information about a past focus session
|
||||
class SessionDetailScreen extends StatelessWidget {
|
||||
final FocusSession session;
|
||||
|
||||
const SessionDetailScreen({super.key, required this.session});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final pointsService = getIt<PointsService>();
|
||||
final storageService = getIt<StorageService>();
|
||||
final encouragementService = getIt<EncouragementService>();
|
||||
|
||||
// Calculate points for this session
|
||||
final pointsBreakdown = pointsService.calculateSessionPoints(session);
|
||||
final pointsEarned = pointsBreakdown['total'] as int;
|
||||
final basePoints = pointsBreakdown['basePoints'] as int;
|
||||
final honestyBonus = pointsBreakdown['honestyBonus'] as int;
|
||||
|
||||
// Get user progress to show total points
|
||||
final progress = storageService.getUserProgress();
|
||||
final encouragement = encouragementService.getRandomMessage();
|
||||
|
||||
// Find achievements that might have been unlocked during this session
|
||||
final sessionAchievements = _findSessionAchievements(
|
||||
session,
|
||||
storageService,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('会话详情'),
|
||||
backgroundColor: AppColors.background,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Session Date and Time
|
||||
_buildSessionHeader(context, l10n),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Focused Time Section
|
||||
Text(l10n.youFocusedFor, style: AppTextStyles.headline),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.minutesValue(
|
||||
session.actualMinutes,
|
||||
l10n.minutes(session.actualMinutes),
|
||||
),
|
||||
style: AppTextStyles.largeNumber,
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Points Earned Section
|
||||
_buildPointsCard(
|
||||
context,
|
||||
l10n,
|
||||
pointsEarned,
|
||||
basePoints,
|
||||
honestyBonus,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Session Stats Card
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('会话统计', style: AppTextStyles.headline),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildStatRow(
|
||||
icon: '⏱️',
|
||||
label: '计划时长',
|
||||
value: l10n.minutesValue(
|
||||
session.durationMinutes,
|
||||
l10n.minutes(session.durationMinutes),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildStatRow(
|
||||
icon: '✅',
|
||||
label: '实际专注',
|
||||
value: l10n.minutesValue(
|
||||
session.actualMinutes,
|
||||
l10n.minutes(session.actualMinutes),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildStatRow(
|
||||
icon: '🤚',
|
||||
label: '分心次数',
|
||||
value: l10n.distractionsCount(
|
||||
session.distractionCount,
|
||||
l10n.times(session.distractionCount),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildStatRow(
|
||||
icon: '🏁',
|
||||
label: '状态',
|
||||
value: session.completed
|
||||
? l10n.completed
|
||||
: l10n.stoppedEarly,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Text(
|
||||
'"$encouragement"',
|
||||
style: AppTextStyles.encouragementQuote,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Achievements Unlocked Section
|
||||
if (sessionAchievements.isNotEmpty)
|
||||
..._buildAchievementCards(context, l10n, sessionAchievements),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Total Points Display
|
||||
Text(
|
||||
l10n.totalPoints(progress.totalPoints),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Nunito',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build session header with date and time
|
||||
Widget _buildSessionHeader(BuildContext context, AppLocalizations l10n) {
|
||||
final dateStr = session.startTime.toLocal().toString().split(' ')[0];
|
||||
final timeStr = session.startTime
|
||||
.toLocal()
|
||||
.toString()
|
||||
.split(' ')[1]
|
||||
.substring(0, 5);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(dateStr, style: AppTextStyles.headline),
|
||||
const SizedBox(height: 8),
|
||||
Text(timeStr, style: AppTextStyles.largeNumber),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build points earned card
|
||||
Widget _buildPointsCard(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
int pointsEarned,
|
||||
int basePoints,
|
||||
int honestyBonus,
|
||||
) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Main points display
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
l10n.earnedPoints,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Nunito',
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'+$pointsEarned',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Nunito',
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const Text(' ⚡', style: TextStyle(fontSize: 24)),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
Divider(
|
||||
thickness: 1,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.2),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Points breakdown
|
||||
_buildPointRow(l10n.basePoints, '+$basePoints', AppColors.success),
|
||||
if (honestyBonus > 0) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildPointRow(
|
||||
l10n.honestyBonus,
|
||||
'+$honestyBonus',
|
||||
AppColors.success,
|
||||
subtitle: l10n.distractionsRecorded(
|
||||
session.distractionCount,
|
||||
l10n.distractions(session.distractionCount),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a single point row in the breakdown
|
||||
Widget _buildPointRow(
|
||||
String label,
|
||||
String points,
|
||||
Color color, {
|
||||
String? subtitle,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'├─ ',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.4),
|
||||
fontFamily: 'Nunito',
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Nunito',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
points,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Nunito',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (subtitle != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Nunito',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a single stat row
|
||||
Widget _buildStatRow({
|
||||
required String icon,
|
||||
required String label,
|
||||
required String value,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(icon, style: const TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 12),
|
||||
Text(label, style: AppTextStyles.bodyText),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Nunito',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Find achievements that might have been unlocked during this session
|
||||
List<AchievementConfig> _findSessionAchievements(
|
||||
FocusSession session,
|
||||
StorageService storageService,
|
||||
) {
|
||||
final allAchievements = AchievementConfig.all;
|
||||
final unlockedAchievements = storageService
|
||||
.getUserProgress()
|
||||
.unlockedAchievements;
|
||||
final sessionAchievements = <AchievementConfig>[];
|
||||
|
||||
// Get all sessions to determine the state before this one
|
||||
final allSessions = storageService.getAllSessions();
|
||||
final sessionIndex = allSessions.indexOf(session);
|
||||
|
||||
// Calculate stats before this session
|
||||
int sessionsBefore = sessionIndex;
|
||||
int distractionsBefore = allSessions
|
||||
.sublist(0, sessionIndex)
|
||||
.fold(0, (sum, s) => sum + s.distractionCount);
|
||||
int minutesBefore = allSessions
|
||||
.sublist(0, sessionIndex)
|
||||
.fold(0, (sum, s) => sum + s.actualMinutes);
|
||||
|
||||
// Check which achievements might have been unlocked by this session
|
||||
for (final achievement in allAchievements) {
|
||||
// Skip if not unlocked
|
||||
if (!unlockedAchievements.containsKey(achievement.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this session could have unlocked the achievement
|
||||
bool unlockedByThisSession = false;
|
||||
switch (achievement.type) {
|
||||
case AchievementType.sessionCount:
|
||||
unlockedByThisSession =
|
||||
sessionsBefore < achievement.requiredValue &&
|
||||
(sessionsBefore + 1) >= achievement.requiredValue;
|
||||
break;
|
||||
case AchievementType.distractionCount:
|
||||
unlockedByThisSession =
|
||||
distractionsBefore < achievement.requiredValue &&
|
||||
(distractionsBefore + session.distractionCount) >=
|
||||
achievement.requiredValue;
|
||||
break;
|
||||
case AchievementType.totalMinutes:
|
||||
unlockedByThisSession =
|
||||
minutesBefore < achievement.requiredValue &&
|
||||
(minutesBefore + session.actualMinutes) >=
|
||||
achievement.requiredValue;
|
||||
break;
|
||||
case AchievementType.consecutiveDays:
|
||||
// Consecutive days are not directly related to a single session
|
||||
// but rather to check-ins, so we'll skip this type
|
||||
break;
|
||||
}
|
||||
|
||||
if (unlockedByThisSession) {
|
||||
sessionAchievements.add(achievement);
|
||||
}
|
||||
}
|
||||
|
||||
return sessionAchievements;
|
||||
}
|
||||
|
||||
/// Build achievement cards for achievements unlocked in this session
|
||||
List<Widget> _buildAchievementCards(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
List<AchievementConfig> achievements,
|
||||
) {
|
||||
return [
|
||||
Text('解锁的成就', style: AppTextStyles.headline),
|
||||
const SizedBox(height: 16),
|
||||
...achievements.map((achievement) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFFFD700), Color(0xFFFFC107)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.orange.withValues(alpha: 0.4),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(achievement.icon, style: const TextStyle(fontSize: 32)),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'成就解锁!',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Nunito',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_getLocalizedAchievementName(l10n, achievement.nameKey),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Nunito',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_getLocalizedAchievementDesc(l10n, achievement.descKey),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Nunito',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (achievement.bonusPoints > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'+${achievement.bonusPoints} 积分',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Nunito',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/// Get localized achievement name by key
|
||||
String _getLocalizedAchievementName(AppLocalizations l10n, String key) {
|
||||
switch (key) {
|
||||
case 'achievement_first_session_name':
|
||||
return l10n.achievement_first_session_name;
|
||||
case 'achievement_sessions_10_name':
|
||||
return l10n.achievement_sessions_10_name;
|
||||
case 'achievement_sessions_50_name':
|
||||
return l10n.achievement_sessions_50_name;
|
||||
case 'achievement_sessions_100_name':
|
||||
return l10n.achievement_sessions_100_name;
|
||||
case 'achievement_honest_bronze_name':
|
||||
return l10n.achievement_honest_bronze_name;
|
||||
case 'achievement_honest_silver_name':
|
||||
return l10n.achievement_honest_silver_name;
|
||||
case 'achievement_honest_gold_name':
|
||||
return l10n.achievement_honest_gold_name;
|
||||
case 'achievement_marathon_name':
|
||||
return l10n.achievement_marathon_name;
|
||||
case 'achievement_century_name':
|
||||
return l10n.achievement_century_name;
|
||||
case 'achievement_master_name':
|
||||
return l10n.achievement_master_name;
|
||||
case 'achievement_persistence_star_name':
|
||||
return l10n.achievement_persistence_star_name;
|
||||
case 'achievement_monthly_habit_name':
|
||||
return l10n.achievement_monthly_habit_name;
|
||||
case 'achievement_centurion_name':
|
||||
return l10n.achievement_centurion_name;
|
||||
case 'achievement_year_warrior_name':
|
||||
return l10n.achievement_year_warrior_name;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get localized achievement description by key
|
||||
String _getLocalizedAchievementDesc(AppLocalizations l10n, String key) {
|
||||
switch (key) {
|
||||
case 'achievement_first_session_desc':
|
||||
return l10n.achievement_first_session_desc;
|
||||
case 'achievement_sessions_10_desc':
|
||||
return l10n.achievement_sessions_10_desc;
|
||||
case 'achievement_sessions_50_desc':
|
||||
return l10n.achievement_sessions_50_desc;
|
||||
case 'achievement_sessions_100_desc':
|
||||
return l10n.achievement_sessions_100_desc;
|
||||
case 'achievement_honest_bronze_desc':
|
||||
return l10n.achievement_honest_bronze_desc;
|
||||
case 'achievement_honest_silver_desc':
|
||||
return l10n.achievement_honest_silver_desc;
|
||||
case 'achievement_honest_gold_desc':
|
||||
return l10n.achievement_honest_gold_desc;
|
||||
case 'achievement_marathon_desc':
|
||||
return l10n.achievement_marathon_desc;
|
||||
case 'achievement_century_desc':
|
||||
return l10n.achievement_century_desc;
|
||||
case 'achievement_master_desc':
|
||||
return l10n.achievement_master_desc;
|
||||
case 'achievement_persistence_star_desc':
|
||||
return l10n.achievement_persistence_star_desc;
|
||||
case 'achievement_monthly_habit_desc':
|
||||
return l10n.achievement_monthly_habit_desc;
|
||||
case 'achievement_centurion_desc':
|
||||
return l10n.achievement_centurion_desc;
|
||||
case 'achievement_year_warrior_desc':
|
||||
return l10n.achievement_year_warrior_desc;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user