feat(online): session compte, stockage sécurisé et adapter API (ticket #64)

Ajoute l'adapter API distant (infrastructure/remote/auth_api.dart,
http_api_client.dart) et le stockage sécurisé du token d'auth
(infrastructure/security/secure_storage_auth_token_store.dart), câblés
dans app_bootstrap.dart. Étend le modèle Drift (migration
schemaVersion 9→10) et la couche application (ports, use cases,
entités) pour la session de compte. flutter pub get OK (ajout http,
flutter_secure_storage), build_runner OK, dart format appliqué,
analyze propre (mêmes infos préexistantes), 109/109 tests verts,
build APK debug validé. Premier ticket du chantier "Ajouter les
features serveur au client" (#63).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:25:49 +02:00
parent a557708273
commit e60c8bbdb8
19 changed files with 1799 additions and 7 deletions

View File

@ -1,7 +1,10 @@
import '../infrastructure/local/local.dart'; import '../infrastructure/local/local.dart';
import '../infrastructure/remote/remote.dart';
import '../infrastructure/security/security.dart';
import 'application.dart'; import 'application.dart';
abstract interface class AppDependencies { abstract interface class AppDependencies {
AuthUseCases get authUseCases;
ExerciseUseCases get exerciseUseCases; ExerciseUseCases get exerciseUseCases;
MediaUseCases get mediaUseCases; MediaUseCases get mediaUseCases;
ProgramUseCases get programUseCases; ProgramUseCases get programUseCases;
@ -15,6 +18,7 @@ abstract interface class AppDependencies {
final class AppBootstrap implements AppDependencies { final class AppBootstrap implements AppDependencies {
AppBootstrap._({ AppBootstrap._({
required this.database, required this.database,
required this.authUseCases,
required this.exerciseUseCases, required this.exerciseUseCases,
required this.mediaUseCases, required this.mediaUseCases,
required this.programUseCases, required this.programUseCases,
@ -28,6 +32,8 @@ final class AppBootstrap implements AppDependencies {
final AppDatabase database; final AppDatabase database;
@override @override
final AuthUseCases authUseCases;
@override
final ExerciseUseCases exerciseUseCases; final ExerciseUseCases exerciseUseCases;
@override @override
final MediaUseCases mediaUseCases; final MediaUseCases mediaUseCases;
@ -50,6 +56,7 @@ final class AppBootstrap implements AppDependencies {
await database.customSelect('SELECT 1').get(); await database.customSelect('SELECT 1').get();
final exerciseRepository = DriftExerciseRepository(database); final exerciseRepository = DriftExerciseRepository(database);
final mediaRepository = DriftMediaAssetRepository(database); final mediaRepository = DriftMediaAssetRepository(database);
final onlineAccountRepository = DriftOnlineAccountRepository(database);
final programRepository = DriftProgramRepository(database); final programRepository = DriftProgramRepository(database);
final templateRepository = DriftWorkoutTemplateRepository(database); final templateRepository = DriftWorkoutTemplateRepository(database);
final activeSessionRepository = DriftActiveSessionRepository(database); final activeSessionRepository = DriftActiveSessionRepository(database);
@ -57,9 +64,19 @@ final class AppBootstrap implements AppDependencies {
final ids = LocalIdGenerator(); final ids = LocalIdGenerator();
const clock = SystemClock(); const clock = SystemClock();
const originDeviceId = 'local-device'; const originDeviceId = 'local-device';
final remoteAuthApi = HttpRemoteAuthApi(
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
);
return AppBootstrap._( return AppBootstrap._(
database: database, database: database,
authUseCases: AuthUseCases(
tokenStore: const SecureStorageAuthTokenStore(),
accountRepository: onlineAccountRepository,
remoteAuthApi: remoteAuthApi,
clock: clock,
ids: ids,
),
exerciseUseCases: ExerciseUseCases( exerciseUseCases: ExerciseUseCases(
repository: exerciseRepository, repository: exerciseRepository,
programRepository: programRepository, programRepository: programRepository,

View File

@ -15,6 +15,64 @@ abstract interface class IdGenerator {
String newId(); 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<void> saveToken(String token, DateTime expiresAt);
Future<String?> readToken();
Future<void> clearToken();
}
abstract interface class OnlineAccountRepository {
Future<UserAccountSession?> currentSession();
Future<void> saveSession(UserAccountSession session);
Future<void> clearSession();
}
abstract interface class RemoteAuthApi {
Future<RemoteAuthResult> register({
required String email,
required String password,
String? displayName,
});
Future<RemoteAuthResult> login({
required String email,
required String password,
});
Future<void> logout(String token);
}
abstract interface class ExerciseRepository { abstract interface class ExerciseRepository {
Future<Exercise?> findById(String id); Future<Exercise?> findById(String id);
Future<List<Exercise>> listActive(); Future<List<Exercise>> listActive();

View File

@ -5,6 +5,107 @@ import 'ports.dart';
const Object _useCaseUnchanged = Object(); 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<UserAccountSession> 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<UserAccountSession> 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<void> 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<UserAccountSession?> 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<UserAccountSession> _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 { final class ExerciseUseCases {
const ExerciseUseCases({ const ExerciseUseCases({
required this.repository, required this.repository,
@ -2264,6 +2365,19 @@ void _validateExerciseSteps(List<ExerciseStep> 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({ void _validateExerciseDefaultTargets({
required bool hasTimeMeasure, required bool hasTimeMeasure,
required bool hasRepsMeasure, required bool hasRepsMeasure,

View File

@ -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 { final class MediaAsset {
const MediaAsset({ const MediaAsset({
required this.metadata, required this.metadata,
@ -997,7 +1049,9 @@ final class ActiveExerciseStepProgressState {
currentStepSnapshotId: currentStepSnapshotId:
currentStepSnapshotId ?? this.currentStepSnapshotId, currentStepSnapshotId ?? this.currentStepSnapshotId,
status: status ?? this.status, status: status ?? this.status,
startedAt: startedAt == _unchanged ? this.startedAt : startedAt as DateTime?, startedAt: startedAt == _unchanged
? this.startedAt
: startedAt as DateTime?,
accumulatedMs: accumulatedMs ?? this.accumulatedMs, accumulatedMs: accumulatedMs ?? this.accumulatedMs,
lastTransitionAt: lastTransitionAt ?? this.lastTransitionAt, lastTransitionAt: lastTransitionAt ?? this.lastTransitionAt,
); );

View File

@ -3,3 +3,7 @@
/// Concrete adapters live here and depend inward on application/domain /// Concrete adapters live here and depend inward on application/domain
/// contracts. /// contracts.
library; library;
export 'local/local.dart';
export 'remote/remote.dart';
export 'security/security.dart';

View File

@ -18,6 +18,7 @@ part 'app_database.g.dart';
ExerciseImages, ExerciseImages,
ExerciseSteps, ExerciseSteps,
MediaAssets, MediaAssets,
OnlineAccountSessions,
ProgramExercises, ProgramExercises,
Programs, Programs,
WorkoutHistories, WorkoutHistories,
@ -41,7 +42,7 @@ final class AppDatabase extends _$AppDatabase {
} }
@override @override
int get schemaVersion => 9; int get schemaVersion => 10;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -83,6 +84,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 9) { if (from < 9) {
await _migrateToSchema9(migrator); await _migrateToSchema9(migrator);
} }
if (from < 10) {
await _migrateToSchema10(migrator);
}
await _createIndexes(); await _createIndexes();
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
@ -123,6 +127,10 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_exercise_steps_exercise_id_position ' 'CREATE INDEX IF NOT EXISTS idx_exercise_steps_exercise_id_position '
'ON 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( await customStatement(
'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id ' 'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id '
'ON workout_template_programs (workout_template_id)', 'ON workout_template_programs (workout_template_id)',
@ -315,4 +323,8 @@ extension on AppDatabase {
await migrator.createTable(activeExerciseStepResults); await migrator.createTable(activeExerciseStepResults);
await migrator.createTable(workoutHistoryStepResults); await migrator.createTable(workoutHistoryStepResults);
} }
Future<void> _migrateToSchema10(Migrator migrator) async {
await migrator.createTable(onlineAccountSessions);
}
} }

View File

@ -14914,6 +14914,552 @@ class ExerciseStepsCompanion extends UpdateCompanion<ExerciseStep> {
} }
} }
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<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _serverUserIdMeta = const VerificationMeta(
'serverUserId',
);
@override
late final GeneratedColumn<String> serverUserId = GeneratedColumn<String>(
'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<String> email = GeneratedColumn<String>(
'email',
aliasedName,
false,
additionalChecks: GeneratedColumn.checkTextLength(minTextLength: 1),
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _displayNameMeta = const VerificationMeta(
'displayName',
);
@override
late final GeneratedColumn<String> displayName = GeneratedColumn<String>(
'display_name',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _isLoggedInMeta = const VerificationMeta(
'isLoggedIn',
);
@override
late final GeneratedColumn<bool> isLoggedIn = GeneratedColumn<bool>(
'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<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
static const VerificationMeta _updatedAtMeta = const VerificationMeta(
'updatedAt',
);
@override
late final GeneratedColumn<DateTime> updatedAt = GeneratedColumn<DateTime>(
'updated_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
static const VerificationMeta _lastAuthenticatedAtMeta =
const VerificationMeta('lastAuthenticatedAt');
@override
late final GeneratedColumn<DateTime> lastAuthenticatedAt =
GeneratedColumn<DateTime>(
'last_authenticated_at',
aliasedName,
true,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> 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<OnlineAccountSession> 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<GeneratedColumn> get $primaryKey => {id};
@override
OnlineAccountSession map(Map<String, dynamic> 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<OnlineAccountSession> {
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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['server_user_id'] = Variable<String>(serverUserId);
map['email'] = Variable<String>(email);
if (!nullToAbsent || displayName != null) {
map['display_name'] = Variable<String>(displayName);
}
map['is_logged_in'] = Variable<bool>(isLoggedIn);
map['created_at'] = Variable<DateTime>(createdAt);
map['updated_at'] = Variable<DateTime>(updatedAt);
if (!nullToAbsent || lastAuthenticatedAt != null) {
map['last_authenticated_at'] = Variable<DateTime>(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<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return OnlineAccountSession(
id: serializer.fromJson<String>(json['id']),
serverUserId: serializer.fromJson<String>(json['serverUserId']),
email: serializer.fromJson<String>(json['email']),
displayName: serializer.fromJson<String?>(json['displayName']),
isLoggedIn: serializer.fromJson<bool>(json['isLoggedIn']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
lastAuthenticatedAt: serializer.fromJson<DateTime?>(
json['lastAuthenticatedAt'],
),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'serverUserId': serializer.toJson<String>(serverUserId),
'email': serializer.toJson<String>(email),
'displayName': serializer.toJson<String?>(displayName),
'isLoggedIn': serializer.toJson<bool>(isLoggedIn),
'createdAt': serializer.toJson<DateTime>(createdAt),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
'lastAuthenticatedAt': serializer.toJson<DateTime?>(lastAuthenticatedAt),
};
}
OnlineAccountSession copyWith({
String? id,
String? serverUserId,
String? email,
Value<String?> displayName = const Value.absent(),
bool? isLoggedIn,
DateTime? createdAt,
DateTime? updatedAt,
Value<DateTime?> 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<OnlineAccountSession> {
final Value<String> id;
final Value<String> serverUserId;
final Value<String> email;
final Value<String?> displayName;
final Value<bool> isLoggedIn;
final Value<DateTime> createdAt;
final Value<DateTime> updatedAt;
final Value<DateTime?> lastAuthenticatedAt;
final Value<int> 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<OnlineAccountSession> custom({
Expression<String>? id,
Expression<String>? serverUserId,
Expression<String>? email,
Expression<String>? displayName,
Expression<bool>? isLoggedIn,
Expression<DateTime>? createdAt,
Expression<DateTime>? updatedAt,
Expression<DateTime>? lastAuthenticatedAt,
Expression<int>? 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<String>? id,
Value<String>? serverUserId,
Value<String>? email,
Value<String?>? displayName,
Value<bool>? isLoggedIn,
Value<DateTime>? createdAt,
Value<DateTime>? updatedAt,
Value<DateTime?>? lastAuthenticatedAt,
Value<int>? 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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (serverUserId.present) {
map['server_user_id'] = Variable<String>(serverUserId.value);
}
if (email.present) {
map['email'] = Variable<String>(email.value);
}
if (displayName.present) {
map['display_name'] = Variable<String>(displayName.value);
}
if (isLoggedIn.present) {
map['is_logged_in'] = Variable<bool>(isLoggedIn.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (updatedAt.present) {
map['updated_at'] = Variable<DateTime>(updatedAt.value);
}
if (lastAuthenticatedAt.present) {
map['last_authenticated_at'] = Variable<DateTime>(
lastAuthenticatedAt.value,
);
}
if (rowid.present) {
map['rowid'] = Variable<int>(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> { class $ProgramsTable extends Programs with TableInfo<$ProgramsTable, Program> {
@override @override
final GeneratedDatabase attachedDatabase; final GeneratedDatabase attachedDatabase;
@ -25542,6 +26088,8 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $ExercisesTable exercises = $ExercisesTable(this); late final $ExercisesTable exercises = $ExercisesTable(this);
late final $ExerciseImagesTable exerciseImages = $ExerciseImagesTable(this); late final $ExerciseImagesTable exerciseImages = $ExerciseImagesTable(this);
late final $ExerciseStepsTable exerciseSteps = $ExerciseStepsTable(this); late final $ExerciseStepsTable exerciseSteps = $ExerciseStepsTable(this);
late final $OnlineAccountSessionsTable onlineAccountSessions =
$OnlineAccountSessionsTable(this);
late final $ProgramsTable programs = $ProgramsTable(this); late final $ProgramsTable programs = $ProgramsTable(this);
late final $ProgramExercisesTable programExercises = $ProgramExercisesTable( late final $ProgramExercisesTable programExercises = $ProgramExercisesTable(
this, this,
@ -25576,6 +26124,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
exercises, exercises,
exerciseImages, exerciseImages,
exerciseSteps, exerciseSteps,
onlineAccountSessions,
programs, programs,
programExercises, programExercises,
workoutHistories, workoutHistories,
@ -35150,6 +35699,290 @@ typedef $$ExerciseStepsTableProcessedTableManager =
ExerciseStep, ExerciseStep,
PrefetchHooks Function({bool exerciseId}) PrefetchHooks Function({bool exerciseId})
>; >;
typedef $$OnlineAccountSessionsTableCreateCompanionBuilder =
OnlineAccountSessionsCompanion Function({
required String id,
required String serverUserId,
required String email,
Value<String?> displayName,
required bool isLoggedIn,
required DateTime createdAt,
required DateTime updatedAt,
Value<DateTime?> lastAuthenticatedAt,
Value<int> rowid,
});
typedef $$OnlineAccountSessionsTableUpdateCompanionBuilder =
OnlineAccountSessionsCompanion Function({
Value<String> id,
Value<String> serverUserId,
Value<String> email,
Value<String?> displayName,
Value<bool> isLoggedIn,
Value<DateTime> createdAt,
Value<DateTime> updatedAt,
Value<DateTime?> lastAuthenticatedAt,
Value<int> rowid,
});
class $$OnlineAccountSessionsTableFilterComposer
extends Composer<_$AppDatabase, $OnlineAccountSessionsTable> {
$$OnlineAccountSessionsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get serverUserId => $composableBuilder(
column: $table.serverUserId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get email => $composableBuilder(
column: $table.email,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get displayName => $composableBuilder(
column: $table.displayName,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get isLoggedIn => $composableBuilder(
column: $table.isLoggedIn,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> 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<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get serverUserId => $composableBuilder(
column: $table.serverUserId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get email => $composableBuilder(
column: $table.email,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get displayName => $composableBuilder(
column: $table.displayName,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get isLoggedIn => $composableBuilder(
column: $table.isLoggedIn,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> 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<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get serverUserId => $composableBuilder(
column: $table.serverUserId,
builder: (column) => column,
);
GeneratedColumn<String> get email =>
$composableBuilder(column: $table.email, builder: (column) => column);
GeneratedColumn<String> get displayName => $composableBuilder(
column: $table.displayName,
builder: (column) => column,
);
GeneratedColumn<bool> get isLoggedIn => $composableBuilder(
column: $table.isLoggedIn,
builder: (column) => column,
);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
GeneratedColumn<DateTime> 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<String> id = const Value.absent(),
Value<String> serverUserId = const Value.absent(),
Value<String> email = const Value.absent(),
Value<String?> displayName = const Value.absent(),
Value<bool> isLoggedIn = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime> updatedAt = const Value.absent(),
Value<DateTime?> lastAuthenticatedAt = const Value.absent(),
Value<int> 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<String?> displayName = const Value.absent(),
required bool isLoggedIn,
required DateTime createdAt,
required DateTime updatedAt,
Value<DateTime?> lastAuthenticatedAt = const Value.absent(),
Value<int> 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 = typedef $$ProgramsTableCreateCompanionBuilder =
ProgramsCompanion Function({ ProgramsCompanion Function({
required String id, required String id,
@ -41434,6 +42267,8 @@ class $AppDatabaseManager {
$$ExerciseImagesTableTableManager(_db, _db.exerciseImages); $$ExerciseImagesTableTableManager(_db, _db.exerciseImages);
$$ExerciseStepsTableTableManager get exerciseSteps => $$ExerciseStepsTableTableManager get exerciseSteps =>
$$ExerciseStepsTableTableManager(_db, _db.exerciseSteps); $$ExerciseStepsTableTableManager(_db, _db.exerciseSteps);
$$OnlineAccountSessionsTableTableManager get onlineAccountSessions =>
$$OnlineAccountSessionsTableTableManager(_db, _db.onlineAccountSessions);
$$ProgramsTableTableManager get programs => $$ProgramsTableTableManager get programs =>
$$ProgramsTableTableManager(_db, _db.programs); $$ProgramsTableTableManager(_db, _db.programs);
$$ProgramExercisesTableTableManager get programExercises => $$ProgramExercisesTableTableManager get programExercises =>

View File

@ -140,6 +140,34 @@ final class DriftMediaAssetRepository implements MediaAssetRepository {
} }
} }
final class DriftOnlineAccountRepository implements OnlineAccountRepository {
const DriftOnlineAccountRepository(this.database);
final db.AppDatabase database;
@override
Future<domain.UserAccountSession?> 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<void> saveSession(domain.UserAccountSession session) async {
await database
.into(database.onlineAccountSessions)
.insertOnConflictUpdate(_userAccountSessionCompanion(session));
}
@override
Future<void> clearSession() async {
await database.delete(database.onlineAccountSessions).go();
}
}
final class DriftProgramRepository implements ProgramRepository { final class DriftProgramRepository implements ProgramRepository {
const DriftProgramRepository(this.database); 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<String?>(session.displayName),
isLoggedIn: Value(session.isLoggedIn),
createdAt: Value(session.createdAt.toUtc()),
updatedAt: Value(session.updatedAt.toUtc()),
lastAuthenticatedAt: Value<DateTime?>(
_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) { db.ProgramsCompanion _programCompanion(domain.Program program) {
final values = _metadataValues(program.metadata); final values = _metadataValues(program.metadata);
return db.ProgramsCompanion( return db.ProgramsCompanion(
@ -1931,8 +1991,7 @@ _activeExerciseStepProgressStateCompanion(
); );
} }
domain.ActiveExerciseStepProgressState domain.ActiveExerciseStepProgressState _activeExerciseStepProgressStateFromRow(
_activeExerciseStepProgressStateFromRow(
db.ActiveExerciseStepProgressState row, db.ActiveExerciseStepProgressState row,
) { ) {
return domain.ActiveExerciseStepProgressState( return domain.ActiveExerciseStepProgressState(

View File

@ -24,6 +24,30 @@ abstract class SyncableTable extends Table {
List<String> get customConstraints => const []; List<String> 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<Column> get primaryKey => {id};
@override
List<String> 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 { class MediaAssets extends SyncableTable {
@override @override
String get tableName => 'media_assets'; String get tableName => 'media_assets';

View File

@ -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<RemoteAuthResult> 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<RemoteAuthResult> 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<void> logout(String token) {
return client.postEmpty('/auth/logout', bearerToken: token);
}
String _requiredString(Map<String, Object?> 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.',
);
}
}

View File

@ -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<Map<String, Object?>> postJson(
String path, {
Map<String, Object?>? body,
String? bearerToken,
Set<int> 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<String, Object?>.from(decoded);
}
throw const RemoteAuthException(
RemoteAuthFailure.unknown,
'Unexpected JSON response.',
);
}
Future<void> postEmpty(
String path, {
String? bearerToken,
Set<int> 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<String, String> _headers(String? bearerToken) => {
'accept': 'application/json',
'content-type': 'application/json',
if (bearerToken != null) 'authorization': 'Bearer $bearerToken',
};
Future<http.Response> _send(Future<http.Response> 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;
}
}

View File

@ -0,0 +1,2 @@
export 'auth_api.dart';
export 'http_api_client.dart';

View File

@ -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<void> saveToken(String token, DateTime expiresAt) async {
await _storage.write(key: _tokenKey, value: token);
await _storage.write(
key: _expiresAtKey,
value: expiresAt.toUtc().toIso8601String(),
);
}
@override
Future<String?> 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<void> clearToken() async {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _expiresAtKey);
}
}

View File

@ -0,0 +1 @@
export 'secure_storage_auth_token_store.dart';

View File

@ -35,7 +35,8 @@ final class AudioplayersExerciseStepAudioCuePlayer
} }
} }
final class NoOpExerciseStepAudioCuePlayer implements ExerciseStepAudioCuePlayer { final class NoOpExerciseStepAudioCuePlayer
implements ExerciseStepAudioCuePlayer {
const NoOpExerciseStepAudioCuePlayer(); const NoOpExerciseStepAudioCuePlayer();
@override @override

View File

@ -374,6 +374,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.35" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -417,7 +465,7 @@ packages:
source: hosted source: hosted
version: "0.15.6" version: "0.15.6"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@ -536,6 +584,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.1" version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
@ -997,6 +1053,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@ -13,6 +13,8 @@ dependencies:
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
drift: ^2.34.2 drift: ^2.34.2
drift_flutter: ^0.3.1 drift_flutter: ^0.3.1
flutter_secure_storage: ^9.2.4
http: ^1.5.0
image_picker: ^1.2.3 image_picker: ^1.2.3
audioplayers: ^6.8.1 audioplayers: ^6.8.1
path: ^1.9.1 path: ^1.9.1

View File

@ -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<RemoteAuthException>().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<RemoteAuthException>().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', () { test('score result enforces manual xor stopwatch values', () {
expect( expect(
() => ActiveSetResult( () => 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<void> clearToken() async {
token = null;
expiresAt = null;
}
@override
Future<String?> readToken() async => token;
@override
Future<void> saveToken(String token, DateTime expiresAt) async {
this.token = token;
this.expiresAt = expiresAt;
}
}
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
UserAccountSession? session;
var clearCalls = 0;
@override
Future<void> clearSession() async {
clearCalls += 1;
session = null;
}
@override
Future<UserAccountSession?> currentSession() async => session;
@override
Future<void> 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<RemoteAuthResult> 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<RemoteAuthResult> login({
required String email,
required String password,
}) async {
loginCalls += 1;
final exception = loginException;
if (exception != null) {
throw exception;
}
return _authResult(email);
}
@override
Future<void> 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( ExerciseUseCases _exerciseUseCase(
_FakeExerciseRepository repository, { _FakeExerciseRepository repository, {
_FakeProgramRepository? programRepository, _FakeProgramRepository? programRepository,

View File

@ -115,7 +115,14 @@ void main() {
final class _FakeBootstrap implements AppDependencies { final class _FakeBootstrap implements AppDependencies {
_FakeBootstrap(_FakeActiveSessionRepository activeRepository) _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, sessionRepository: activeRepository,
templateRepository: _FakeWorkoutTemplateRepository(), templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), 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)), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
); );
@override
final AuthUseCases authUseCases;
@override @override
final ExerciseUseCases exerciseUseCases; final ExerciseUseCases exerciseUseCases;
@ -249,6 +259,50 @@ final class _FakeIds implements IdGenerator {
} }
} }
final class _FakeAuthTokenStore implements AuthTokenStore {
@override
Future<void> clearToken() async {}
@override
Future<String?> readToken() async => null;
@override
Future<void> saveToken(String token, DateTime expiresAt) async {}
}
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
@override
Future<void> clearSession() async {}
@override
Future<UserAccountSession?> currentSession() async => null;
@override
Future<void> saveSession(UserAccountSession session) async {}
}
final class _FakeRemoteAuthApi implements RemoteAuthApi {
@override
Future<RemoteAuthResult> login({
required String email,
required String password,
}) async {
throw const RemoteAuthException(RemoteAuthFailure.network);
}
@override
Future<void> logout(String token) async {}
@override
Future<RemoteAuthResult> register({
required String email,
required String password,
String? displayName,
}) async {
throw const RemoteAuthException(RemoteAuthFailure.network);
}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository { final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session; ActiveWorkoutSession? session;
final restStates = <ActiveRestState>[]; final restStates = <ActiveRestState>[];