diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index ca9ed39..60e25ff 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -1,7 +1,10 @@ import '../infrastructure/local/local.dart'; +import '../infrastructure/remote/remote.dart'; +import '../infrastructure/security/security.dart'; import 'application.dart'; abstract interface class AppDependencies { + AuthUseCases get authUseCases; ExerciseUseCases get exerciseUseCases; MediaUseCases get mediaUseCases; ProgramUseCases get programUseCases; @@ -15,6 +18,7 @@ abstract interface class AppDependencies { final class AppBootstrap implements AppDependencies { AppBootstrap._({ required this.database, + required this.authUseCases, required this.exerciseUseCases, required this.mediaUseCases, required this.programUseCases, @@ -28,6 +32,8 @@ final class AppBootstrap implements AppDependencies { final AppDatabase database; @override + final AuthUseCases authUseCases; + @override final ExerciseUseCases exerciseUseCases; @override final MediaUseCases mediaUseCases; @@ -50,6 +56,7 @@ final class AppBootstrap implements AppDependencies { await database.customSelect('SELECT 1').get(); final exerciseRepository = DriftExerciseRepository(database); final mediaRepository = DriftMediaAssetRepository(database); + final onlineAccountRepository = DriftOnlineAccountRepository(database); final programRepository = DriftProgramRepository(database); final templateRepository = DriftWorkoutTemplateRepository(database); final activeSessionRepository = DriftActiveSessionRepository(database); @@ -57,9 +64,19 @@ final class AppBootstrap implements AppDependencies { final ids = LocalIdGenerator(); const clock = SystemClock(); const originDeviceId = 'local-device'; + final remoteAuthApi = HttpRemoteAuthApi( + HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)), + ); return AppBootstrap._( database: database, + authUseCases: AuthUseCases( + tokenStore: const SecureStorageAuthTokenStore(), + accountRepository: onlineAccountRepository, + remoteAuthApi: remoteAuthApi, + clock: clock, + ids: ids, + ), exerciseUseCases: ExerciseUseCases( repository: exerciseRepository, programRepository: programRepository, diff --git a/lib/application/ports.dart b/lib/application/ports.dart index dd798b6..bb9b6d2 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -15,6 +15,64 @@ abstract interface class IdGenerator { String newId(); } +enum RemoteAuthFailure { + invalidCredentials, + emailAlreadyUsed, + network, + unknown, +} + +final class RemoteAuthException implements Exception { + const RemoteAuthException(this.failure, [this.message]); + + final RemoteAuthFailure failure; + final String? message; + + @override + String toString() => message ?? failure.name; +} + +final class RemoteAuthResult { + const RemoteAuthResult({ + required this.userId, + required this.email, + required this.token, + required this.expiresAt, + }); + + final String userId; + final String email; + final String token; + final DateTime expiresAt; +} + +abstract interface class AuthTokenStore { + Future saveToken(String token, DateTime expiresAt); + Future readToken(); + Future clearToken(); +} + +abstract interface class OnlineAccountRepository { + Future currentSession(); + Future saveSession(UserAccountSession session); + Future clearSession(); +} + +abstract interface class RemoteAuthApi { + Future register({ + required String email, + required String password, + String? displayName, + }); + + Future login({ + required String email, + required String password, + }); + + Future logout(String token); +} + abstract interface class ExerciseRepository { Future findById(String id); Future> listActive(); diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 7b46b17..b798e01 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -5,6 +5,107 @@ import 'ports.dart'; const Object _useCaseUnchanged = Object(); +final class AuthUseCases { + const AuthUseCases({ + required this.tokenStore, + required this.accountRepository, + required this.remoteAuthApi, + required this.clock, + required this.ids, + }); + + final AuthTokenStore tokenStore; + final OnlineAccountRepository accountRepository; + final RemoteAuthApi remoteAuthApi; + final Clock clock; + final IdGenerator ids; + + Future register({ + required String email, + required String password, + String? displayName, + }) async { + _validateAuthInput(email: email, password: password); + final result = await remoteAuthApi.register( + email: email.trim(), + password: password, + displayName: displayName, + ); + return _persistAuthenticatedSession(result, displayName: displayName); + } + + Future login({ + required String email, + required String password, + }) async { + _validateAuthInput(email: email, password: password); + final result = await remoteAuthApi.login( + email: email.trim(), + password: password, + ); + return _persistAuthenticatedSession(result); + } + + Future logout() async { + final token = await tokenStore.readToken(); + if (token != null) { + try { + await remoteAuthApi.logout(token); + } on RemoteAuthException { + // Local logout must not depend on remote reachability. + } + } + await tokenStore.clearToken(); + final session = await accountRepository.currentSession(); + if (session != null) { + final now = clock.now(); + await accountRepository.saveSession( + session.copyWith(isLoggedIn: false, updatedAt: now), + ); + } + } + + Future currentSession() async { + final session = await accountRepository.currentSession(); + if (session == null) { + return null; + } + final token = await tokenStore.readToken(); + if (token != null || !session.isLoggedIn) { + return session; + } + final updated = session.copyWith(isLoggedIn: false, updatedAt: clock.now()); + await accountRepository.saveSession(updated); + return updated; + } + + Future _persistAuthenticatedSession( + RemoteAuthResult result, { + String? displayName, + }) async { + await tokenStore.saveToken(result.token, result.expiresAt); + final now = clock.now(); + final existing = await accountRepository.currentSession(); + final session = UserAccountSession( + id: existing?.id ?? ids.newId(), + serverUserId: + existing != null && + existing.email.trim().toLowerCase() == + result.email.trim().toLowerCase() + ? existing.serverUserId + : result.userId, + email: result.email, + displayName: displayName ?? existing?.displayName, + isLoggedIn: true, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + lastAuthenticatedAt: now, + ); + await accountRepository.saveSession(session); + return session; + } +} + final class ExerciseUseCases { const ExerciseUseCases({ required this.repository, @@ -2264,6 +2365,19 @@ void _validateExerciseSteps(List steps) { } } +void _validateAuthInput({required String email, required String password}) { + final normalizedEmail = email.trim(); + final hasBasicEmailShape = RegExp( + r'^[^@\s]+@[^@\s]+\.[^@\s]+$', + ).hasMatch(normalizedEmail); + if (!hasBasicEmailShape) { + throw const DomainException('Email format is invalid.'); + } + if (password.length < 8) { + throw const DomainException('Password must contain at least 8 characters.'); + } +} + void _validateExerciseDefaultTargets({ required bool hasTimeMeasure, required bool hasRepsMeasure, diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index b60b8a4..95975a2 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -102,6 +102,58 @@ final class EntityMetadata { } } +final class UserAccountSession { + UserAccountSession({ + required String id, + required String serverUserId, + required String email, + this.displayName, + required this.isLoggedIn, + required this.createdAt, + required this.updatedAt, + this.lastAuthenticatedAt, + }) : id = _nonBlank(id, 'User account session id'), + serverUserId = _nonBlank(serverUserId, 'Server user id'), + email = _nonBlank(email, 'Email') { + if (displayName != null && displayName!.trim().isEmpty) { + throw const DomainException('Display name must not be blank.'); + } + } + + final String id; + final String serverUserId; + final String email; + final String? displayName; + final bool isLoggedIn; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? lastAuthenticatedAt; + + UserAccountSession copyWith({ + String? serverUserId, + String? email, + Object? displayName = _unchanged, + bool? isLoggedIn, + DateTime? updatedAt, + Object? lastAuthenticatedAt = _unchanged, + }) { + return UserAccountSession( + id: id, + serverUserId: serverUserId ?? this.serverUserId, + email: email ?? this.email, + displayName: displayName == _unchanged + ? this.displayName + : displayName as String?, + isLoggedIn: isLoggedIn ?? this.isLoggedIn, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastAuthenticatedAt: lastAuthenticatedAt == _unchanged + ? this.lastAuthenticatedAt + : lastAuthenticatedAt as DateTime?, + ); + } +} + final class MediaAsset { const MediaAsset({ required this.metadata, @@ -997,7 +1049,9 @@ final class ActiveExerciseStepProgressState { currentStepSnapshotId: currentStepSnapshotId ?? this.currentStepSnapshotId, status: status ?? this.status, - startedAt: startedAt == _unchanged ? this.startedAt : startedAt as DateTime?, + startedAt: startedAt == _unchanged + ? this.startedAt + : startedAt as DateTime?, accumulatedMs: accumulatedMs ?? this.accumulatedMs, lastTransitionAt: lastTransitionAt ?? this.lastTransitionAt, ); diff --git a/lib/infrastructure/infrastructure.dart b/lib/infrastructure/infrastructure.dart index d3e0627..b8732f3 100644 --- a/lib/infrastructure/infrastructure.dart +++ b/lib/infrastructure/infrastructure.dart @@ -3,3 +3,7 @@ /// Concrete adapters live here and depend inward on application/domain /// contracts. library; + +export 'local/local.dart'; +export 'remote/remote.dart'; +export 'security/security.dart'; diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index aa60471..0371bb9 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -18,6 +18,7 @@ part 'app_database.g.dart'; ExerciseImages, ExerciseSteps, MediaAssets, + OnlineAccountSessions, ProgramExercises, Programs, WorkoutHistories, @@ -41,7 +42,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 9; + int get schemaVersion => 10; @override MigrationStrategy get migration { @@ -83,6 +84,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 9) { await _migrateToSchema9(migrator); } + if (from < 10) { + await _migrateToSchema10(migrator); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -123,6 +127,10 @@ final class AppDatabase extends _$AppDatabase { 'CREATE INDEX IF NOT EXISTS idx_exercise_steps_exercise_id_position ' 'ON exercise_steps (exercise_id, position)', ); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_online_account_sessions_logged_in ' + 'ON online_account_sessions (is_logged_in, updated_at)', + ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id ' 'ON workout_template_programs (workout_template_id)', @@ -315,4 +323,8 @@ extension on AppDatabase { await migrator.createTable(activeExerciseStepResults); await migrator.createTable(workoutHistoryStepResults); } + + Future _migrateToSchema10(Migrator migrator) async { + await migrator.createTable(onlineAccountSessions); + } } diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index cae6f0d..0825180 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -14914,6 +14914,552 @@ class ExerciseStepsCompanion extends UpdateCompanion { } } +class $OnlineAccountSessionsTable extends OnlineAccountSessions + with TableInfo<$OnlineAccountSessionsTable, OnlineAccountSession> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $OnlineAccountSessionsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _serverUserIdMeta = const VerificationMeta( + 'serverUserId', + ); + @override + late final GeneratedColumn serverUserId = GeneratedColumn( + 'server_user_id', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength(minTextLength: 1), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _emailMeta = const VerificationMeta('email'); + @override + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength(minTextLength: 1), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _displayNameMeta = const VerificationMeta( + 'displayName', + ); + @override + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _isLoggedInMeta = const VerificationMeta( + 'isLoggedIn', + ); + @override + late final GeneratedColumn isLoggedIn = GeneratedColumn( + 'is_logged_in', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_logged_in" IN (0, 1))', + ), + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastAuthenticatedAtMeta = + const VerificationMeta('lastAuthenticatedAt'); + @override + late final GeneratedColumn lastAuthenticatedAt = + GeneratedColumn( + 'last_authenticated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + serverUserId, + email, + displayName, + isLoggedIn, + createdAt, + updatedAt, + lastAuthenticatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'online_account_sessions'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('server_user_id')) { + context.handle( + _serverUserIdMeta, + serverUserId.isAcceptableOrUnknown( + data['server_user_id']!, + _serverUserIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_serverUserIdMeta); + } + if (data.containsKey('email')) { + context.handle( + _emailMeta, + email.isAcceptableOrUnknown(data['email']!, _emailMeta), + ); + } else if (isInserting) { + context.missing(_emailMeta); + } + if (data.containsKey('display_name')) { + context.handle( + _displayNameMeta, + displayName.isAcceptableOrUnknown( + data['display_name']!, + _displayNameMeta, + ), + ); + } + if (data.containsKey('is_logged_in')) { + context.handle( + _isLoggedInMeta, + isLoggedIn.isAcceptableOrUnknown( + data['is_logged_in']!, + _isLoggedInMeta, + ), + ); + } else if (isInserting) { + context.missing(_isLoggedInMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('last_authenticated_at')) { + context.handle( + _lastAuthenticatedAtMeta, + lastAuthenticatedAt.isAcceptableOrUnknown( + data['last_authenticated_at']!, + _lastAuthenticatedAtMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + OnlineAccountSession map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return OnlineAccountSession( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + serverUserId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}server_user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + displayName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}display_name'], + ), + isLoggedIn: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_logged_in'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + lastAuthenticatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_authenticated_at'], + ), + ); + } + + @override + $OnlineAccountSessionsTable createAlias(String alias) { + return $OnlineAccountSessionsTable(attachedDatabase, alias); + } +} + +class OnlineAccountSession extends DataClass + implements Insertable { + final String id; + final String serverUserId; + final String email; + final String? displayName; + final bool isLoggedIn; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? lastAuthenticatedAt; + const OnlineAccountSession({ + required this.id, + required this.serverUserId, + required this.email, + this.displayName, + required this.isLoggedIn, + required this.createdAt, + required this.updatedAt, + this.lastAuthenticatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['server_user_id'] = Variable(serverUserId); + map['email'] = Variable(email); + if (!nullToAbsent || displayName != null) { + map['display_name'] = Variable(displayName); + } + map['is_logged_in'] = Variable(isLoggedIn); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || lastAuthenticatedAt != null) { + map['last_authenticated_at'] = Variable(lastAuthenticatedAt); + } + return map; + } + + OnlineAccountSessionsCompanion toCompanion(bool nullToAbsent) { + return OnlineAccountSessionsCompanion( + id: Value(id), + serverUserId: Value(serverUserId), + email: Value(email), + displayName: displayName == null && nullToAbsent + ? const Value.absent() + : Value(displayName), + isLoggedIn: Value(isLoggedIn), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + lastAuthenticatedAt: lastAuthenticatedAt == null && nullToAbsent + ? const Value.absent() + : Value(lastAuthenticatedAt), + ); + } + + factory OnlineAccountSession.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return OnlineAccountSession( + id: serializer.fromJson(json['id']), + serverUserId: serializer.fromJson(json['serverUserId']), + email: serializer.fromJson(json['email']), + displayName: serializer.fromJson(json['displayName']), + isLoggedIn: serializer.fromJson(json['isLoggedIn']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + lastAuthenticatedAt: serializer.fromJson( + json['lastAuthenticatedAt'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'serverUserId': serializer.toJson(serverUserId), + 'email': serializer.toJson(email), + 'displayName': serializer.toJson(displayName), + 'isLoggedIn': serializer.toJson(isLoggedIn), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'lastAuthenticatedAt': serializer.toJson(lastAuthenticatedAt), + }; + } + + OnlineAccountSession copyWith({ + String? id, + String? serverUserId, + String? email, + Value displayName = const Value.absent(), + bool? isLoggedIn, + DateTime? createdAt, + DateTime? updatedAt, + Value lastAuthenticatedAt = const Value.absent(), + }) => OnlineAccountSession( + id: id ?? this.id, + serverUserId: serverUserId ?? this.serverUserId, + email: email ?? this.email, + displayName: displayName.present ? displayName.value : this.displayName, + isLoggedIn: isLoggedIn ?? this.isLoggedIn, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastAuthenticatedAt: lastAuthenticatedAt.present + ? lastAuthenticatedAt.value + : this.lastAuthenticatedAt, + ); + OnlineAccountSession copyWithCompanion(OnlineAccountSessionsCompanion data) { + return OnlineAccountSession( + id: data.id.present ? data.id.value : this.id, + serverUserId: data.serverUserId.present + ? data.serverUserId.value + : this.serverUserId, + email: data.email.present ? data.email.value : this.email, + displayName: data.displayName.present + ? data.displayName.value + : this.displayName, + isLoggedIn: data.isLoggedIn.present + ? data.isLoggedIn.value + : this.isLoggedIn, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + lastAuthenticatedAt: data.lastAuthenticatedAt.present + ? data.lastAuthenticatedAt.value + : this.lastAuthenticatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('OnlineAccountSession(') + ..write('id: $id, ') + ..write('serverUserId: $serverUserId, ') + ..write('email: $email, ') + ..write('displayName: $displayName, ') + ..write('isLoggedIn: $isLoggedIn, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('lastAuthenticatedAt: $lastAuthenticatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + serverUserId, + email, + displayName, + isLoggedIn, + createdAt, + updatedAt, + lastAuthenticatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is OnlineAccountSession && + other.id == this.id && + other.serverUserId == this.serverUserId && + other.email == this.email && + other.displayName == this.displayName && + other.isLoggedIn == this.isLoggedIn && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.lastAuthenticatedAt == this.lastAuthenticatedAt); +} + +class OnlineAccountSessionsCompanion + extends UpdateCompanion { + final Value id; + final Value serverUserId; + final Value email; + final Value displayName; + final Value isLoggedIn; + final Value createdAt; + final Value updatedAt; + final Value lastAuthenticatedAt; + final Value rowid; + const OnlineAccountSessionsCompanion({ + this.id = const Value.absent(), + this.serverUserId = const Value.absent(), + this.email = const Value.absent(), + this.displayName = const Value.absent(), + this.isLoggedIn = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.lastAuthenticatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + OnlineAccountSessionsCompanion.insert({ + required String id, + required String serverUserId, + required String email, + this.displayName = const Value.absent(), + required bool isLoggedIn, + required DateTime createdAt, + required DateTime updatedAt, + this.lastAuthenticatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + serverUserId = Value(serverUserId), + email = Value(email), + isLoggedIn = Value(isLoggedIn), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? serverUserId, + Expression? email, + Expression? displayName, + Expression? isLoggedIn, + Expression? createdAt, + Expression? updatedAt, + Expression? lastAuthenticatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (serverUserId != null) 'server_user_id': serverUserId, + if (email != null) 'email': email, + if (displayName != null) 'display_name': displayName, + if (isLoggedIn != null) 'is_logged_in': isLoggedIn, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (lastAuthenticatedAt != null) + 'last_authenticated_at': lastAuthenticatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + OnlineAccountSessionsCompanion copyWith({ + Value? id, + Value? serverUserId, + Value? email, + Value? displayName, + Value? isLoggedIn, + Value? createdAt, + Value? updatedAt, + Value? lastAuthenticatedAt, + Value? rowid, + }) { + return OnlineAccountSessionsCompanion( + id: id ?? this.id, + serverUserId: serverUserId ?? this.serverUserId, + email: email ?? this.email, + displayName: displayName ?? this.displayName, + isLoggedIn: isLoggedIn ?? this.isLoggedIn, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (serverUserId.present) { + map['server_user_id'] = Variable(serverUserId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (displayName.present) { + map['display_name'] = Variable(displayName.value); + } + if (isLoggedIn.present) { + map['is_logged_in'] = Variable(isLoggedIn.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (lastAuthenticatedAt.present) { + map['last_authenticated_at'] = Variable( + lastAuthenticatedAt.value, + ); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('OnlineAccountSessionsCompanion(') + ..write('id: $id, ') + ..write('serverUserId: $serverUserId, ') + ..write('email: $email, ') + ..write('displayName: $displayName, ') + ..write('isLoggedIn: $isLoggedIn, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('lastAuthenticatedAt: $lastAuthenticatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $ProgramsTable extends Programs with TableInfo<$ProgramsTable, Program> { @override final GeneratedDatabase attachedDatabase; @@ -25542,6 +26088,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ExercisesTable exercises = $ExercisesTable(this); late final $ExerciseImagesTable exerciseImages = $ExerciseImagesTable(this); late final $ExerciseStepsTable exerciseSteps = $ExerciseStepsTable(this); + late final $OnlineAccountSessionsTable onlineAccountSessions = + $OnlineAccountSessionsTable(this); late final $ProgramsTable programs = $ProgramsTable(this); late final $ProgramExercisesTable programExercises = $ProgramExercisesTable( this, @@ -25576,6 +26124,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { exercises, exerciseImages, exerciseSteps, + onlineAccountSessions, programs, programExercises, workoutHistories, @@ -35150,6 +35699,290 @@ typedef $$ExerciseStepsTableProcessedTableManager = ExerciseStep, PrefetchHooks Function({bool exerciseId}) >; +typedef $$OnlineAccountSessionsTableCreateCompanionBuilder = + OnlineAccountSessionsCompanion Function({ + required String id, + required String serverUserId, + required String email, + Value displayName, + required bool isLoggedIn, + required DateTime createdAt, + required DateTime updatedAt, + Value lastAuthenticatedAt, + Value rowid, + }); +typedef $$OnlineAccountSessionsTableUpdateCompanionBuilder = + OnlineAccountSessionsCompanion Function({ + Value id, + Value serverUserId, + Value email, + Value displayName, + Value isLoggedIn, + Value createdAt, + Value updatedAt, + Value lastAuthenticatedAt, + Value rowid, + }); + +class $$OnlineAccountSessionsTableFilterComposer + extends Composer<_$AppDatabase, $OnlineAccountSessionsTable> { + $$OnlineAccountSessionsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverUserId => $composableBuilder( + column: $table.serverUserId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get email => $composableBuilder( + column: $table.email, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isLoggedIn => $composableBuilder( + column: $table.isLoggedIn, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastAuthenticatedAt => $composableBuilder( + column: $table.lastAuthenticatedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$OnlineAccountSessionsTableOrderingComposer + extends Composer<_$AppDatabase, $OnlineAccountSessionsTable> { + $$OnlineAccountSessionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverUserId => $composableBuilder( + column: $table.serverUserId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get email => $composableBuilder( + column: $table.email, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isLoggedIn => $composableBuilder( + column: $table.isLoggedIn, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastAuthenticatedAt => $composableBuilder( + column: $table.lastAuthenticatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$OnlineAccountSessionsTableAnnotationComposer + extends Composer<_$AppDatabase, $OnlineAccountSessionsTable> { + $$OnlineAccountSessionsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get serverUserId => $composableBuilder( + column: $table.serverUserId, + builder: (column) => column, + ); + + GeneratedColumn get email => + $composableBuilder(column: $table.email, builder: (column) => column); + + GeneratedColumn get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => column, + ); + + GeneratedColumn get isLoggedIn => $composableBuilder( + column: $table.isLoggedIn, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get lastAuthenticatedAt => $composableBuilder( + column: $table.lastAuthenticatedAt, + builder: (column) => column, + ); +} + +class $$OnlineAccountSessionsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $OnlineAccountSessionsTable, + OnlineAccountSession, + $$OnlineAccountSessionsTableFilterComposer, + $$OnlineAccountSessionsTableOrderingComposer, + $$OnlineAccountSessionsTableAnnotationComposer, + $$OnlineAccountSessionsTableCreateCompanionBuilder, + $$OnlineAccountSessionsTableUpdateCompanionBuilder, + ( + OnlineAccountSession, + BaseReferences< + _$AppDatabase, + $OnlineAccountSessionsTable, + OnlineAccountSession + >, + ), + OnlineAccountSession, + PrefetchHooks Function() + > { + $$OnlineAccountSessionsTableTableManager( + _$AppDatabase db, + $OnlineAccountSessionsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$OnlineAccountSessionsTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$OnlineAccountSessionsTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$OnlineAccountSessionsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value serverUserId = const Value.absent(), + Value email = const Value.absent(), + Value displayName = const Value.absent(), + Value isLoggedIn = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value lastAuthenticatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => OnlineAccountSessionsCompanion( + id: id, + serverUserId: serverUserId, + email: email, + displayName: displayName, + isLoggedIn: isLoggedIn, + createdAt: createdAt, + updatedAt: updatedAt, + lastAuthenticatedAt: lastAuthenticatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String serverUserId, + required String email, + Value displayName = const Value.absent(), + required bool isLoggedIn, + required DateTime createdAt, + required DateTime updatedAt, + Value lastAuthenticatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => OnlineAccountSessionsCompanion.insert( + id: id, + serverUserId: serverUserId, + email: email, + displayName: displayName, + isLoggedIn: isLoggedIn, + createdAt: createdAt, + updatedAt: updatedAt, + lastAuthenticatedAt: lastAuthenticatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$OnlineAccountSessionsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $OnlineAccountSessionsTable, + OnlineAccountSession, + $$OnlineAccountSessionsTableFilterComposer, + $$OnlineAccountSessionsTableOrderingComposer, + $$OnlineAccountSessionsTableAnnotationComposer, + $$OnlineAccountSessionsTableCreateCompanionBuilder, + $$OnlineAccountSessionsTableUpdateCompanionBuilder, + ( + OnlineAccountSession, + BaseReferences< + _$AppDatabase, + $OnlineAccountSessionsTable, + OnlineAccountSession + >, + ), + OnlineAccountSession, + PrefetchHooks Function() + >; typedef $$ProgramsTableCreateCompanionBuilder = ProgramsCompanion Function({ required String id, @@ -41434,6 +42267,8 @@ class $AppDatabaseManager { $$ExerciseImagesTableTableManager(_db, _db.exerciseImages); $$ExerciseStepsTableTableManager get exerciseSteps => $$ExerciseStepsTableTableManager(_db, _db.exerciseSteps); + $$OnlineAccountSessionsTableTableManager get onlineAccountSessions => + $$OnlineAccountSessionsTableTableManager(_db, _db.onlineAccountSessions); $$ProgramsTableTableManager get programs => $$ProgramsTableTableManager(_db, _db.programs); $$ProgramExercisesTableTableManager get programExercises => diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index d7a1632..21ad754 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -140,6 +140,34 @@ final class DriftMediaAssetRepository implements MediaAssetRepository { } } +final class DriftOnlineAccountRepository implements OnlineAccountRepository { + const DriftOnlineAccountRepository(this.database); + + final db.AppDatabase database; + + @override + Future currentSession() async { + final row = + await (database.select(database.onlineAccountSessions) + ..orderBy([(table) => OrderingTerm.desc(table.updatedAt)]) + ..limit(1)) + .getSingleOrNull(); + return row == null ? null : _userAccountSessionFromRow(row); + } + + @override + Future saveSession(domain.UserAccountSession session) async { + await database + .into(database.onlineAccountSessions) + .insertOnConflictUpdate(_userAccountSessionCompanion(session)); + } + + @override + Future clearSession() async { + await database.delete(database.onlineAccountSessions).go(); + } +} + final class DriftProgramRepository implements ProgramRepository { const DriftProgramRepository(this.database); @@ -1471,6 +1499,38 @@ domain.MediaAsset _mediaAssetFromRow(db.MediaAsset row) { ); } +db.OnlineAccountSessionsCompanion _userAccountSessionCompanion( + domain.UserAccountSession session, +) { + return db.OnlineAccountSessionsCompanion( + id: Value(session.id), + serverUserId: Value(session.serverUserId), + email: Value(session.email), + displayName: Value(session.displayName), + isLoggedIn: Value(session.isLoggedIn), + createdAt: Value(session.createdAt.toUtc()), + updatedAt: Value(session.updatedAt.toUtc()), + lastAuthenticatedAt: Value( + _utcOrNull(session.lastAuthenticatedAt), + ), + ); +} + +domain.UserAccountSession _userAccountSessionFromRow( + db.OnlineAccountSession row, +) { + return domain.UserAccountSession( + id: row.id, + serverUserId: row.serverUserId, + email: row.email, + displayName: row.displayName, + isLoggedIn: row.isLoggedIn, + createdAt: _utc(row.createdAt), + updatedAt: _utc(row.updatedAt), + lastAuthenticatedAt: _utcOrNull(row.lastAuthenticatedAt), + ); +} + db.ProgramsCompanion _programCompanion(domain.Program program) { final values = _metadataValues(program.metadata); return db.ProgramsCompanion( @@ -1931,8 +1991,7 @@ _activeExerciseStepProgressStateCompanion( ); } -domain.ActiveExerciseStepProgressState -_activeExerciseStepProgressStateFromRow( +domain.ActiveExerciseStepProgressState _activeExerciseStepProgressStateFromRow( db.ActiveExerciseStepProgressState row, ) { return domain.ActiveExerciseStepProgressState( diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index 6ab7e85..eb2a280 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -24,6 +24,30 @@ abstract class SyncableTable extends Table { List get customConstraints => const []; } +class OnlineAccountSessions extends Table { + @override + String get tableName => 'online_account_sessions'; + + TextColumn get id => text()(); + TextColumn get serverUserId => text().withLength(min: 1)(); + TextColumn get email => text().withLength(min: 1)(); + TextColumn get displayName => text().nullable()(); + BoolColumn get isLoggedIn => boolean()(); + DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get updatedAt => dateTime()(); + DateTimeColumn get lastAuthenticatedAt => dateTime().nullable()(); + + @override + Set get primaryKey => {id}; + + @override + List get customConstraints => [ + 'UNIQUE (server_user_id)', + 'CHECK (length(trim(email)) > 0)', + 'CHECK (display_name IS NULL OR length(trim(display_name)) > 0)', + ]; +} + class MediaAssets extends SyncableTable { @override String get tableName => 'media_assets'; diff --git a/lib/infrastructure/remote/auth_api.dart b/lib/infrastructure/remote/auth_api.dart new file mode 100644 index 0000000..5e9925f --- /dev/null +++ b/lib/infrastructure/remote/auth_api.dart @@ -0,0 +1,71 @@ +import '../../application/application.dart'; +import 'http_api_client.dart'; + +final class HttpRemoteAuthApi implements RemoteAuthApi { + const HttpRemoteAuthApi(this.client); + + final HttpApiClient client; + + @override + Future register({ + required String email, + required String password, + String? displayName, + }) async { + final registered = await client.postJson( + '/auth/register', + body: { + 'email': email, + 'password': password, + if (displayName != null) 'displayName': displayName, + }, + expectedStatuses: const {201}, + ); + final userId = _requiredString(registered, 'userId'); + final registeredEmail = _requiredString(registered, 'email'); + final loggedIn = await login(email: email, password: password); + return RemoteAuthResult( + userId: userId, + email: registeredEmail, + token: loggedIn.token, + expiresAt: loggedIn.expiresAt, + ); + } + + @override + Future login({ + required String email, + required String password, + }) async { + final response = await client.postJson( + '/auth/login', + body: {'email': email, 'password': password}, + ); + return RemoteAuthResult( + userId: response['userId'] is String + ? response['userId'] as String + : email.trim().toLowerCase(), + email: response['email'] is String + ? response['email'] as String + : email.trim().toLowerCase(), + token: _requiredString(response, 'token'), + expiresAt: DateTime.parse(_requiredString(response, 'expiresAt')).toUtc(), + ); + } + + @override + Future logout(String token) { + return client.postEmpty('/auth/logout', bearerToken: token); + } + + String _requiredString(Map json, String key) { + final value = json[key]; + if (value is String && value.trim().isNotEmpty) { + return value; + } + throw RemoteAuthException( + RemoteAuthFailure.unknown, + '$key is missing from auth response.', + ); + } +} diff --git a/lib/infrastructure/remote/http_api_client.dart b/lib/infrastructure/remote/http_api_client.dart new file mode 100644 index 0000000..bc7520d --- /dev/null +++ b/lib/infrastructure/remote/http_api_client.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../application/application.dart'; + +final class HttpApiClient { + HttpApiClient({ + required this.baseUrl, + http.Client? client, + this.timeout = const Duration(seconds: 10), + }) : client = client ?? http.Client(); + + static const defaultBaseUrl = String.fromEnvironment( + 'GAMETIME_API_BASE_URL', + defaultValue: 'http://localhost:8080', + ); + + final Uri baseUrl; + final http.Client client; + final Duration timeout; + + Future> postJson( + String path, { + Map? body, + String? bearerToken, + Set expectedStatuses = const {200, 201}, + }) async { + final response = await _send( + () => client.post( + _resolve(path), + headers: _headers(bearerToken), + body: body == null ? null : jsonEncode(body), + ), + ); + if (!expectedStatuses.contains(response.statusCode)) { + throw _exceptionForStatus(response.statusCode, response.body); + } + if (response.body.trim().isEmpty) { + return const {}; + } + final Object? decoded; + try { + decoded = jsonDecode(response.body); + } on FormatException catch (error) { + throw RemoteAuthException(RemoteAuthFailure.unknown, error.message); + } + if (decoded is Map) { + return Map.from(decoded); + } + throw const RemoteAuthException( + RemoteAuthFailure.unknown, + 'Unexpected JSON response.', + ); + } + + Future postEmpty( + String path, { + String? bearerToken, + Set expectedStatuses = const {204}, + }) async { + final response = await _send( + () => client.post(_resolve(path), headers: _headers(bearerToken)), + ); + if (!expectedStatuses.contains(response.statusCode)) { + throw _exceptionForStatus(response.statusCode, response.body); + } + } + + Uri _resolve(String path) { + final normalized = path.startsWith('/') ? path.substring(1) : path; + final base = baseUrl.toString().endsWith('/') + ? baseUrl + : Uri.parse('${baseUrl.toString()}/'); + return base.resolve(normalized); + } + + Map _headers(String? bearerToken) => { + 'accept': 'application/json', + 'content-type': 'application/json', + if (bearerToken != null) 'authorization': 'Bearer $bearerToken', + }; + + Future _send(Future Function() send) async { + try { + return await send().timeout(timeout); + } on TimeoutException { + throw const RemoteAuthException(RemoteAuthFailure.network, 'Timeout.'); + } on http.ClientException catch (error) { + throw RemoteAuthException(RemoteAuthFailure.network, error.message); + } on FormatException catch (error) { + throw RemoteAuthException(RemoteAuthFailure.unknown, error.message); + } + } + + RemoteAuthException _exceptionForStatus(int statusCode, String body) { + final message = _messageFromBody(body); + return switch (statusCode) { + 401 => RemoteAuthException(RemoteAuthFailure.invalidCredentials, message), + 409 => RemoteAuthException(RemoteAuthFailure.emailAlreadyUsed, message), + >= 500 => RemoteAuthException(RemoteAuthFailure.network, message), + _ => RemoteAuthException(RemoteAuthFailure.unknown, message), + }; + } + + String? _messageFromBody(String body) { + if (body.trim().isEmpty) { + return null; + } + try { + final decoded = jsonDecode(body); + if (decoded is Map && decoded['error'] is String) { + return decoded['error'] as String; + } + if (decoded is Map && decoded['message'] is String) { + return decoded['message'] as String; + } + } on FormatException { + return body; + } + return body; + } +} diff --git a/lib/infrastructure/remote/remote.dart b/lib/infrastructure/remote/remote.dart new file mode 100644 index 0000000..37d10b8 --- /dev/null +++ b/lib/infrastructure/remote/remote.dart @@ -0,0 +1,2 @@ +export 'auth_api.dart'; +export 'http_api_client.dart'; diff --git a/lib/infrastructure/security/secure_storage_auth_token_store.dart b/lib/infrastructure/security/secure_storage_auth_token_store.dart new file mode 100644 index 0000000..98c329a --- /dev/null +++ b/lib/infrastructure/security/secure_storage_auth_token_store.dart @@ -0,0 +1,44 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../../application/application.dart'; + +final class SecureStorageAuthTokenStore implements AuthTokenStore { + const SecureStorageAuthTokenStore({ + FlutterSecureStorage storage = const FlutterSecureStorage(), + }) : _storage = storage; + + static const _tokenKey = 'gametime.auth.token'; + static const _expiresAtKey = 'gametime.auth.expiresAt'; + + final FlutterSecureStorage _storage; + + @override + Future saveToken(String token, DateTime expiresAt) async { + await _storage.write(key: _tokenKey, value: token); + await _storage.write( + key: _expiresAtKey, + value: expiresAt.toUtc().toIso8601String(), + ); + } + + @override + Future readToken() async { + final token = await _storage.read(key: _tokenKey); + final expiresAtValue = await _storage.read(key: _expiresAtKey); + if (token == null || expiresAtValue == null) { + return token; + } + final expiresAt = DateTime.tryParse(expiresAtValue)?.toUtc(); + if (expiresAt != null && !expiresAt.isAfter(DateTime.now().toUtc())) { + await clearToken(); + return null; + } + return token; + } + + @override + Future clearToken() async { + await _storage.delete(key: _tokenKey); + await _storage.delete(key: _expiresAtKey); + } +} diff --git a/lib/infrastructure/security/security.dart b/lib/infrastructure/security/security.dart new file mode 100644 index 0000000..1a3de13 --- /dev/null +++ b/lib/infrastructure/security/security.dart @@ -0,0 +1 @@ +export 'secure_storage_auth_token_store.dart'; diff --git a/lib/presentation/exercise_step_audio.dart b/lib/presentation/exercise_step_audio.dart index ad07739..c97da64 100644 --- a/lib/presentation/exercise_step_audio.dart +++ b/lib/presentation/exercise_step_audio.dart @@ -35,7 +35,8 @@ final class AudioplayersExerciseStepAudioCuePlayer } } -final class NoOpExerciseStepAudioCuePlayer implements ExerciseStepAudioCuePlayer { +final class NoOpExerciseStepAudioCuePlayer + implements ExerciseStepAudioCuePlayer { const NoOpExerciseStepAudioCuePlayer(); @override diff --git a/pubspec.lock b/pubspec.lock index 589efe6..d4625b1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -374,6 +374,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.35" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -417,7 +465,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -536,6 +584,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: transitive description: @@ -997,6 +1053,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a307db7..4a04a36 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,8 @@ dependencies: cupertino_icons: ^1.0.8 drift: ^2.34.2 drift_flutter: ^0.3.1 + flutter_secure_storage: ^9.2.4 + http: ^1.5.0 image_picker: ^1.2.3 audioplayers: ^6.8.1 path: ^1.9.1 diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 664792b..dca299d 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -1107,6 +1107,150 @@ void main() { }, ); + test('AuthUseCases register stores token and account session', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final tokenStore = _FakeAuthTokenStore(); + final accountRepository = _FakeOnlineAccountRepository(); + final remoteAuthApi = _FakeRemoteAuthApi(); + final useCase = _authUseCase( + tokenStore: tokenStore, + accountRepository: accountRepository, + remoteAuthApi: remoteAuthApi, + clock: clock, + ); + + final session = await useCase.register( + email: 'USER@example.com', + password: 'password123', + displayName: 'User', + ); + + expect(session.serverUserId, 'server-user-1'); + expect(session.email, 'user@example.com'); + expect(session.displayName, 'User'); + expect(session.isLoggedIn, isTrue); + expect(tokenStore.token, 'token-1'); + expect(accountRepository.session, session); + }); + + test('AuthUseCases register propagates email already used error', () async { + final remoteAuthApi = _FakeRemoteAuthApi() + ..registerException = const RemoteAuthException( + RemoteAuthFailure.emailAlreadyUsed, + ); + + await expectLater( + _authUseCase( + remoteAuthApi: remoteAuthApi, + ).register(email: 'user@example.com', password: 'password123'), + throwsA( + isA().having( + (error) => error.failure, + 'failure', + RemoteAuthFailure.emailAlreadyUsed, + ), + ), + ); + }); + + test('AuthUseCases login stores token and account session', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final tokenStore = _FakeAuthTokenStore(); + final accountRepository = _FakeOnlineAccountRepository(); + final remoteAuthApi = _FakeRemoteAuthApi(); + final useCase = _authUseCase( + tokenStore: tokenStore, + accountRepository: accountRepository, + remoteAuthApi: remoteAuthApi, + clock: clock, + ); + + final session = await useCase.login( + email: 'user@example.com', + password: 'password123', + ); + + expect(session.serverUserId, 'server-user-1'); + expect(session.isLoggedIn, isTrue); + expect(tokenStore.token, 'token-1'); + expect(tokenStore.expiresAt, DateTime.utc(2026, 8, 16, 12)); + expect(accountRepository.session?.lastAuthenticatedAt, clock.now()); + }); + + test('AuthUseCases login propagates invalid credentials', () async { + final remoteAuthApi = _FakeRemoteAuthApi() + ..loginException = const RemoteAuthException( + RemoteAuthFailure.invalidCredentials, + ); + + await expectLater( + _authUseCase( + remoteAuthApi: remoteAuthApi, + ).login(email: 'user@example.com', password: 'wrong-password'), + throwsA( + isA().having( + (error) => error.failure, + 'failure', + RemoteAuthFailure.invalidCredentials, + ), + ), + ); + }); + + test('AuthUseCases logout clears local token even if remote fails', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final tokenStore = _FakeAuthTokenStore() + ..token = 'token-1' + ..expiresAt = DateTime.utc(2026, 8, 16, 12); + final accountRepository = _FakeOnlineAccountRepository() + ..session = UserAccountSession( + id: 'session-1', + serverUserId: 'server-user-1', + email: 'user@example.com', + isLoggedIn: true, + createdAt: DateTime.utc(2026, 7, 17, 11), + updatedAt: DateTime.utc(2026, 7, 17, 11), + ); + final remoteAuthApi = _FakeRemoteAuthApi() + ..logoutException = const RemoteAuthException(RemoteAuthFailure.network); + + await _authUseCase( + tokenStore: tokenStore, + accountRepository: accountRepository, + remoteAuthApi: remoteAuthApi, + clock: clock, + ).logout(); + + expect(tokenStore.token, isNull); + expect(accountRepository.session?.isLoggedIn, isFalse); + expect(remoteAuthApi.logoutCalls, 1); + }); + + test('AuthUseCases currentSession reads local cache only', () async { + final tokenStore = _FakeAuthTokenStore()..token = 'token-1'; + final accountRepository = _FakeOnlineAccountRepository() + ..session = UserAccountSession( + id: 'session-1', + serverUserId: 'server-user-1', + email: 'user@example.com', + isLoggedIn: true, + createdAt: DateTime.utc(2026, 7, 17, 11), + updatedAt: DateTime.utc(2026, 7, 17, 11), + ); + final remoteAuthApi = _FakeRemoteAuthApi(); + + final session = await _authUseCase( + tokenStore: tokenStore, + accountRepository: accountRepository, + remoteAuthApi: remoteAuthApi, + ).currentSession(); + + expect(session?.email, 'user@example.com'); + expect(remoteAuthApi.registerCalls, 0); + expect(remoteAuthApi.loginCalls, 0); + expect(remoteAuthApi.logoutCalls, 0); + }); + test('score result enforces manual xor stopwatch values', () { expect( () => ActiveSetResult( @@ -1209,6 +1353,114 @@ final class _FakeIds implements IdGenerator { } } +AuthUseCases _authUseCase({ + _FakeAuthTokenStore? tokenStore, + _FakeOnlineAccountRepository? accountRepository, + _FakeRemoteAuthApi? remoteAuthApi, + _FakeClock? clock, +}) { + return AuthUseCases( + tokenStore: tokenStore ?? _FakeAuthTokenStore(), + accountRepository: accountRepository ?? _FakeOnlineAccountRepository(), + remoteAuthApi: remoteAuthApi ?? _FakeRemoteAuthApi(), + clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + ); +} + +final class _FakeAuthTokenStore implements AuthTokenStore { + String? token; + DateTime? expiresAt; + + @override + Future clearToken() async { + token = null; + expiresAt = null; + } + + @override + Future readToken() async => token; + + @override + Future saveToken(String token, DateTime expiresAt) async { + this.token = token; + this.expiresAt = expiresAt; + } +} + +final class _FakeOnlineAccountRepository implements OnlineAccountRepository { + UserAccountSession? session; + var clearCalls = 0; + + @override + Future clearSession() async { + clearCalls += 1; + session = null; + } + + @override + Future currentSession() async => session; + + @override + Future saveSession(UserAccountSession session) async { + this.session = session; + } +} + +final class _FakeRemoteAuthApi implements RemoteAuthApi { + RemoteAuthException? registerException; + RemoteAuthException? loginException; + RemoteAuthException? logoutException; + var registerCalls = 0; + var loginCalls = 0; + var logoutCalls = 0; + + @override + Future register({ + required String email, + required String password, + String? displayName, + }) async { + registerCalls += 1; + final exception = registerException; + if (exception != null) { + throw exception; + } + return _authResult(email); + } + + @override + Future login({ + required String email, + required String password, + }) async { + loginCalls += 1; + final exception = loginException; + if (exception != null) { + throw exception; + } + return _authResult(email); + } + + @override + Future logout(String token) async { + logoutCalls += 1; + final exception = logoutException; + if (exception != null) { + throw exception; + } + } + + RemoteAuthResult _authResult(String email) { + return RemoteAuthResult( + userId: 'server-user-1', + email: email.trim().toLowerCase(), + token: 'token-1', + expiresAt: DateTime.utc(2026, 8, 16, 12), + ); + } +} + ExerciseUseCases _exerciseUseCase( _FakeExerciseRepository repository, { _FakeProgramRepository? programRepository, diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 43ff150..c1086a1 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -115,7 +115,14 @@ void main() { final class _FakeBootstrap implements AppDependencies { _FakeBootstrap(_FakeActiveSessionRepository activeRepository) - : activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases( + : authUseCases = AuthUseCases( + tokenStore: _FakeAuthTokenStore(), + accountRepository: _FakeOnlineAccountRepository(), + remoteAuthApi: _FakeRemoteAuthApi(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + ), + activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases( sessionRepository: activeRepository, templateRepository: _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), @@ -170,6 +177,9 @@ final class _FakeBootstrap implements AppDependencies { clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ); + @override + final AuthUseCases authUseCases; + @override final ExerciseUseCases exerciseUseCases; @@ -249,6 +259,50 @@ final class _FakeIds implements IdGenerator { } } +final class _FakeAuthTokenStore implements AuthTokenStore { + @override + Future clearToken() async {} + + @override + Future readToken() async => null; + + @override + Future saveToken(String token, DateTime expiresAt) async {} +} + +final class _FakeOnlineAccountRepository implements OnlineAccountRepository { + @override + Future clearSession() async {} + + @override + Future currentSession() async => null; + + @override + Future saveSession(UserAccountSession session) async {} +} + +final class _FakeRemoteAuthApi implements RemoteAuthApi { + @override + Future login({ + required String email, + required String password, + }) async { + throw const RemoteAuthException(RemoteAuthFailure.network); + } + + @override + Future logout(String token) async {} + + @override + Future register({ + required String email, + required String password, + String? displayName, + }) async { + throw const RemoteAuthException(RemoteAuthFailure.network); + } +} + final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final restStates = [];