mnemo_cards/mnemo_cards_backend/lib/statistics/achievement_manager.dart
2025-11-16 15:15:21 +03:00

422 lines
13 KiB
Dart

import 'dart:async';
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
/// Service for managing user achievements
///
/// Handles achievement checking, unlocking, and progress tracking.
/// Automatically evaluates user progress against achievement requirements.
@lazySingleton
class AchievementManager {
final Isar _isar;
AchievementManager(this._isar);
/// Get all available achievements with their definitions
List<AchievementDefinition> get allAchievementDefinitions =>
_achievementDefinitions;
/// Check and update achievements for a user
///
/// Evaluates all achievements against current user data and unlocks any
/// newly achieved accomplishments. Returns list of newly unlocked achievements.
Future<List<AchievementDto>> checkAndUnlockAchievements(
int userId,
UserDataModel userData,
) async {
final newlyUnlocked = <AchievementDto>[];
try {
// Get current user achievements
final currentAchievements = await _getUserAchievements(userId);
for (final definition in _achievementDefinitions) {
// Skip if already unlocked
if (currentAchievements
.any((a) => a.id == definition.id && a.isUnlocked)) {
continue;
}
// Check if achievement should be unlocked
final shouldUnlock = await _evaluateAchievement(definition, userData);
if (shouldUnlock) {
final unlockedAchievement = definition.unlock();
newlyUnlocked.add(unlockedAchievement);
// Save to database
await _saveAchievement(userId, unlockedAchievement);
log('Achievement unlocked: ${unlockedAchievement.title} for user $userId');
}
}
} catch (e, s) {
log('Error checking achievements for user $userId: $e\n$s');
}
return newlyUnlocked;
}
/// Get achievement progress for a user
///
/// Returns progress (0.0 to 1.0) for achievements that support progress tracking
Future<Map<String, double>> getAchievementProgress(
int userId,
UserDataModel userData,
) async {
final progress = <String, double>{};
for (final definition in _achievementDefinitions) {
if (definition.supportsProgress) {
final currentProgress = await _calculateProgress(definition, userData);
progress[definition.id] = currentProgress;
}
}
return progress;
}
/// Get all achievements for a user
Future<List<AchievementDto>> getUserAchievements(int userId) async {
return await _getUserAchievements(userId);
}
/// Force unlock an achievement for a user (admin function)
Future<bool> forceUnlockAchievement(
int userId,
String achievementId,
) async {
final definition = _achievementDefinitions.firstWhere(
(def) => def.id == achievementId,
);
final unlockedAchievement = definition.unlock();
await _saveAchievement(userId, unlockedAchievement);
return true;
}
/// Evaluate if an achievement should be unlocked
Future<bool> _evaluateAchievement(
AchievementDefinition definition,
UserDataModel userData,
) async {
return await definition.evaluate(userData);
}
/// Calculate progress for an achievement
Future<double> _calculateProgress(
AchievementDefinition definition,
UserDataModel userData,
) async {
return await definition.calculateProgress?.call(userData) ?? 0.0;
}
/// Get user achievements from database
Future<List<AchievementDto>> _getUserAchievements(int userId) async {
final user = await _isar.userModels.get(userId);
if (user?.userData.value == null) return [];
return user!.userData.value!.achievements
.map((model) => model.toDto())
.toList();
}
/// Save achievement to user data
Future<void> _saveAchievement(int userId, AchievementDto achievement) async {
final user = await _isar.userModels.get(userId);
if (user?.userData.value == null) return;
final userData = user!.userData.value!;
final model = AchievementModel.fromDto(achievement);
// Update achievements list
final existingAchievements = userData.achievements;
final updatedAchievements = [
...existingAchievements.where((a) => a.id != achievement.id),
model,
];
final updatedUserData =
userData.copyWith(achievements: updatedAchievements);
await _isar.writeTxn(() async {
await _isar.userDataModels.put(updatedUserData);
});
}
/// Achievement definitions
static final List<AchievementDefinition> _achievementDefinitions = [
// First Steps
AchievementDefinition(
id: 'first_word',
title: 'First Word',
description: 'Learn your first word',
type: AchievementType.firstWordLearned,
evaluate: (data) => Future.value(data.words.isNotEmpty),
supportsProgress: false,
),
AchievementDefinition(
id: 'first_test',
title: 'First Test',
description: 'Complete your first test',
type: AchievementType.firstTestCompleted,
evaluate: (data) => Future.value(data.testsStatistics.isNotEmpty),
supportsProgress: false,
),
AchievementDefinition(
id: 'first_pack',
title: 'First Pack',
description: 'Complete your first pack',
type: AchievementType.firstPackCompleted,
evaluate: (data) =>
Future.value(data.packProgress.any((p) => p.progress >= 1.0)),
supportsProgress: false,
),
// Streak Achievements
AchievementDefinition(
id: 'streak_3',
title: '3-Day Streak',
description: 'Study for 3 consecutive days',
type: AchievementType.streak3Days,
evaluate: (data) => Future.value(data.currentStreak >= 3),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.currentStreak / 3.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'streak_7',
title: 'Week Warrior',
description: 'Study for 7 consecutive days',
type: AchievementType.streak7Days,
evaluate: (data) => Future.value(data.currentStreak >= 7),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.currentStreak / 7.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'streak_30',
title: 'Monthly Master',
description: 'Study for 30 consecutive days',
type: AchievementType.streak30Days,
evaluate: (data) => Future.value(data.currentStreak >= 30),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.currentStreak / 30.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'streak_100',
title: 'Century Champion',
description: 'Study for 100 consecutive days',
type: AchievementType.streak100Days,
evaluate: (data) => Future.value(data.currentStreak >= 100),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.currentStreak / 100.0).clamp(0.0, 1.0)),
),
// Words Mastery
AchievementDefinition(
id: 'words_10',
title: 'Word Explorer',
description: 'Learn 10 words',
type: AchievementType.words10Learned,
evaluate: (data) => Future.value(data.words.length >= 10),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.words.length / 10.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'words_50',
title: 'Vocabulary Builder',
description: 'Learn 50 words',
type: AchievementType.words50Learned,
evaluate: (data) => Future.value(data.words.length >= 50),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.words.length / 50.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'words_100',
title: 'Language Learner',
description: 'Learn 100 words',
type: AchievementType.words100Learned,
evaluate: (data) => Future.value(data.words.length >= 100),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.words.length / 100.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'words_500',
title: 'Word Master',
description: 'Learn 500 words',
type: AchievementType.words500Learned,
evaluate: (data) => Future.value(data.words.length >= 500),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.words.length / 500.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'words_1000',
title: 'Vocabulary Expert',
description: 'Learn 1000 words',
type: AchievementType.words1000Learned,
evaluate: (data) => Future.value(data.words.length >= 1000),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.words.length / 1000.0).clamp(0.0, 1.0)),
),
// Performance Achievements
AchievementDefinition(
id: 'perfect_test',
title: 'Perfect Score',
description: 'Complete a test with 100% accuracy',
type: AchievementType.perfectTestScore,
evaluate: (data) => Future.value(
data.testsStatistics.any((test) => test.attempts.any((attempt) {
final totalWords = attempt.words.length;
final correctWords = attempt.words
.where((word) => word.correct > word.incorrect)
.length;
return totalWords > 0 && correctWords == totalWords;
})),
),
supportsProgress: false,
),
AchievementDefinition(
id: 'speed_learner',
title: 'Speed Learner',
description: 'Complete a pack in less than 24 hours',
type: AchievementType.speedLearner,
evaluate: (data) => Future.value(
data.packProgress.any((pack) =>
pack.progress >= 1.0 &&
pack.studyTimeMinutes < 24 * 60 // Less than 24 hours
),
),
supportsProgress: false,
),
// Dedication Achievements
AchievementDefinition(
id: 'dedicated_learner',
title: 'Dedicated Learner',
description: 'Study for 100 hours total',
type: AchievementType.dedicatedLearner,
evaluate: (data) => Future.value(data.totalStudyTimeMinutes >= 100 * 60),
supportsProgress: true,
calculateProgress: (data) => Future.value(
(data.totalStudyTimeMinutes / (100 * 60)).clamp(0.0, 1.0)),
),
// Time-based Achievements
AchievementDefinition(
id: 'early_bird',
title: 'Early Bird',
description: 'Study before 6 AM',
type: AchievementType.earlyBird,
evaluate: (data) => Future.value(
data.studyDates.any((date) => date.hour < 6),
),
supportsProgress: false,
),
AchievementDefinition(
id: 'night_owl',
title: 'Night Owl',
description: 'Study after 10 PM',
type: AchievementType.nightOwl,
evaluate: (data) => Future.value(
data.studyDates.any((date) => date.hour >= 22),
),
supportsProgress: false,
),
// Special Achievements
AchievementDefinition(
id: 'consistent_learner',
title: 'Consistent Learner',
description: 'Study every day for a month',
type: AchievementType.consistentLearner,
evaluate: (data) => Future.value(data.longestStreak >= 30),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.longestStreak / 30.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'language_master',
title: 'Language Master',
description: 'Complete 5 packs',
type: AchievementType.languageMaster,
evaluate: (data) => Future.value(
data.packProgress.where((p) => p.progress >= 1.0).length >= 5,
),
supportsProgress: true,
calculateProgress: (data) => Future.value(
(data.packProgress.where((p) => p.progress >= 1.0).length / 5.0)
.clamp(0.0, 1.0),
),
),
];
}
/// Definition of an achievement with evaluation logic
class AchievementDefinition {
final String id;
final String title;
final String description;
final AchievementType type;
final Future<bool> Function(UserDataModel data) evaluate;
final bool supportsProgress;
final Future<double> Function(UserDataModel data)? calculateProgress;
AchievementDefinition({
required this.id,
required this.title,
required this.description,
required this.type,
required this.evaluate,
this.supportsProgress = false,
this.calculateProgress,
});
/// Create an unlocked achievement DTO
AchievementDto unlock() {
return AchievementDto(
id: id,
title: title,
description: description,
type: type,
unlockedAt: DateTime.now(),
progress: 1.0,
);
}
/// Create a locked achievement DTO with progress
AchievementDto withProgress(double progress) {
return AchievementDto(
id: id,
title: title,
description: description,
type: type,
progress: progress.clamp(0.0, 1.0),
);
}
}