积分、成就系统

This commit is contained in:
ytc1012
2025-11-27 13:37:10 +08:00
parent 0195cdf54b
commit 58f6ec39b7
35 changed files with 7786 additions and 199 deletions

View File

@@ -0,0 +1,821 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../theme/app_colors.dart';
import '../services/storage_service.dart';
import '../services/points_service.dart';
import '../services/achievement_service.dart';
import '../services/di.dart';
import '../models/user_progress.dart';
import '../models/achievement_config.dart';
/// Profile Screen - Shows user points, level, check-in calendar, and achievements
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
final StorageService _storageService = getIt<StorageService>();
final PointsService _pointsService = getIt<PointsService>();
final AchievementService _achievementService = getIt<AchievementService>();
late UserProgress _progress;
@override
void initState() {
super.initState();
_progress = _storageService.getUserProgress();
}
Future<void> _handleCheckIn() async {
final l10n = AppLocalizations.of(context)!;
if (_progress.hasCheckedInToday) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.alreadyCheckedIn),
duration: const Duration(seconds: 2),
),
);
return;
}
// Process check-in with detailed breakdown
final checkInResult = _pointsService.processCheckIn(_progress);
final pointsEarned = checkInResult['points'] as int;
// Add points
_progress.totalPoints += pointsEarned;
_progress.currentPoints += pointsEarned;
// Check for newly unlocked achievements (streak achievements) asynchronously
final newAchievements = await _achievementService.checkAchievementsAsync(
_progress,
);
// Save progress
await _storageService.saveUserProgress(_progress);
// Update UI
setState(() {});
// Show success message
if (!mounted) return;
String message = l10n.checkInSuccess(pointsEarned);
if (_progress.consecutiveCheckIns % 7 == 0) {
message += '\n${l10n.weeklyStreakBonus}';
}
if (newAchievements.isNotEmpty) {
message += '\n${l10n.newAchievementUnlocked}';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 3),
backgroundColor: AppColors.success,
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
l10n.profile,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
centerTitle: true,
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// User header card
_buildUserHeaderCard(l10n),
const SizedBox(height: 24),
// Check-in calendar
_buildCheckInCalendar(l10n),
const SizedBox(height: 24),
// Achievement wall
_buildAchievementWall(l10n),
const SizedBox(height: 24),
],
),
),
),
);
}
/// Build user header card with points, level, and progress bar
Widget _buildUserHeaderCard(AppLocalizations l10n) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, AppColors.primary.withValues(alpha: 0.8)],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
// User icon (placeholder)
const CircleAvatar(
radius: 40,
backgroundColor: Colors.white,
child: Text('👤', style: TextStyle(fontSize: 40)),
),
const SizedBox(height: 16),
// User name (placeholder)
Text(
l10n.focuser,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 20),
// Points and Level row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Points
Column(
children: [
Row(
children: [
const Text('', style: TextStyle(fontSize: 28)),
const SizedBox(width: 4),
Text(
'${_progress.totalPoints}',
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
Text(
l10n.points,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
color: Colors.white,
),
),
],
),
// Divider
Container(
height: 40,
width: 1,
color: Colors.white.withValues(alpha: 0.3),
),
// Level
Column(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Text('🎖️', style: TextStyle(fontSize: 24)),
const SizedBox(width: 4),
Text(
'Lv ${_progress.level}',
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
),
const SizedBox(height: 4),
Text(
l10n.level,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
color: Colors.white,
),
),
],
),
],
),
const SizedBox(height: 20),
// Level progress bar
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pointsToNextLevel(
_progress.pointsToNextLevel,
_progress.level + 1,
),
style: TextStyle(
fontFamily: 'Nunito',
fontSize: 12,
color: Colors.white.withValues(alpha: 0.9),
),
),
const SizedBox(height: 8),
Stack(
children: [
// Background bar
Container(
height: 10,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(5),
),
),
// Progress bar
FractionallySizedBox(
widthFactor: _progress.levelProgress,
child: Container(
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
),
),
),
],
),
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: Text(
'${(_progress.levelProgress * 100).toInt()}%',
style: TextStyle(
fontFamily: 'Nunito',
fontSize: 12,
color: Colors.white.withValues(alpha: 0.9),
),
),
),
],
),
],
),
);
}
/// Build check-in calendar section
Widget _buildCheckInCalendar(AppLocalizations l10n) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.checkInCalendar,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
Text(
l10n.daysCount(_progress.checkInHistory.length),
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
color: AppColors.textSecondary,
),
),
],
),
const SizedBox(height: 16),
// Check-in button
if (!_progress.hasCheckedInToday)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _handleCheckIn,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.checkInToday,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
if (_progress.hasCheckedInToday)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.checkedInToday,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.success,
),
),
],
),
),
const SizedBox(height: 16),
// Stats row
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem(
l10n.currentStreak,
l10n.daysCount(_progress.consecutiveCheckIns),
),
Container(height: 40, width: 1, color: AppColors.divider),
_buildStatItem(
l10n.longestStreak,
l10n.daysCount(_progress.longestCheckInStreak),
),
],
),
const SizedBox(height: 16),
// Calendar grid (last 28 days)
_buildCalendarGrid(),
],
),
);
}
/// Build calendar grid showing check-in history
Widget _buildCalendarGrid() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
return Column(
children: [
// Weekday labels
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: ['S', 'M', 'T', 'W', 'T', 'F', 'S']
.map(
(day) => SizedBox(
width: 40,
child: Center(
child: Text(
day,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
),
)
.toList(),
),
const SizedBox(height: 8),
// Calendar days (last 4 weeks)
Wrap(
spacing: 4,
runSpacing: 4,
children: List.generate(28, (index) {
final date = today.subtract(Duration(days: 27 - index));
final isCheckedIn = _progress.checkInHistory.any(
(checkInDate) =>
checkInDate.year == date.year &&
checkInDate.month == date.month &&
checkInDate.day == date.day,
);
final isToday = date == today;
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isCheckedIn
? AppColors.primary.withValues(alpha: 0.2)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: isToday
? Border.all(color: AppColors.primary, width: 2)
: null,
),
child: Center(
child: Text(
isCheckedIn ? '' : date.day.toString(),
style: TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
fontWeight: FontWeight.w600,
color: isCheckedIn
? AppColors.primary
: AppColors.textSecondary,
),
),
),
);
}),
),
],
);
}
/// Build a stat item
Widget _buildStatItem(String label, String value) {
return Column(
children: [
Text(
value,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 12,
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
],
);
}
/// Build achievement wall section
Widget _buildAchievementWall(AppLocalizations l10n) {
final allAchievements = AchievementConfig.all;
final unlockedCount = _progress.unlockedAchievements.length;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.achievements,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
Text(
'$unlockedCount/${allAchievements.length}',
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
color: AppColors.textSecondary,
),
),
],
),
const SizedBox(height: 16),
// Achievement list
...allAchievements.take(6).map((achievement) {
final isUnlocked = _progress.unlockedAchievements.containsKey(
achievement.id,
);
final progress = _achievementService.getAchievementProgress(
_progress,
achievement,
);
final currentValue = _achievementService.getAchievementCurrentValue(
_progress,
achievement,
);
return _buildAchievementItem(
l10n: l10n,
achievement: achievement,
isUnlocked: isUnlocked,
progress: progress,
currentValue: currentValue,
);
}),
const SizedBox(height: 16),
// View all button
Center(
child: TextButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.allAchievementsComingSoon),
duration: const Duration(seconds: 2),
),
);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.viewAllAchievements,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_forward, size: 16),
],
),
),
),
],
),
);
}
/// Build a single achievement item
Widget _buildAchievementItem({
required AppLocalizations l10n,
required AchievementConfig achievement,
required bool isUnlocked,
required double progress,
required int currentValue,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isUnlocked
? AppColors.success.withValues(alpha: 0.05)
: AppColors.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isUnlocked
? AppColors.success.withValues(alpha: 0.3)
: AppColors.divider,
width: 1,
),
),
child: Row(
children: [
// Icon
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isUnlocked
? AppColors.success.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
achievement.icon,
style: TextStyle(
fontSize: 24,
color: isUnlocked ? null : Colors.grey,
),
),
),
),
const SizedBox(width: 12),
// Content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getLocalizedAchievementName(l10n, achievement.nameKey),
style: TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
fontWeight: FontWeight.w600,
color: isUnlocked
? AppColors.textPrimary
: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
_getLocalizedAchievementDesc(l10n, achievement.descKey),
style: TextStyle(
fontFamily: 'Nunito',
fontSize: 12,
color: AppColors.textSecondary.withValues(alpha: 0.8),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (!isUnlocked) ...[
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.withValues(alpha: 0.2),
valueColor: const AlwaysStoppedAnimation(
AppColors.primary,
),
),
),
const SizedBox(width: 8),
Text(
'$currentValue/${achievement.requiredValue}',
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 10,
color: AppColors.textSecondary,
),
),
],
),
],
],
),
),
const SizedBox(width: 8),
// Status icon
if (isUnlocked)
const Icon(Icons.check_circle, color: AppColors.success, size: 24)
else
const Icon(
Icons.lock_outline,
color: AppColors.textSecondary,
size: 24,
),
],
),
);
}
/// 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;
}
}
}