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

@ -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';

View File

@ -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<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> {
@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<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 =
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 =>

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 {
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) {
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(

View File

@ -24,6 +24,30 @@ abstract class SyncableTable extends Table {
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 {
@override
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';