487 lines
16 KiB
Text
487 lines
16 KiB
Text
|
|
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<Id?, DateTime> _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<UserModel?> fetchUser(Id id) async {
|
||
|
|
final user = await isar.userModels.get(id);
|
||
|
|
return user;
|
||
|
|
}
|
||
|
|
|
||
|
|
DateTime? lastOnline(Id id) => _onlineUsers[id];
|
||
|
|
|
||
|
|
Future<String> 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<UserModel?> 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<void> 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<List<TokenModel>> 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<void> addWordStatistics(
|
||
|
|
// UserModel user, AllWordsStatisticsDto wordStat) async {
|
||
|
|
// // final currentData =
|
||
|
|
// // user.userData?.decode(UserDataDto.fromJson) ?? UserDataDto();
|
||
|
|
// // updateUserData(
|
||
|
|
// // user,
|
||
|
|
// // currentData.copyWith.allWordsStatistics(
|
||
|
|
// // wordStat.merge(currentData.allWordsStatistics),
|
||
|
|
// // ),
|
||
|
|
// // );
|
||
|
|
// }
|
||
|
|
|
||
|
|
Future<void> 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<DateTime>.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<PackProgressDto>.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<List<AchievementDto>> 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<void> _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<int>(
|
||
|
|
0,
|
||
|
|
(sum, word) => sum + word.correct.toInt(),
|
||
|
|
);
|
||
|
|
final totalAnswers = testStat.words.words.fold<int>(
|
||
|
|
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<void> updateUserSettings(UserModel user, UserSettingsDto settings) {
|
||
|
|
return isar.writeTxn(
|
||
|
|
() => isar.userModels.put(
|
||
|
|
user.copyWith(userSettings: settings.encode()),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<bool> 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
|
||
|
|
? <CardPackModel>[]
|
||
|
|
: (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<bool> 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;
|
||
|
|
}
|
||
|
|
}
|