422 lines
13 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|