积分、成就系统
This commit is contained in:
111
lib/services/achievement_service.dart
Normal file
111
lib/services/achievement_service.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import '../models/user_progress.dart';
|
||||
import '../models/achievement_config.dart';
|
||||
|
||||
/// Service for managing achievements
|
||||
class AchievementService {
|
||||
/// Check for newly unlocked achievements asynchronously
|
||||
/// Returns list of newly unlocked achievement IDs
|
||||
Future<List<String>> checkAchievementsAsync(UserProgress progress) async {
|
||||
List<String> newlyUnlocked = [];
|
||||
|
||||
for (var achievement in AchievementConfig.all) {
|
||||
// Skip if already unlocked
|
||||
if (progress.unlockedAchievements.containsKey(achievement.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if requirement is met
|
||||
bool unlocked = false;
|
||||
switch (achievement.type) {
|
||||
case AchievementType.sessionCount:
|
||||
unlocked = progress.totalSessions >= achievement.requiredValue;
|
||||
break;
|
||||
case AchievementType.distractionCount:
|
||||
unlocked = progress.totalDistractions >= achievement.requiredValue;
|
||||
break;
|
||||
case AchievementType.totalMinutes:
|
||||
unlocked = progress.totalFocusMinutes >= achievement.requiredValue;
|
||||
break;
|
||||
case AchievementType.consecutiveDays:
|
||||
unlocked = progress.consecutiveCheckIns >= achievement.requiredValue;
|
||||
break;
|
||||
}
|
||||
|
||||
if (unlocked) {
|
||||
// Mark as unlocked with timestamp
|
||||
progress.unlockedAchievements[achievement.id] = DateTime.now();
|
||||
|
||||
// Award bonus points
|
||||
progress.totalPoints += achievement.bonusPoints;
|
||||
progress.currentPoints += achievement.bonusPoints;
|
||||
|
||||
newlyUnlocked.add(achievement.id);
|
||||
}
|
||||
}
|
||||
|
||||
return newlyUnlocked;
|
||||
}
|
||||
|
||||
/// Get progress towards a specific achievement (0.0 - 1.0)
|
||||
double getAchievementProgress(
|
||||
UserProgress progress,
|
||||
AchievementConfig achievement,
|
||||
) {
|
||||
int currentValue = 0;
|
||||
|
||||
switch (achievement.type) {
|
||||
case AchievementType.sessionCount:
|
||||
currentValue = progress.totalSessions;
|
||||
break;
|
||||
case AchievementType.distractionCount:
|
||||
currentValue = progress.totalDistractions;
|
||||
break;
|
||||
case AchievementType.totalMinutes:
|
||||
currentValue = progress.totalFocusMinutes;
|
||||
break;
|
||||
case AchievementType.consecutiveDays:
|
||||
currentValue = progress.consecutiveCheckIns;
|
||||
break;
|
||||
}
|
||||
|
||||
return (currentValue / achievement.requiredValue).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Get current value for achievement type
|
||||
int getAchievementCurrentValue(
|
||||
UserProgress progress,
|
||||
AchievementConfig achievement,
|
||||
) {
|
||||
switch (achievement.type) {
|
||||
case AchievementType.sessionCount:
|
||||
return progress.totalSessions;
|
||||
case AchievementType.distractionCount:
|
||||
return progress.totalDistractions;
|
||||
case AchievementType.totalMinutes:
|
||||
return progress.totalFocusMinutes;
|
||||
case AchievementType.consecutiveDays:
|
||||
return progress.consecutiveCheckIns;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all achievements with their current progress
|
||||
Map<AchievementConfig, double> getAllAchievementsWithProgress(
|
||||
UserProgress progress,
|
||||
) {
|
||||
final result = <AchievementConfig, double>{};
|
||||
for (var achievement in AchievementConfig.all) {
|
||||
result[achievement] = getAchievementProgress(progress, achievement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Get newly unlocked achievements since last check
|
||||
/// This can be used to show notifications for achievements unlocked in background
|
||||
List<String> getNewlyUnlockedAchievements(
|
||||
UserProgress progress,
|
||||
Set<String> previouslyUnlocked,
|
||||
) {
|
||||
final currentlyUnlocked = progress.unlockedAchievements.keys.toSet();
|
||||
return currentlyUnlocked.difference(previouslyUnlocked).toList();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'storage_service.dart';
|
||||
import 'notification_service.dart';
|
||||
import 'encouragement_service.dart';
|
||||
import 'points_service.dart';
|
||||
import 'achievement_service.dart';
|
||||
|
||||
/// GetIt instance for dependency injection
|
||||
final getIt = GetIt.instance;
|
||||
@@ -31,6 +33,10 @@ Future<void> initializeDI() async {
|
||||
return service;
|
||||
});
|
||||
|
||||
// Register synchronous services
|
||||
getIt.registerSingleton<PointsService>(PointsService());
|
||||
getIt.registerSingleton<AchievementService>(AchievementService());
|
||||
|
||||
// Wait for all services to be initialized
|
||||
await getIt.allReady();
|
||||
|
||||
|
||||
160
lib/services/points_service.dart
Normal file
160
lib/services/points_service.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'dart:math';
|
||||
import '../models/focus_session.dart';
|
||||
import '../models/user_progress.dart';
|
||||
|
||||
/// Service for calculating and managing points
|
||||
class PointsService {
|
||||
/// Calculate points earned from a focus session
|
||||
/// Returns a map with breakdown: {basePoints, honestyBonus, total, breakdown}
|
||||
Map<String, dynamic> calculateSessionPoints(FocusSession session) {
|
||||
// Base points = actual minutes focused
|
||||
int basePoints = session.actualMinutes;
|
||||
|
||||
// Honesty bonus: reward for recording distractions (with cap to prevent abuse)
|
||||
int honestyBonus = _calculateHonestyBonus(
|
||||
session.distractionCount,
|
||||
session.actualMinutes,
|
||||
);
|
||||
|
||||
int total = basePoints + honestyBonus;
|
||||
|
||||
// Detailed breakdown for UI display
|
||||
List<Map<String, dynamic>> breakdown = [
|
||||
{
|
||||
'label': '专注时长',
|
||||
'value': basePoints,
|
||||
'description': '每专注1分钟获得1积分',
|
||||
},
|
||||
{
|
||||
'label': '诚实奖励',
|
||||
'value': honestyBonus,
|
||||
'description': '记录分心情况获得额外积分',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
'basePoints': basePoints,
|
||||
'honestyBonus': honestyBonus,
|
||||
'total': total,
|
||||
'breakdown': breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/// Calculate honesty bonus with anti-abuse cap
|
||||
/// Strategy: Max 1 rewarded distraction per 10 minutes
|
||||
int _calculateHonestyBonus(int distractionCount, int minutes) {
|
||||
if (distractionCount == 0) return 0;
|
||||
|
||||
// Cap: 1 rewarded distraction per 10 minutes
|
||||
// 15 min → max 2 distractions
|
||||
// 25 min → max 3 distractions
|
||||
// 45 min → max 5 distractions
|
||||
int maxBonusDistraction = max(1, (minutes / 10).ceil());
|
||||
int rewardedCount = min(distractionCount, maxBonusDistraction);
|
||||
|
||||
return rewardedCount; // 1 point per recorded distraction (up to cap)
|
||||
}
|
||||
|
||||
/// Process daily check-in and return points earned with detailed breakdown
|
||||
Map<String, dynamic> processCheckIn(UserProgress progress) {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Base check-in points
|
||||
int points = 5;
|
||||
List<Map<String, dynamic>> breakdown = [
|
||||
{
|
||||
'label': '每日签到',
|
||||
'value': 5,
|
||||
'description': '每日首次签到获得基础积分',
|
||||
},
|
||||
];
|
||||
|
||||
// Update check-in streak
|
||||
if (_isConsecutiveDay(progress.lastCheckInDate, now)) {
|
||||
progress.consecutiveCheckIns++;
|
||||
|
||||
// Bonus for streak milestones
|
||||
if (progress.consecutiveCheckIns % 7 == 0) {
|
||||
int weeklyBonus = 30;
|
||||
points += weeklyBonus;
|
||||
breakdown.add({
|
||||
'label': '连续签到奖励',
|
||||
'value': weeklyBonus,
|
||||
'description': '连续签到${progress.consecutiveCheckIns}天',
|
||||
});
|
||||
} else if (progress.consecutiveCheckIns % 30 == 0) {
|
||||
int monthlyBonus = 100;
|
||||
points += monthlyBonus;
|
||||
breakdown.add({
|
||||
'label': '连续签到奖励',
|
||||
'value': monthlyBonus,
|
||||
'description': '连续签到${progress.consecutiveCheckIns}天',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
progress.consecutiveCheckIns = 1;
|
||||
}
|
||||
|
||||
// Update last check-in date
|
||||
progress.lastCheckInDate = now;
|
||||
|
||||
// Add to check-in history (store date only, not time)
|
||||
final dateOnly = DateTime(now.year, now.month, now.day);
|
||||
if (!progress.checkInHistory.any((date) =>
|
||||
date.year == dateOnly.year &&
|
||||
date.month == dateOnly.month &&
|
||||
date.day == dateOnly.day)) {
|
||||
progress.checkInHistory.add(dateOnly);
|
||||
}
|
||||
|
||||
return {
|
||||
'points': points,
|
||||
'consecutiveDays': progress.consecutiveCheckIns,
|
||||
'breakdown': breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if two dates are consecutive days
|
||||
bool _isConsecutiveDay(DateTime? lastDate, DateTime currentDate) {
|
||||
if (lastDate == null) return false;
|
||||
|
||||
final lastDateOnly = DateTime(lastDate.year, lastDate.month, lastDate.day);
|
||||
final currentDateOnly =
|
||||
DateTime(currentDate.year, currentDate.month, currentDate.day);
|
||||
|
||||
final diff = currentDateOnly.difference(lastDateOnly).inDays;
|
||||
return diff == 1;
|
||||
}
|
||||
|
||||
/// Calculate level based on total points
|
||||
Map<String, dynamic> calculateLevel(int totalPoints) {
|
||||
// Simple level calculation: each level requires 100 points
|
||||
int level = (totalPoints / 100).floor() + 1;
|
||||
int pointsForCurrentLevel = (level - 1) * 100;
|
||||
int pointsForNextLevel = level * 100;
|
||||
int pointsInCurrentLevel = totalPoints - pointsForCurrentLevel;
|
||||
double progress = pointsInCurrentLevel / 100;
|
||||
|
||||
return {
|
||||
'level': level,
|
||||
'pointsForCurrentLevel': pointsForCurrentLevel,
|
||||
'pointsForNextLevel': pointsForNextLevel,
|
||||
'pointsInCurrentLevel': pointsInCurrentLevel,
|
||||
'progress': progress,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get points balance summary
|
||||
Map<String, dynamic> getPointsSummary(UserProgress progress) {
|
||||
final levelInfo = calculateLevel(progress.totalPoints);
|
||||
|
||||
return {
|
||||
'currentPoints': progress.currentPoints,
|
||||
'totalPoints': progress.totalPoints,
|
||||
'level': levelInfo['level'],
|
||||
'levelProgress': levelInfo['progress'],
|
||||
'consecutiveCheckIns': progress.consecutiveCheckIns,
|
||||
'totalCheckIns': progress.checkInHistory.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'storage_service.dart';
|
||||
import 'notification_service.dart';
|
||||
import 'encouragement_service.dart';
|
||||
import 'points_service.dart';
|
||||
import 'achievement_service.dart';
|
||||
|
||||
/// Service Locator - 统一管理所有服务实例
|
||||
class ServiceLocator {
|
||||
@@ -13,6 +15,8 @@ class ServiceLocator {
|
||||
late StorageService _storageService;
|
||||
late NotificationService _notificationService;
|
||||
late EncouragementService _encouragementService;
|
||||
late PointsService _pointsService;
|
||||
late AchievementService _achievementService;
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 初始化所有服务
|
||||
@@ -33,6 +37,12 @@ class ServiceLocator {
|
||||
_encouragementService = EncouragementService();
|
||||
await _encouragementService.loadMessages();
|
||||
|
||||
// 初始化积分服务
|
||||
_pointsService = PointsService();
|
||||
|
||||
// 初始化成就服务
|
||||
_achievementService = AchievementService();
|
||||
|
||||
_isInitialized = true;
|
||||
if (kDebugMode) {
|
||||
print('ServiceLocator initialized successfully');
|
||||
@@ -63,6 +73,18 @@ class ServiceLocator {
|
||||
return _encouragementService;
|
||||
}
|
||||
|
||||
/// 获取积分服务实例
|
||||
PointsService get pointsService {
|
||||
_checkInitialized();
|
||||
return _pointsService;
|
||||
}
|
||||
|
||||
/// 获取成就服务实例
|
||||
AchievementService get achievementService {
|
||||
_checkInitialized();
|
||||
return _achievementService;
|
||||
}
|
||||
|
||||
/// 检查服务是否已初始化
|
||||
void _checkInitialized() {
|
||||
if (!_isInitialized) {
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/focus_session.dart';
|
||||
import '../models/user_progress.dart';
|
||||
|
||||
/// Service to manage local storage using Hive
|
||||
class StorageService {
|
||||
static const String _focusSessionBox = 'focus_sessions';
|
||||
|
||||
static const String _userProgressBox = 'user_progress';
|
||||
static const String _progressKey = 'user_progress_key';
|
||||
|
||||
// Cache for today's sessions to improve performance
|
||||
List<FocusSession>? _todaySessionsCache;
|
||||
DateTime? _cacheDate;
|
||||
|
||||
// Cache for user progress
|
||||
UserProgress? _userProgressCache;
|
||||
|
||||
/// Initialize Hive storage service
|
||||
///
|
||||
///
|
||||
/// This method initializes Hive, registers adapters, and opens the focus sessions box.
|
||||
/// It should be called once during app initialization.
|
||||
Future<void> init() async {
|
||||
@@ -20,10 +26,12 @@ class StorageService {
|
||||
|
||||
// Register adapters
|
||||
Hive.registerAdapter(FocusSessionAdapter());
|
||||
Hive.registerAdapter(UserProgressAdapter());
|
||||
|
||||
// Open boxes
|
||||
await Hive.openBox<FocusSession>(_focusSessionBox);
|
||||
|
||||
await Hive.openBox<UserProgress>(_userProgressBox);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('StorageService initialized successfully');
|
||||
}
|
||||
@@ -37,13 +45,92 @@ class StorageService {
|
||||
|
||||
/// Get the focus sessions box
|
||||
Box<FocusSession> get _sessionsBox => Hive.box<FocusSession>(_focusSessionBox);
|
||||
|
||||
|
||||
/// Get the user progress box
|
||||
Box<UserProgress> get _progressBox => Hive.box<UserProgress>(_userProgressBox);
|
||||
|
||||
/// Invalidate the cache when data changes
|
||||
void _invalidateCache() {
|
||||
_todaySessionsCache = null;
|
||||
_cacheDate = null;
|
||||
}
|
||||
|
||||
/// Invalidate user progress cache
|
||||
void _invalidateProgressCache() {
|
||||
_userProgressCache = null;
|
||||
}
|
||||
|
||||
// ==================== User Progress Methods ====================
|
||||
|
||||
/// Get user progress (creates new one if doesn't exist)
|
||||
UserProgress getUserProgress() {
|
||||
try {
|
||||
// Return cached progress if available
|
||||
if (_userProgressCache != null) {
|
||||
return _userProgressCache!;
|
||||
}
|
||||
|
||||
// Try to get from box
|
||||
var progress = _progressBox.get(_progressKey);
|
||||
|
||||
// Create new progress if doesn't exist
|
||||
if (progress == null) {
|
||||
progress = UserProgress();
|
||||
_progressBox.put(_progressKey, progress);
|
||||
}
|
||||
|
||||
// Cache and return
|
||||
_userProgressCache = progress;
|
||||
return progress;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to get user progress: $e');
|
||||
}
|
||||
// Return new progress as fallback
|
||||
return UserProgress();
|
||||
}
|
||||
}
|
||||
|
||||
/// Save user progress
|
||||
Future<void> saveUserProgress(UserProgress progress) async {
|
||||
try {
|
||||
await _progressBox.put(_progressKey, progress);
|
||||
_userProgressCache = progress; // Update cache
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to save user progress: $e');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update user progress with a function
|
||||
Future<void> updateUserProgress(Function(UserProgress) updateFn) async {
|
||||
try {
|
||||
final progress = getUserProgress();
|
||||
updateFn(progress);
|
||||
await saveUserProgress(progress);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to update user progress: $e');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear user progress (for testing/reset)
|
||||
Future<void> clearUserProgress() async {
|
||||
try {
|
||||
await _progressBox.delete(_progressKey);
|
||||
_invalidateProgressCache();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to clear user progress: $e');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a focus session to local storage
|
||||
///
|
||||
/// [session] - The focus session to save
|
||||
|
||||
Reference in New Issue
Block a user