2025-12-13 20:55:50 +00:00
|
|
|
import 'package:drift/drift.dart';
|
2025-12-13 23:35:14 +00:00
|
|
|
import 'package:drift_postgres/drift_postgres.dart';
|
2025-11-16 11:25:27 +00:00
|
|
|
import 'package:injectable/injectable.dart';
|
2025-12-13 13:27:05 +00:00
|
|
|
import 'package:mnemo_cards_backend/database/database.dart';
|
2026-01-09 17:21:18 +00:00
|
|
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
2025-11-16 11:25:27 +00:00
|
|
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
|
|
|
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
|
|
|
|
|
|
|
|
|
@lazySingleton
|
|
|
|
|
class AchievementManager {
|
2026-01-09 17:21:18 +00:00
|
|
|
final AchievementRepository _achievementRepository;
|
2026-01-08 16:07:56 +00:00
|
|
|
final UserRepository _userRepository;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2026-01-09 17:21:18 +00:00
|
|
|
AchievementManager(this._achievementRepository, this._userRepository);
|
2025-11-16 11:25:27 +00:00
|
|
|
|
|
|
|
|
/// Get all available achievements with their definitions
|
2025-12-20 18:26:15 +00:00
|
|
|
List<AchievementDto> get allAchievementDefinitions =>
|
|
|
|
|
AchievementDefinitions.allAchievements;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
|
|
|
|
/// Check and update achievements for a user
|
|
|
|
|
Future<List<AchievementDto>> checkAndUnlockAchievements(
|
2025-12-13 20:55:50 +00:00
|
|
|
String userId,
|
2025-11-16 11:25:27 +00:00
|
|
|
UserDataModel userData,
|
|
|
|
|
) async {
|
2025-12-13 13:27:05 +00:00
|
|
|
final unlockedAchievements = <AchievementDto>[];
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
// Get existing user achievements
|
|
|
|
|
final userAchievements = await getUserAchievements(userId);
|
|
|
|
|
final existingIds = userAchievements.map((a) => a.id).toSet();
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
// Check each achievement
|
|
|
|
|
for (final achievement in AchievementDefinitions.allAchievements) {
|
|
|
|
|
if (existingIds.contains(achievement.id)) continue;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-20 18:26:15 +00:00
|
|
|
final isUnlocked = await _checkAchievementCondition(
|
|
|
|
|
userId,
|
|
|
|
|
userData,
|
|
|
|
|
achievement,
|
|
|
|
|
);
|
2025-12-13 13:27:05 +00:00
|
|
|
if (isUnlocked) {
|
|
|
|
|
final unlocked = await unlockAchievement(userId, achievement.id);
|
|
|
|
|
if (unlocked != null) {
|
|
|
|
|
unlockedAchievements.add(unlocked);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Update progress if applicable
|
2025-12-20 18:26:15 +00:00
|
|
|
final progress = await _calculateAchievementProgress(
|
|
|
|
|
userId,
|
|
|
|
|
userData,
|
|
|
|
|
achievement,
|
|
|
|
|
);
|
2025-12-13 13:27:05 +00:00
|
|
|
if (progress > 0.0) {
|
2026-01-09 17:21:18 +00:00
|
|
|
await _achievementRepository.updateAchievementProgress(
|
2025-12-20 18:26:15 +00:00
|
|
|
userId,
|
|
|
|
|
achievement.id,
|
|
|
|
|
progress,
|
|
|
|
|
);
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
return unlockedAchievements;
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
/// Get achievements for a user
|
2025-12-13 20:55:50 +00:00
|
|
|
Future<List<AchievementDto>> getUserAchievements(String userId) async {
|
2026-01-09 17:21:18 +00:00
|
|
|
final userAchievements = await _achievementRepository.getUserAchievements(
|
|
|
|
|
userId,
|
|
|
|
|
);
|
|
|
|
|
final progressMap = await _achievementRepository.getAchievementProgress(
|
2025-12-20 18:26:15 +00:00
|
|
|
userId,
|
|
|
|
|
);
|
2025-12-13 13:27:05 +00:00
|
|
|
|
|
|
|
|
final achievements = <AchievementDto>[];
|
|
|
|
|
|
|
|
|
|
for (final userAchievement in userAchievements) {
|
2025-12-20 18:26:15 +00:00
|
|
|
final definition = AchievementDefinitions.getById(
|
|
|
|
|
userAchievement.achievementId,
|
|
|
|
|
);
|
2025-12-13 13:27:05 +00:00
|
|
|
if (definition != null) {
|
2025-12-20 18:26:15 +00:00
|
|
|
achievements.add(
|
|
|
|
|
definition.copyWith(
|
|
|
|
|
unlockedAt: userAchievement.unlockedAt.dateTime,
|
|
|
|
|
progress: userAchievement.progress,
|
|
|
|
|
),
|
|
|
|
|
);
|
2025-12-13 13:27:05 +00:00
|
|
|
}
|
|
|
|
|
}
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
// Add locked achievements with progress
|
|
|
|
|
for (final definition in AchievementDefinitions.allAchievements) {
|
|
|
|
|
if (!achievements.any((a) => a.id == definition.id)) {
|
|
|
|
|
final progress = progressMap[definition.id] ?? 0.0;
|
|
|
|
|
achievements.add(definition.updateProgress(progress));
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
return achievements;
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
/// Check if user has specific achievement
|
2025-12-13 20:55:50 +00:00
|
|
|
Future<bool> hasAchievement(String userId, String achievementId) async {
|
2026-01-09 17:21:18 +00:00
|
|
|
return await _achievementRepository.hasAchievement(userId, achievementId);
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
/// Unlock achievement for user
|
|
|
|
|
Future<AchievementDto?> unlockAchievement(
|
2025-12-13 20:55:50 +00:00
|
|
|
String userId,
|
2025-11-16 11:25:27 +00:00
|
|
|
String achievementId,
|
|
|
|
|
) async {
|
2025-12-13 13:27:05 +00:00
|
|
|
final definition = AchievementDefinitions.getById(achievementId);
|
|
|
|
|
if (definition == null) return null;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
final companion = UserAchievementsCompanion.insert(
|
|
|
|
|
userId: userId,
|
|
|
|
|
achievementId: achievementId,
|
2025-12-13 23:35:14 +00:00
|
|
|
unlockedAt: Value(PgDateTime(DateTime.now())),
|
2025-12-13 20:55:50 +00:00
|
|
|
progress: Value(1.0),
|
2025-12-13 13:27:05 +00:00
|
|
|
);
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
try {
|
2026-01-09 17:21:18 +00:00
|
|
|
await _achievementRepository.unlockAchievement(companion);
|
2025-12-13 13:27:05 +00:00
|
|
|
return definition.unlock();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Achievement might already exist, return existing
|
2026-01-09 17:21:18 +00:00
|
|
|
final existing = await _achievementRepository.getUserAchievement(
|
2025-12-20 18:26:15 +00:00
|
|
|
userId,
|
|
|
|
|
achievementId,
|
|
|
|
|
);
|
2025-12-13 13:27:05 +00:00
|
|
|
return existing != null ? definition.unlock() : null;
|
|
|
|
|
}
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
/// Get achievement progress for user
|
2025-12-13 20:55:50 +00:00
|
|
|
Future<Map<String, double>> getAchievementProgress(String userId) async {
|
2026-01-09 17:21:18 +00:00
|
|
|
return await _achievementRepository.getAchievementProgress(userId);
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
/// Check if achievement condition is met
|
|
|
|
|
Future<bool> _checkAchievementCondition(
|
2025-12-13 20:55:50 +00:00
|
|
|
String userId,
|
2025-11-16 11:25:27 +00:00
|
|
|
UserDataModel userData,
|
2025-12-13 13:27:05 +00:00
|
|
|
AchievementDto achievement,
|
2025-11-16 11:25:27 +00:00
|
|
|
) async {
|
2025-12-13 13:27:05 +00:00
|
|
|
switch (achievement.type) {
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.firstWordLearned:
|
|
|
|
|
// return userData.totalCards > 0;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.firstTestCompleted:
|
|
|
|
|
// return userData.totalTests > 0;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
case AchievementType.firstPackCompleted:
|
2026-01-08 16:07:56 +00:00
|
|
|
final userPacks = await _userRepository.getUserPacks(userId);
|
2025-12-13 13:27:05 +00:00
|
|
|
return userPacks.isNotEmpty;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.words10Learned:
|
|
|
|
|
// return userData.totalCards >= 10;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.words50Learned:
|
|
|
|
|
// return userData.totalCards >= 50;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.words100Learned:
|
|
|
|
|
// return userData.totalCards >= 100;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.words500Learned:
|
|
|
|
|
// return userData.totalCards >= 500;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.words1000Learned:
|
|
|
|
|
// return userData.totalCards >= 1000;
|
2025-11-16 11:25:27 +00:00
|
|
|
|
2025-12-13 14:48:00 +00:00
|
|
|
case AchievementType.streak7Days:
|
|
|
|
|
return userData.currentStreak >= 7;
|
|
|
|
|
|
|
|
|
|
case AchievementType.streak30Days:
|
|
|
|
|
return userData.currentStreak >= 30;
|
|
|
|
|
|
|
|
|
|
case AchievementType.streak100Days:
|
|
|
|
|
return userData.currentStreak >= 100;
|
|
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.perfectTest:
|
|
|
|
|
// return userData.totalTests > 0;
|
2025-12-13 14:48:00 +00:00
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.speedLearner:
|
|
|
|
|
// return userData.totalCards >= 100 && userData.totalStudyTimeMinutes < 600;
|
2025-12-13 13:27:05 +00:00
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
/// Calculate progress towards achievement
|
|
|
|
|
Future<double> _calculateAchievementProgress(
|
2025-12-13 20:55:50 +00:00
|
|
|
String userId,
|
2025-12-13 13:27:05 +00:00
|
|
|
UserDataModel userData,
|
|
|
|
|
AchievementDto achievement,
|
|
|
|
|
) async {
|
|
|
|
|
switch (achievement.type) {
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.words10Learned:
|
|
|
|
|
// return (userData.totalCards / 10.0).clamp(0.0, 1.0);
|
2025-12-13 13:27:05 +00:00
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.words50Learned:
|
|
|
|
|
// return (userData.totalCards / 50.0).clamp(0.0, 1.0);
|
2025-12-13 13:27:05 +00:00
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.words100Learned:
|
|
|
|
|
// return (userData.totalCards / 100.0).clamp(0.0, 1.0);
|
2025-12-13 13:27:05 +00:00
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.words500Learned:
|
|
|
|
|
// return (userData.totalCards / 500.0).clamp(0.0, 1.0);
|
2025-12-13 13:27:05 +00:00
|
|
|
|
2025-12-13 20:55:50 +00:00
|
|
|
// case AchievementType.words1000Learned:
|
|
|
|
|
// return (userData.totalCards / 1000.0).clamp(0.0, 1.0);
|
2025-12-13 13:27:05 +00:00
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return 0.0;
|
|
|
|
|
}
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
}
|