import 'dart:developer'; import 'package:injectable/injectable.dart'; import 'package:isar/isar.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import '../main.dart'; import '../packs/free_packs_distributor.dart'; import '../statistics/session_tracker.dart'; import '../statistics/statistics_calculator.dart'; import '../statistics/achievement_manager.dart'; import 'secure.dart'; Map _onlineUsers = {}; @lazySingleton class UserManager { final FreePacksDistributor _freePacksDistributor; final SessionTracker _sessionTracker; final StatisticsCalculator _statisticsCalculator; final AchievementManager _achievementManager; UserManager( this._freePacksDistributor, this._sessionTracker, this._statisticsCalculator, this._achievementManager, ); Future fetchUser(Id id) async { final user = await isar.userModels.get(id); return user; } DateTime? lastOnline(Id id) => _onlineUsers[id]; Future createOrGetAuthToken(UserModel user, String externalId) async { if (user.id == null) { throw Exception('Cant create token for empty id'); } final now = DateTime.now(); final tokenModel = await isar.tokenModels.filter().userIdEqualTo(user.id!).findFirst(); if (tokenModel != null) { if (tokenModel.expires.isAfter(now)) { return tokenModel.token; } await isar.tokenModels.delete(tokenModel.id!); } final userToken = Secure.token(); await isar.tokenModels.put( TokenModel( token: userToken, externalUserId: externalId, userId: user.id!, created: now, expires: now.add(Duration(days: 360)), ), ); return userToken; } Future getUserByToken(String authToken) async { final tokenModel = await isar.tokenModels.filter().tokenEqualTo(authToken).findFirst(); if (tokenModel == null) { return null; } final now = DateTime.now(); if (tokenModel.expires.isBefore(now)) { isar.writeTxn(() => isar.tokenModels.delete(tokenModel.id!)); return null; } final user = await isar.userModels.get(tokenModel.userId); if (user == null) { return null; } else { _onlineUsers[user.id] = now; if (_onlineUsers.length > 300) { updateOnlineUsers(); } } return user; } Future updateOnlineUsers() async { if (_onlineUsers.isEmpty) { return; } return isar.writeTxn(() async { final ids = _onlineUsers.keys.whereNotNull().toList(); final users = (await isar.userModels.getAll(ids)).whereNotNull(); await Future.wait(users.map((user) => user.userData.load())); final updated = users .map((user) => user.userData.value?.copyWith( lastTimeOnline: _onlineUsers[user.id] ?? user.userData.value?.lastTimeOnline, )) .whereNotNull() .toList(); await isar.userDataModels.putAll(updated); _onlineUsers.clear(); }); } Future> getTokensByUser(String userId) async { final id = int.tryParse(userId); if (id == null) return []; return isar.txn( () => isar.tokenModels.filter().userIdEqualTo(id).findAll(), ); } Future<(UserModel, String)> createOrGetUser({ required String externalId, required String email, String? name, }) async { final tokenModel = await isar.tokenModels .filter() .externalUserIdEqualTo(externalId) .findFirst(); var user = tokenModel == null ? null : await fetchUser(tokenModel.userId); if (user == null) { print('Creating new user $name $email'); final modelAndToken = await isar.writeTxn(() async { final userModel = UserModel.empty.copyWith( email: email, name: name, ); await isar.userModels.put(userModel); print('User $name $email saved'); await isar.tokenModels .filter() .externalUserIdEqualTo(externalId) .deleteFirst(); print('User tokens deleted $name $email'); final token = await createOrGetAuthToken(userModel, externalId); final userData = UserDataModel()..user.value = userModel; await isar.userDataModels.put(userData); userModel.userData.value = userData; await userModel.userData.save(); return (userModel, token); }); await _freePacksDistributor.giveFreePacksToUser(modelAndToken.$1); return modelAndToken; } else { if (user.email == null) { user = user.copyWith.email(email); await isar.writeTxn(() => isar.userModels.put(user!)); } await isar.writeTxn(() async { await user!.userData.load(); if (user.userData.value == null) { final userData = UserDataModel()..user.value = user; await isar.userDataModels.put(userData); user.userData.value = userData; await user.userData.save(); } }); return (user, tokenModel!.token); } } // Future addWordStatistics( // UserModel user, AllWordsStatisticsDto wordStat) async { // // final currentData = // // user.userData?.decode(UserDataDto.fromJson) ?? UserDataDto(); // // updateUserData( // // user, // // currentData.copyWith.allWordsStatistics( // // wordStat.merge(currentData.allWordsStatistics), // // ), // // ); // } Future addTestStatistics( UserModel user, TestStatisticsDto testStat, ) async { UserDataModel currentData; print('adding test stats'); if (user.userData.value == null) { print('new user data'); currentData = UserDataModel(); await isar.writeTxn(() async { final id = await isar.userDataModels.put( currentData..user.value = user, ); currentData = (await isar.userDataModels.get(id))!; }); print('user data saved'); } else { print('found user data'); currentData = user.userData.value!; } if (testStat.sessionToken == currentData.lastTestSessionToken) { print('old session token'); return; } final testStatistics = await isar.txn( () async => await currentData.testsStatistics .filter() .test((q) => q.idEqualTo(testStat.testId)) .findFirst() ?? TestStatisticsModel(), ); print('got tests stat for ${testStat.testId}'); final allWords = currentData.words.mergeWithDto(testStat.words.words); print( 'merged all words: ${allWords.length} (was ${currentData.words.length})'); return isar.writeTxn(() async { try { final test = await isar.testModels.get(testStat.testId); // final testWords = updateWords(testStatistics.words, testStat.words.words); final updatedTestStat = testStatistics.copyWith.attempts( [ ...testStatistics.attempts.takeLast(2), TestAttempt( words: testStat.words.words.map((e) => e.toModel()).toList(), sessionToken: testStat.sessionToken, ), ], )..test.value = test; await isar.testStatisticsModels.put(updatedTestStat); await updatedTestStat.test.save(); print('updated test stats saved'); // Calculate updated statistics final studyDates = List.from(currentData.studyDates); final today = DateTime.now(); final todayNormalized = DateTime(today.year, today.month, today.day); // Add today's study date if not already present if (!studyDates.any((date) => date.year == todayNormalized.year && date.month == todayNormalized.month && date.day == todayNormalized.day)) { studyDates.add(todayNormalized); } // Calculate current streak based on study dates final currentStreak = _statisticsCalculator.calculateStreak(studyDates); // Calculate longest streak final longestStreak = currentStreak > currentData.longestStreak ? currentStreak : currentData.longestStreak; // Calculate total study time (simplified - add time from this test) final testDurationMinutes = 5; // Assume 5 minutes per test as default final totalStudyTimeMinutes = currentData.totalStudyTimeMinutes + testDurationMinutes; // Calculate pack progress if we have pack info final packProgress = List.from(currentData.packProgress); // Update pack progress if test was for a specific pack if (testStat.testId != null) { // This would need pack information lookup - simplified for now // In a real implementation, we'd update the specific pack's progress } // Check and unlock achievements with updated data final tempUpdatedData = currentData.copyWith( currentStreak: currentStreak, longestStreak: longestStreak, studyDates: studyDates, totalStudyTimeMinutes: totalStudyTimeMinutes, words: allWords, ); // Check for newly unlocked achievements await checkAndUpdateAchievements(user, tempUpdatedData); final updatedData = currentData.copyWith( lastTestSessionToken: testStat.sessionToken, words: allWords, currentStreak: currentStreak, longestStreak: longestStreak, studyDates: studyDates, totalStudyTimeMinutes: totalStudyTimeMinutes, lastTimeOnline: today, ) ..testsStatistics.add(updatedTestStat) ..user.value = user; // Note: packProgress and achievements are handled separately // since UserDataModel expects Model types, not DTO types print('saving updated user data'); await isar.userDataModels.put(updatedData); await updatedData.testsStatistics.save(); await updatedData.user.save(); print('user data saved'); // Track session activity await _updateSessionFromTest(user, testStat, updatedData); } catch (e, s) { print('error while saving data ${e.toString()} ${s.toString()}'); log('error', error: e, stackTrace: s); } }); } /// Check and update achievements for a user Future> checkAndUpdateAchievements( UserModel user, UserDataModel userData, ) async { try { final newlyUnlockedAchievements = await _achievementManager.checkAndUnlockAchievements( user.id!, userData, ); if (newlyUnlockedAchievements.isNotEmpty) { print( 'New achievements unlocked for user ${user.id}: ${newlyUnlockedAchievements.map((a) => a.title).join(', ')}'); } return newlyUnlockedAchievements; } catch (e, s) { print('Error checking achievements for user ${user.id}: $e\n$s'); return []; } } /// Update session tracking based on test completion Future _updateSessionFromTest( UserModel user, TestStatisticsDto testStat, UserDataModel updatedData, ) async { if (user.id == null) return; try { // Get or create session for this user final sessionId = await _sessionTracker.getOrCreateSession( user.id!, testId: testStat.testId.toString(), ); // Calculate test statistics final wordsLearned = testStat.words.words.length; final correctAnswers = testStat.words.words.fold( 0, (sum, word) => sum + word.correct.toInt(), ); final totalAnswers = testStat.words.words.fold( 0, (sum, word) => sum + word.correct.toInt() + word.incorrect.toInt(), ); final accuracy = totalAnswers > 0 ? correctAnswers / totalAnswers : 0.0; // Update session progress await _sessionTracker.updateSessionProgress( sessionId, wordsLearned: wordsLearned, testsCompleted: 1, accuracy: accuracy, ); print( 'Updated session $sessionId: +$wordsLearned words, accuracy: ${accuracy.toStringAsFixed(2)}'); } catch (e, s) { print('Error updating session tracking: $e\n$s'); // Don't fail the main operation if session tracking fails } } Future updateUserSettings(UserModel user, UserSettingsDto settings) { return isar.writeTxn( () => isar.userModels.put( user.copyWith(userSettings: settings.encode()), ), ); } Future editUser(UserDto user) async { if (user.id == null || user.id! < 0) { throw Exception('Cant edit new user'); } final existUser = await isar.userModels.get(user.id!); if (existUser == null) { throw Exception('User ${user.id} not found'); } final dtoPackIds = user.packs.map((s) => int.tryParse(s)).whereNotNull(); final dtoPacks = dtoPackIds.isEmpty ? [] : (await isar.cardPackModels.getAll(dtoPackIds.toList())) .whereNotNull() .toList(); isar.writeTxn(() async { final updatedUser = existUser.copyWith( name: user.name ?? existUser.name, // subscription: user.subscription, // userSettings: user.userSettingsDto?.encode() ?? existUser.userSettings, // userData: user.userDataDto?.encode() ?? existUser.userData, ); final id = await isar.userModels.put(updatedUser); final packs = (await isar.userModels.get(id))!.packs; await updatedUser.subscriptionModel.load(); final subscriptionModel = updatedUser.subscriptionModel.value; print( 'Editing user ${user.id}, subscription = ${user.subscription} and hasModel = ${subscriptionModel != null}', ); if (user.subscription == true) { UserSubscriptionModel userSubscriptionModel; if (subscriptionModel == null) { print('Creating new sub model'); userSubscriptionModel = UserSubscriptionModel( start: DateTime.now(), finish: DateTime.now().add(Duration(days: 1)), features: [ SubscriptionFeatureEnum.packs, SubscriptionFeatureEnum.ads, ], ); } else { print('Updating sub model'); userSubscriptionModel = subscriptionModel.copyWith( start: DateTime.now(), finish: DateTime.now().add(Duration(days: 1)), features: [ SubscriptionFeatureEnum.packs, SubscriptionFeatureEnum.ads, ]); } updatedUser.subscriptionModel.value = userSubscriptionModel; await isar.userSubscriptionModels.put(userSubscriptionModel); await updatedUser.subscriptionModel.save(); } else if (subscriptionModel != null) { final updatedModel = subscriptionModel.copyWith( start: DateTime.now(), finish: DateTime.now(), features: [], )..user.value = updatedUser; await isar.userSubscriptionModels.put(updatedModel); updatedUser.subscriptionModel.value = updatedModel; await updatedUser.subscriptionModel.save(); } await packs.reset(); await (packs..addAll(dtoPacks)).save(); }); return true; } Future deleteUser(String stringId) async { final id = int.tryParse(stringId); if (id != null) { await isar.writeTxn(() async { final tokens = await isar.tokenModels .filter() .userIdEqualTo(id) .idProperty() .findAll(); if (tokens.isNotEmpty) { await isar.tokenModels.deleteAll(tokens); } await isar.testStatisticsModels .filter() .userData((q) => q.user((u) => u.idEqualTo(id))) .deleteAll(); await isar.userDataModels .filter() .user((q) => q.idEqualTo(id)) .deleteAll(); return isar.userModels.delete(id); }); return true; } return false; } }