diff --git a/justfile b/justfile index 09b47f7..96786c9 100644 --- a/justfile +++ b/justfile @@ -1,3 +1,6 @@ +export FLUTTER_SUPPRESS_ANALYTICS := "true" +export DART_SUPPRESS_ANALYTICS := "true" + # Format all Dart files format: dart format lib test diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index e5bca7d..c71aacd 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -2,16 +2,32 @@ import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:path_provider/path_provider.dart'; -import 'tables.dart'; +import 'package:lazurite/core/database/tables.dart'; part 'app_database.g.dart'; -@DriftDatabase(tables: [Accounts, Settings]) +@DriftDatabase(tables: [Accounts, CachedProfiles, CachedPosts, Settings]) class AppDatabase extends _$AppDatabase { AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); @override - int get schemaVersion => 1; + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (migrator) async { + await migrator.createAll(); + }, + onUpgrade: (migrator, from, to) async { + if (from < 2) { + await migrator.addColumn(accounts, accounts.service); + await migrator.addColumn(accounts, accounts.dpopPublicKey); + await migrator.addColumn(accounts, accounts.dpopNonce); + await migrator.createTable(cachedProfiles); + await migrator.createTable(cachedPosts); + } + }, + ); static QueryExecutor _openConnection() { return driftDatabase( @@ -23,7 +39,7 @@ class AppDatabase extends _$AppDatabase { Future getAccount(String did) => (select(accounts)..where((a) => a.did.equals(did))).getSingleOrNull(); Future getActiveAccount() async { - final all = await select(accounts).get(); + final all = await (select(accounts)..orderBy([(a) => OrderingTerm.desc(a.updatedAt)])).get(); return all.isNotEmpty ? all.first : null; } @@ -40,6 +56,7 @@ class AppDatabase extends _$AppDatabase { required String accessToken, String? refreshToken, DateTime? expiresAt, + String? dpopNonce, }) async { final query = update(accounts)..where((a) => a.did.equals(did)); final rowsAffected = await query.write( @@ -47,12 +64,45 @@ class AppDatabase extends _$AppDatabase { accessToken: Value(accessToken), refreshToken: refreshToken != null ? Value(refreshToken) : const Value.absent(), expiresAt: expiresAt != null ? Value(expiresAt) : const Value.absent(), + dpopNonce: dpopNonce != null ? Value(dpopNonce) : const Value.absent(), updatedAt: Value(DateTime.now()), ), ); return rowsAffected > 0; } + Future cacheProfile({ + required String did, + required String handle, + required String payload, + DateTime? fetchedAt, + }) => into(cachedProfiles).insert( + CachedProfilesCompanion( + did: Value(did), + handle: Value(handle), + payload: Value(payload), + fetchedAt: Value(fetchedAt ?? DateTime.now()), + ), + mode: InsertMode.replace, + ); + + Future cachePost({ + required String uri, + required String authorDid, + required String payload, + DateTime? createdAt, + DateTime? fetchedAt, + }) => into(cachedPosts).insert( + CachedPostsCompanion( + uri: Value(uri), + authorDid: Value(authorDid), + payload: Value(payload), + createdAt: createdAt != null ? Value(createdAt) : const Value.absent(), + fetchedAt: Value(fetchedAt ?? DateTime.now()), + ), + mode: InsertMode.replace, + ); + Future getSetting(String key) async { final setting = await (select(settings)..where((s) => s.key.equals(key))).getSingleOrNull(); return setting?.value; diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index 6e2ff37..6fb0c23 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -35,6 +35,15 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _serviceMeta = const VerificationMeta('service'); + @override + late final GeneratedColumn service = GeneratedColumn( + 'service', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _accessTokenMeta = const VerificationMeta('accessToken'); @override late final GeneratedColumn accessToken = GeneratedColumn( @@ -53,6 +62,15 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _dpopPublicKeyMeta = const VerificationMeta('dpopPublicKey'); + @override + late final GeneratedColumn dpopPublicKey = GeneratedColumn( + 'dpop_public_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _dpopPrivateKeyMeta = const VerificationMeta('dpopPrivateKey'); @override late final GeneratedColumn dpopPrivateKey = GeneratedColumn( @@ -62,6 +80,15 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _dpopNonceMeta = const VerificationMeta('dpopNonce'); + @override + late final GeneratedColumn dpopNonce = GeneratedColumn( + 'dpop_nonce', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _expiresAtMeta = const VerificationMeta('expiresAt'); @override late final GeneratedColumn expiresAt = GeneratedColumn( @@ -96,9 +123,12 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { did, handle, displayName, + service, accessToken, refreshToken, + dpopPublicKey, dpopPrivateKey, + dpopNonce, expiresAt, createdAt, updatedAt, @@ -125,6 +155,9 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { if (data.containsKey('display_name')) { context.handle(_displayNameMeta, displayName.isAcceptableOrUnknown(data['display_name']!, _displayNameMeta)); } + if (data.containsKey('service')) { + context.handle(_serviceMeta, service.isAcceptableOrUnknown(data['service']!, _serviceMeta)); + } if (data.containsKey('access_token')) { context.handle(_accessTokenMeta, accessToken.isAcceptableOrUnknown(data['access_token']!, _accessTokenMeta)); } else if (isInserting) { @@ -133,12 +166,21 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { if (data.containsKey('refresh_token')) { context.handle(_refreshTokenMeta, refreshToken.isAcceptableOrUnknown(data['refresh_token']!, _refreshTokenMeta)); } + if (data.containsKey('dpop_public_key')) { + context.handle( + _dpopPublicKeyMeta, + dpopPublicKey.isAcceptableOrUnknown(data['dpop_public_key']!, _dpopPublicKeyMeta), + ); + } if (data.containsKey('dpop_private_key')) { context.handle( _dpopPrivateKeyMeta, dpopPrivateKey.isAcceptableOrUnknown(data['dpop_private_key']!, _dpopPrivateKeyMeta), ); } + if (data.containsKey('dpop_nonce')) { + context.handle(_dpopNonceMeta, dpopNonce.isAcceptableOrUnknown(data['dpop_nonce']!, _dpopNonceMeta)); + } if (data.containsKey('expires_at')) { context.handle(_expiresAtMeta, expiresAt.isAcceptableOrUnknown(data['expires_at']!, _expiresAtMeta)); } @@ -160,12 +202,15 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { did: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}did'])!, handle: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}handle'])!, displayName: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}display_name']), + service: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}service']), accessToken: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}access_token'])!, refreshToken: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}refresh_token']), + dpopPublicKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}dpop_public_key']), dpopPrivateKey: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}dpop_private_key'], ), + dpopNonce: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}dpop_nonce']), expiresAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}expires_at']), createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, @@ -182,9 +227,12 @@ class Account extends DataClass implements Insertable { final String did; final String handle; final String? displayName; + final String? service; final String accessToken; final String? refreshToken; + final String? dpopPublicKey; final String? dpopPrivateKey; + final String? dpopNonce; final DateTime? expiresAt; final DateTime createdAt; final DateTime updatedAt; @@ -192,9 +240,12 @@ class Account extends DataClass implements Insertable { required this.did, required this.handle, this.displayName, + this.service, required this.accessToken, this.refreshToken, + this.dpopPublicKey, this.dpopPrivateKey, + this.dpopNonce, this.expiresAt, required this.createdAt, required this.updatedAt, @@ -207,13 +258,22 @@ class Account extends DataClass implements Insertable { if (!nullToAbsent || displayName != null) { map['display_name'] = Variable(displayName); } + if (!nullToAbsent || service != null) { + map['service'] = Variable(service); + } map['access_token'] = Variable(accessToken); if (!nullToAbsent || refreshToken != null) { map['refresh_token'] = Variable(refreshToken); } + if (!nullToAbsent || dpopPublicKey != null) { + map['dpop_public_key'] = Variable(dpopPublicKey); + } if (!nullToAbsent || dpopPrivateKey != null) { map['dpop_private_key'] = Variable(dpopPrivateKey); } + if (!nullToAbsent || dpopNonce != null) { + map['dpop_nonce'] = Variable(dpopNonce); + } if (!nullToAbsent || expiresAt != null) { map['expires_at'] = Variable(expiresAt); } @@ -227,9 +287,12 @@ class Account extends DataClass implements Insertable { did: Value(did), handle: Value(handle), displayName: displayName == null && nullToAbsent ? const Value.absent() : Value(displayName), + service: service == null && nullToAbsent ? const Value.absent() : Value(service), accessToken: Value(accessToken), refreshToken: refreshToken == null && nullToAbsent ? const Value.absent() : Value(refreshToken), + dpopPublicKey: dpopPublicKey == null && nullToAbsent ? const Value.absent() : Value(dpopPublicKey), dpopPrivateKey: dpopPrivateKey == null && nullToAbsent ? const Value.absent() : Value(dpopPrivateKey), + dpopNonce: dpopNonce == null && nullToAbsent ? const Value.absent() : Value(dpopNonce), expiresAt: expiresAt == null && nullToAbsent ? const Value.absent() : Value(expiresAt), createdAt: Value(createdAt), updatedAt: Value(updatedAt), @@ -242,9 +305,12 @@ class Account extends DataClass implements Insertable { did: serializer.fromJson(json['did']), handle: serializer.fromJson(json['handle']), displayName: serializer.fromJson(json['displayName']), + service: serializer.fromJson(json['service']), accessToken: serializer.fromJson(json['accessToken']), refreshToken: serializer.fromJson(json['refreshToken']), + dpopPublicKey: serializer.fromJson(json['dpopPublicKey']), dpopPrivateKey: serializer.fromJson(json['dpopPrivateKey']), + dpopNonce: serializer.fromJson(json['dpopNonce']), expiresAt: serializer.fromJson(json['expiresAt']), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), @@ -257,9 +323,12 @@ class Account extends DataClass implements Insertable { 'did': serializer.toJson(did), 'handle': serializer.toJson(handle), 'displayName': serializer.toJson(displayName), + 'service': serializer.toJson(service), 'accessToken': serializer.toJson(accessToken), 'refreshToken': serializer.toJson(refreshToken), + 'dpopPublicKey': serializer.toJson(dpopPublicKey), 'dpopPrivateKey': serializer.toJson(dpopPrivateKey), + 'dpopNonce': serializer.toJson(dpopNonce), 'expiresAt': serializer.toJson(expiresAt), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), @@ -270,9 +339,12 @@ class Account extends DataClass implements Insertable { String? did, String? handle, Value displayName = const Value.absent(), + Value service = const Value.absent(), String? accessToken, Value refreshToken = const Value.absent(), + Value dpopPublicKey = const Value.absent(), Value dpopPrivateKey = const Value.absent(), + Value dpopNonce = const Value.absent(), Value expiresAt = const Value.absent(), DateTime? createdAt, DateTime? updatedAt, @@ -280,9 +352,12 @@ class Account extends DataClass implements Insertable { did: did ?? this.did, handle: handle ?? this.handle, displayName: displayName.present ? displayName.value : this.displayName, + service: service.present ? service.value : this.service, accessToken: accessToken ?? this.accessToken, refreshToken: refreshToken.present ? refreshToken.value : this.refreshToken, + dpopPublicKey: dpopPublicKey.present ? dpopPublicKey.value : this.dpopPublicKey, dpopPrivateKey: dpopPrivateKey.present ? dpopPrivateKey.value : this.dpopPrivateKey, + dpopNonce: dpopNonce.present ? dpopNonce.value : this.dpopNonce, expiresAt: expiresAt.present ? expiresAt.value : this.expiresAt, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, @@ -292,9 +367,12 @@ class Account extends DataClass implements Insertable { did: data.did.present ? data.did.value : this.did, handle: data.handle.present ? data.handle.value : this.handle, displayName: data.displayName.present ? data.displayName.value : this.displayName, + service: data.service.present ? data.service.value : this.service, accessToken: data.accessToken.present ? data.accessToken.value : this.accessToken, refreshToken: data.refreshToken.present ? data.refreshToken.value : this.refreshToken, + dpopPublicKey: data.dpopPublicKey.present ? data.dpopPublicKey.value : this.dpopPublicKey, dpopPrivateKey: data.dpopPrivateKey.present ? data.dpopPrivateKey.value : this.dpopPrivateKey, + dpopNonce: data.dpopNonce.present ? data.dpopNonce.value : this.dpopNonce, expiresAt: data.expiresAt.present ? data.expiresAt.value : this.expiresAt, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, @@ -307,9 +385,12 @@ class Account extends DataClass implements Insertable { ..write('did: $did, ') ..write('handle: $handle, ') ..write('displayName: $displayName, ') + ..write('service: $service, ') ..write('accessToken: $accessToken, ') ..write('refreshToken: $refreshToken, ') + ..write('dpopPublicKey: $dpopPublicKey, ') ..write('dpopPrivateKey: $dpopPrivateKey, ') + ..write('dpopNonce: $dpopNonce, ') ..write('expiresAt: $expiresAt, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt') @@ -318,8 +399,20 @@ class Account extends DataClass implements Insertable { } @override - int get hashCode => - Object.hash(did, handle, displayName, accessToken, refreshToken, dpopPrivateKey, expiresAt, createdAt, updatedAt); + int get hashCode => Object.hash( + did, + handle, + displayName, + service, + accessToken, + refreshToken, + dpopPublicKey, + dpopPrivateKey, + dpopNonce, + expiresAt, + createdAt, + updatedAt, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -327,9 +420,12 @@ class Account extends DataClass implements Insertable { other.did == this.did && other.handle == this.handle && other.displayName == this.displayName && + other.service == this.service && other.accessToken == this.accessToken && other.refreshToken == this.refreshToken && + other.dpopPublicKey == this.dpopPublicKey && other.dpopPrivateKey == this.dpopPrivateKey && + other.dpopNonce == this.dpopNonce && other.expiresAt == this.expiresAt && other.createdAt == this.createdAt && other.updatedAt == this.updatedAt); @@ -339,9 +435,12 @@ class AccountsCompanion extends UpdateCompanion { final Value did; final Value handle; final Value displayName; + final Value service; final Value accessToken; final Value refreshToken; + final Value dpopPublicKey; final Value dpopPrivateKey; + final Value dpopNonce; final Value expiresAt; final Value createdAt; final Value updatedAt; @@ -350,9 +449,12 @@ class AccountsCompanion extends UpdateCompanion { this.did = const Value.absent(), this.handle = const Value.absent(), this.displayName = const Value.absent(), + this.service = const Value.absent(), this.accessToken = const Value.absent(), this.refreshToken = const Value.absent(), + this.dpopPublicKey = const Value.absent(), this.dpopPrivateKey = const Value.absent(), + this.dpopNonce = const Value.absent(), this.expiresAt = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), @@ -362,9 +464,12 @@ class AccountsCompanion extends UpdateCompanion { required String did, required String handle, this.displayName = const Value.absent(), + this.service = const Value.absent(), required String accessToken, this.refreshToken = const Value.absent(), + this.dpopPublicKey = const Value.absent(), this.dpopPrivateKey = const Value.absent(), + this.dpopNonce = const Value.absent(), this.expiresAt = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), @@ -376,9 +481,12 @@ class AccountsCompanion extends UpdateCompanion { Expression? did, Expression? handle, Expression? displayName, + Expression? service, Expression? accessToken, Expression? refreshToken, + Expression? dpopPublicKey, Expression? dpopPrivateKey, + Expression? dpopNonce, Expression? expiresAt, Expression? createdAt, Expression? updatedAt, @@ -388,9 +496,12 @@ class AccountsCompanion extends UpdateCompanion { if (did != null) 'did': did, if (handle != null) 'handle': handle, if (displayName != null) 'display_name': displayName, + if (service != null) 'service': service, if (accessToken != null) 'access_token': accessToken, if (refreshToken != null) 'refresh_token': refreshToken, + if (dpopPublicKey != null) 'dpop_public_key': dpopPublicKey, if (dpopPrivateKey != null) 'dpop_private_key': dpopPrivateKey, + if (dpopNonce != null) 'dpop_nonce': dpopNonce, if (expiresAt != null) 'expires_at': expiresAt, if (createdAt != null) 'created_at': createdAt, if (updatedAt != null) 'updated_at': updatedAt, @@ -402,9 +513,12 @@ class AccountsCompanion extends UpdateCompanion { Value? did, Value? handle, Value? displayName, + Value? service, Value? accessToken, Value? refreshToken, + Value? dpopPublicKey, Value? dpopPrivateKey, + Value? dpopNonce, Value? expiresAt, Value? createdAt, Value? updatedAt, @@ -414,9 +528,12 @@ class AccountsCompanion extends UpdateCompanion { did: did ?? this.did, handle: handle ?? this.handle, displayName: displayName ?? this.displayName, + service: service ?? this.service, accessToken: accessToken ?? this.accessToken, refreshToken: refreshToken ?? this.refreshToken, + dpopPublicKey: dpopPublicKey ?? this.dpopPublicKey, dpopPrivateKey: dpopPrivateKey ?? this.dpopPrivateKey, + dpopNonce: dpopNonce ?? this.dpopNonce, expiresAt: expiresAt ?? this.expiresAt, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, @@ -436,15 +553,24 @@ class AccountsCompanion extends UpdateCompanion { if (displayName.present) { map['display_name'] = Variable(displayName.value); } + if (service.present) { + map['service'] = Variable(service.value); + } if (accessToken.present) { map['access_token'] = Variable(accessToken.value); } if (refreshToken.present) { map['refresh_token'] = Variable(refreshToken.value); } + if (dpopPublicKey.present) { + map['dpop_public_key'] = Variable(dpopPublicKey.value); + } if (dpopPrivateKey.present) { map['dpop_private_key'] = Variable(dpopPrivateKey.value); } + if (dpopNonce.present) { + map['dpop_nonce'] = Variable(dpopNonce.value); + } if (expiresAt.present) { map['expires_at'] = Variable(expiresAt.value); } @@ -466,9 +592,12 @@ class AccountsCompanion extends UpdateCompanion { ..write('did: $did, ') ..write('handle: $handle, ') ..write('displayName: $displayName, ') + ..write('service: $service, ') ..write('accessToken: $accessToken, ') ..write('refreshToken: $refreshToken, ') + ..write('dpopPublicKey: $dpopPublicKey, ') ..write('dpopPrivateKey: $dpopPrivateKey, ') + ..write('dpopNonce: $dpopNonce, ') ..write('expiresAt: $expiresAt, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') @@ -478,33 +607,42 @@ class AccountsCompanion extends UpdateCompanion { } } -class $SettingsTable extends Settings with TableInfo<$SettingsTable, SettingsEntry> { +class $CachedProfilesTable extends CachedProfiles with TableInfo<$CachedProfilesTable, CachedProfile> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $SettingsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _keyMeta = const VerificationMeta('key'); + $CachedProfilesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _didMeta = const VerificationMeta('did'); @override - late final GeneratedColumn key = GeneratedColumn( - 'key', + late final GeneratedColumn did = GeneratedColumn( + 'did', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _valueMeta = const VerificationMeta('value'); + static const VerificationMeta _handleMeta = const VerificationMeta('handle'); @override - late final GeneratedColumn value = GeneratedColumn( - 'value', + late final GeneratedColumn handle = GeneratedColumn( + 'handle', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); + static const VerificationMeta _payloadMeta = const VerificationMeta('payload'); @override - late final GeneratedColumn updatedAt = GeneratedColumn( - 'updated_at', + late final GeneratedColumn payload = GeneratedColumn( + 'payload', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _fetchedAtMeta = const VerificationMeta('fetchedAt'); + @override + late final GeneratedColumn fetchedAt = GeneratedColumn( + 'fetched_at', aliasedName, false, type: DriftSqlType.dateTime, @@ -512,159 +650,189 @@ class $SettingsTable extends Settings with TableInfo<$SettingsTable, SettingsEnt defaultValue: currentDateAndTime, ); @override - List get $columns => [key, value, updatedAt]; + List get $columns => [did, handle, payload, fetchedAt]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'settings'; + static const String $name = 'cached_profiles'; @override - VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('key')) { - context.handle(_keyMeta, key.isAcceptableOrUnknown(data['key']!, _keyMeta)); + if (data.containsKey('did')) { + context.handle(_didMeta, did.isAcceptableOrUnknown(data['did']!, _didMeta)); } else if (isInserting) { - context.missing(_keyMeta); + context.missing(_didMeta); } - if (data.containsKey('value')) { - context.handle(_valueMeta, value.isAcceptableOrUnknown(data['value']!, _valueMeta)); + if (data.containsKey('handle')) { + context.handle(_handleMeta, handle.isAcceptableOrUnknown(data['handle']!, _handleMeta)); } else if (isInserting) { - context.missing(_valueMeta); + context.missing(_handleMeta); } - if (data.containsKey('updated_at')) { - context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + if (data.containsKey('payload')) { + context.handle(_payloadMeta, payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta)); + } else if (isInserting) { + context.missing(_payloadMeta); + } + if (data.containsKey('fetched_at')) { + context.handle(_fetchedAtMeta, fetchedAt.isAcceptableOrUnknown(data['fetched_at']!, _fetchedAtMeta)); } return context; } @override - Set get $primaryKey => {key}; + Set get $primaryKey => {did}; @override - SettingsEntry map(Map data, {String? tablePrefix}) { + CachedProfile map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return SettingsEntry( - key: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}key'])!, - value: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}value'])!, - updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + return CachedProfile( + did: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}did'])!, + handle: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}handle'])!, + payload: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}payload'])!, + fetchedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}fetched_at'])!, ); } @override - $SettingsTable createAlias(String alias) { - return $SettingsTable(attachedDatabase, alias); + $CachedProfilesTable createAlias(String alias) { + return $CachedProfilesTable(attachedDatabase, alias); } } -class SettingsEntry extends DataClass implements Insertable { - final String key; - final String value; - final DateTime updatedAt; - const SettingsEntry({required this.key, required this.value, required this.updatedAt}); +class CachedProfile extends DataClass implements Insertable { + final String did; + final String handle; + final String payload; + final DateTime fetchedAt; + const CachedProfile({required this.did, required this.handle, required this.payload, required this.fetchedAt}); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['key'] = Variable(key); - map['value'] = Variable(value); - map['updated_at'] = Variable(updatedAt); + map['did'] = Variable(did); + map['handle'] = Variable(handle); + map['payload'] = Variable(payload); + map['fetched_at'] = Variable(fetchedAt); return map; } - SettingsCompanion toCompanion(bool nullToAbsent) { - return SettingsCompanion(key: Value(key), value: Value(value), updatedAt: Value(updatedAt)); + CachedProfilesCompanion toCompanion(bool nullToAbsent) { + return CachedProfilesCompanion( + did: Value(did), + handle: Value(handle), + payload: Value(payload), + fetchedAt: Value(fetchedAt), + ); } - factory SettingsEntry.fromJson(Map json, {ValueSerializer? serializer}) { + factory CachedProfile.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; - return SettingsEntry( - key: serializer.fromJson(json['key']), - value: serializer.fromJson(json['value']), - updatedAt: serializer.fromJson(json['updatedAt']), + return CachedProfile( + did: serializer.fromJson(json['did']), + handle: serializer.fromJson(json['handle']), + payload: serializer.fromJson(json['payload']), + fetchedAt: serializer.fromJson(json['fetchedAt']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'key': serializer.toJson(key), - 'value': serializer.toJson(value), - 'updatedAt': serializer.toJson(updatedAt), + 'did': serializer.toJson(did), + 'handle': serializer.toJson(handle), + 'payload': serializer.toJson(payload), + 'fetchedAt': serializer.toJson(fetchedAt), }; } - SettingsEntry copyWith({String? key, String? value, DateTime? updatedAt}) => - SettingsEntry(key: key ?? this.key, value: value ?? this.value, updatedAt: updatedAt ?? this.updatedAt); - SettingsEntry copyWithCompanion(SettingsCompanion data) { - return SettingsEntry( - key: data.key.present ? data.key.value : this.key, - value: data.value.present ? data.value.value : this.value, - updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + CachedProfile copyWith({String? did, String? handle, String? payload, DateTime? fetchedAt}) => CachedProfile( + did: did ?? this.did, + handle: handle ?? this.handle, + payload: payload ?? this.payload, + fetchedAt: fetchedAt ?? this.fetchedAt, + ); + CachedProfile copyWithCompanion(CachedProfilesCompanion data) { + return CachedProfile( + did: data.did.present ? data.did.value : this.did, + handle: data.handle.present ? data.handle.value : this.handle, + payload: data.payload.present ? data.payload.value : this.payload, + fetchedAt: data.fetchedAt.present ? data.fetchedAt.value : this.fetchedAt, ); } @override String toString() { - return (StringBuffer('SettingsEntry(') - ..write('key: $key, ') - ..write('value: $value, ') - ..write('updatedAt: $updatedAt') + return (StringBuffer('CachedProfile(') + ..write('did: $did, ') + ..write('handle: $handle, ') + ..write('payload: $payload, ') + ..write('fetchedAt: $fetchedAt') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(key, value, updatedAt); + int get hashCode => Object.hash(did, handle, payload, fetchedAt); @override bool operator ==(Object other) => identical(this, other) || - (other is SettingsEntry && - other.key == this.key && - other.value == this.value && - other.updatedAt == this.updatedAt); + (other is CachedProfile && + other.did == this.did && + other.handle == this.handle && + other.payload == this.payload && + other.fetchedAt == this.fetchedAt); } -class SettingsCompanion extends UpdateCompanion { - final Value key; - final Value value; - final Value updatedAt; +class CachedProfilesCompanion extends UpdateCompanion { + final Value did; + final Value handle; + final Value payload; + final Value fetchedAt; final Value rowid; - const SettingsCompanion({ - this.key = const Value.absent(), - this.value = const Value.absent(), - this.updatedAt = const Value.absent(), + const CachedProfilesCompanion({ + this.did = const Value.absent(), + this.handle = const Value.absent(), + this.payload = const Value.absent(), + this.fetchedAt = const Value.absent(), this.rowid = const Value.absent(), }); - SettingsCompanion.insert({ - required String key, - required String value, - this.updatedAt = const Value.absent(), + CachedProfilesCompanion.insert({ + required String did, + required String handle, + required String payload, + this.fetchedAt = const Value.absent(), this.rowid = const Value.absent(), - }) : key = Value(key), - value = Value(value); - static Insertable custom({ - Expression? key, - Expression? value, - Expression? updatedAt, + }) : did = Value(did), + handle = Value(handle), + payload = Value(payload); + static Insertable custom({ + Expression? did, + Expression? handle, + Expression? payload, + Expression? fetchedAt, Expression? rowid, }) { return RawValuesInsertable({ - if (key != null) 'key': key, - if (value != null) 'value': value, - if (updatedAt != null) 'updated_at': updatedAt, + if (did != null) 'did': did, + if (handle != null) 'handle': handle, + if (payload != null) 'payload': payload, + if (fetchedAt != null) 'fetched_at': fetchedAt, if (rowid != null) 'rowid': rowid, }); } - SettingsCompanion copyWith({ - Value? key, - Value? value, - Value? updatedAt, + CachedProfilesCompanion copyWith({ + Value? did, + Value? handle, + Value? payload, + Value? fetchedAt, Value? rowid, }) { - return SettingsCompanion( - key: key ?? this.key, - value: value ?? this.value, - updatedAt: updatedAt ?? this.updatedAt, + return CachedProfilesCompanion( + did: did ?? this.did, + handle: handle ?? this.handle, + payload: payload ?? this.payload, + fetchedAt: fetchedAt ?? this.fetchedAt, rowid: rowid ?? this.rowid, ); } @@ -672,14 +840,17 @@ class SettingsCompanion extends UpdateCompanion { @override Map toColumns(bool nullToAbsent) { final map = {}; - if (key.present) { - map['key'] = Variable(key.value); + if (did.present) { + map['did'] = Variable(did.value); } - if (value.present) { - map['value'] = Variable(value.value); + if (handle.present) { + map['handle'] = Variable(handle.value); } - if (updatedAt.present) { - map['updated_at'] = Variable(updatedAt.value); + if (payload.present) { + map['payload'] = Variable(payload.value); + } + if (fetchedAt.present) { + map['fetched_at'] = Variable(fetchedAt.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -689,79 +860,629 @@ class SettingsCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('SettingsCompanion(') - ..write('key: $key, ') - ..write('value: $value, ') - ..write('updatedAt: $updatedAt, ') + return (StringBuffer('CachedProfilesCompanion(') + ..write('did: $did, ') + ..write('handle: $handle, ') + ..write('payload: $payload, ') + ..write('fetchedAt: $fetchedAt, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -abstract class _$AppDatabase extends GeneratedDatabase { - _$AppDatabase(QueryExecutor e) : super(e); - $AppDatabaseManager get managers => $AppDatabaseManager(this); - late final $AccountsTable accounts = $AccountsTable(this); - late final $SettingsTable settings = $SettingsTable(this); +class $CachedPostsTable extends CachedPosts with TableInfo<$CachedPostsTable, CachedPost> { @override - Iterable> get allTables => allSchemaEntities.whereType>(); + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CachedPostsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _uriMeta = const VerificationMeta('uri'); @override - List get allSchemaEntities => [accounts, settings]; -} - -typedef $$AccountsTableCreateCompanionBuilder = - AccountsCompanion Function({ - required String did, - required String handle, - Value displayName, - required String accessToken, - Value refreshToken, - Value dpopPrivateKey, - Value expiresAt, - Value createdAt, - Value updatedAt, - Value rowid, - }); -typedef $$AccountsTableUpdateCompanionBuilder = - AccountsCompanion Function({ - Value did, - Value handle, - Value displayName, - Value accessToken, - Value refreshToken, - Value dpopPrivateKey, - Value expiresAt, - Value createdAt, - Value updatedAt, - Value rowid, - }); - -class $$AccountsTableFilterComposer extends Composer<_$AppDatabase, $AccountsTable> { - $$AccountsTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get did => $composableBuilder(column: $table.did, builder: (column) => ColumnFilters(column)); - - ColumnFilters get handle => + late final GeneratedColumn uri = GeneratedColumn( + 'uri', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _authorDidMeta = const VerificationMeta('authorDid'); + @override + late final GeneratedColumn authorDid = GeneratedColumn( + 'author_did', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _payloadMeta = const VerificationMeta('payload'); + @override + late final GeneratedColumn payload = GeneratedColumn( + 'payload', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _fetchedAtMeta = const VerificationMeta('fetchedAt'); + @override + late final GeneratedColumn fetchedAt = GeneratedColumn( + 'fetched_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [uri, authorDid, payload, createdAt, fetchedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cached_posts'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('uri')) { + context.handle(_uriMeta, uri.isAcceptableOrUnknown(data['uri']!, _uriMeta)); + } else if (isInserting) { + context.missing(_uriMeta); + } + if (data.containsKey('author_did')) { + context.handle(_authorDidMeta, authorDid.isAcceptableOrUnknown(data['author_did']!, _authorDidMeta)); + } else if (isInserting) { + context.missing(_authorDidMeta); + } + if (data.containsKey('payload')) { + context.handle(_payloadMeta, payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta)); + } else if (isInserting) { + context.missing(_payloadMeta); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + if (data.containsKey('fetched_at')) { + context.handle(_fetchedAtMeta, fetchedAt.isAcceptableOrUnknown(data['fetched_at']!, _fetchedAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {uri}; + @override + CachedPost map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CachedPost( + uri: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}uri'])!, + authorDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}author_did'])!, + payload: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}payload'])!, + createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at']), + fetchedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}fetched_at'])!, + ); + } + + @override + $CachedPostsTable createAlias(String alias) { + return $CachedPostsTable(attachedDatabase, alias); + } +} + +class CachedPost extends DataClass implements Insertable { + final String uri; + final String authorDid; + final String payload; + final DateTime? createdAt; + final DateTime fetchedAt; + const CachedPost({ + required this.uri, + required this.authorDid, + required this.payload, + this.createdAt, + required this.fetchedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['uri'] = Variable(uri); + map['author_did'] = Variable(authorDid); + map['payload'] = Variable(payload); + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + map['fetched_at'] = Variable(fetchedAt); + return map; + } + + CachedPostsCompanion toCompanion(bool nullToAbsent) { + return CachedPostsCompanion( + uri: Value(uri), + authorDid: Value(authorDid), + payload: Value(payload), + createdAt: createdAt == null && nullToAbsent ? const Value.absent() : Value(createdAt), + fetchedAt: Value(fetchedAt), + ); + } + + factory CachedPost.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CachedPost( + uri: serializer.fromJson(json['uri']), + authorDid: serializer.fromJson(json['authorDid']), + payload: serializer.fromJson(json['payload']), + createdAt: serializer.fromJson(json['createdAt']), + fetchedAt: serializer.fromJson(json['fetchedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'uri': serializer.toJson(uri), + 'authorDid': serializer.toJson(authorDid), + 'payload': serializer.toJson(payload), + 'createdAt': serializer.toJson(createdAt), + 'fetchedAt': serializer.toJson(fetchedAt), + }; + } + + CachedPost copyWith({ + String? uri, + String? authorDid, + String? payload, + Value createdAt = const Value.absent(), + DateTime? fetchedAt, + }) => CachedPost( + uri: uri ?? this.uri, + authorDid: authorDid ?? this.authorDid, + payload: payload ?? this.payload, + createdAt: createdAt.present ? createdAt.value : this.createdAt, + fetchedAt: fetchedAt ?? this.fetchedAt, + ); + CachedPost copyWithCompanion(CachedPostsCompanion data) { + return CachedPost( + uri: data.uri.present ? data.uri.value : this.uri, + authorDid: data.authorDid.present ? data.authorDid.value : this.authorDid, + payload: data.payload.present ? data.payload.value : this.payload, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + fetchedAt: data.fetchedAt.present ? data.fetchedAt.value : this.fetchedAt, + ); + } + + @override + String toString() { + return (StringBuffer('CachedPost(') + ..write('uri: $uri, ') + ..write('authorDid: $authorDid, ') + ..write('payload: $payload, ') + ..write('createdAt: $createdAt, ') + ..write('fetchedAt: $fetchedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(uri, authorDid, payload, createdAt, fetchedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CachedPost && + other.uri == this.uri && + other.authorDid == this.authorDid && + other.payload == this.payload && + other.createdAt == this.createdAt && + other.fetchedAt == this.fetchedAt); +} + +class CachedPostsCompanion extends UpdateCompanion { + final Value uri; + final Value authorDid; + final Value payload; + final Value createdAt; + final Value fetchedAt; + final Value rowid; + const CachedPostsCompanion({ + this.uri = const Value.absent(), + this.authorDid = const Value.absent(), + this.payload = const Value.absent(), + this.createdAt = const Value.absent(), + this.fetchedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + CachedPostsCompanion.insert({ + required String uri, + required String authorDid, + required String payload, + this.createdAt = const Value.absent(), + this.fetchedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : uri = Value(uri), + authorDid = Value(authorDid), + payload = Value(payload); + static Insertable custom({ + Expression? uri, + Expression? authorDid, + Expression? payload, + Expression? createdAt, + Expression? fetchedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (uri != null) 'uri': uri, + if (authorDid != null) 'author_did': authorDid, + if (payload != null) 'payload': payload, + if (createdAt != null) 'created_at': createdAt, + if (fetchedAt != null) 'fetched_at': fetchedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + CachedPostsCompanion copyWith({ + Value? uri, + Value? authorDid, + Value? payload, + Value? createdAt, + Value? fetchedAt, + Value? rowid, + }) { + return CachedPostsCompanion( + uri: uri ?? this.uri, + authorDid: authorDid ?? this.authorDid, + payload: payload ?? this.payload, + createdAt: createdAt ?? this.createdAt, + fetchedAt: fetchedAt ?? this.fetchedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (uri.present) { + map['uri'] = Variable(uri.value); + } + if (authorDid.present) { + map['author_did'] = Variable(authorDid.value); + } + if (payload.present) { + map['payload'] = Variable(payload.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (fetchedAt.present) { + map['fetched_at'] = Variable(fetchedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CachedPostsCompanion(') + ..write('uri: $uri, ') + ..write('authorDid: $authorDid, ') + ..write('payload: $payload, ') + ..write('createdAt: $createdAt, ') + ..write('fetchedAt: $fetchedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $SettingsTable extends Settings with TableInfo<$SettingsTable, SettingsEntry> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SettingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _keyMeta = const VerificationMeta('key'); + @override + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [key, value, updatedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'settings'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('key')) { + context.handle(_keyMeta, key.isAcceptableOrUnknown(data['key']!, _keyMeta)); + } else if (isInserting) { + context.missing(_keyMeta); + } + if (data.containsKey('value')) { + context.handle(_valueMeta, value.isAcceptableOrUnknown(data['value']!, _valueMeta)); + } else if (isInserting) { + context.missing(_valueMeta); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {key}; + @override + SettingsEntry map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SettingsEntry( + key: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}key'])!, + value: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}value'])!, + updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ); + } + + @override + $SettingsTable createAlias(String alias) { + return $SettingsTable(attachedDatabase, alias); + } +} + +class SettingsEntry extends DataClass implements Insertable { + final String key; + final String value; + final DateTime updatedAt; + const SettingsEntry({required this.key, required this.value, required this.updatedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['value'] = Variable(value); + map['updated_at'] = Variable(updatedAt); + return map; + } + + SettingsCompanion toCompanion(bool nullToAbsent) { + return SettingsCompanion(key: Value(key), value: Value(value), updatedAt: Value(updatedAt)); + } + + factory SettingsEntry.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SettingsEntry( + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + SettingsEntry copyWith({String? key, String? value, DateTime? updatedAt}) => + SettingsEntry(key: key ?? this.key, value: value ?? this.value, updatedAt: updatedAt ?? this.updatedAt); + SettingsEntry copyWithCompanion(SettingsCompanion data) { + return SettingsEntry( + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('SettingsEntry(') + ..write('key: $key, ') + ..write('value: $value, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, value, updatedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SettingsEntry && + other.key == this.key && + other.value == this.value && + other.updatedAt == this.updatedAt); +} + +class SettingsCompanion extends UpdateCompanion { + final Value key; + final Value value; + final Value updatedAt; + final Value rowid; + const SettingsCompanion({ + this.key = const Value.absent(), + this.value = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + SettingsCompanion.insert({ + required String key, + required String value, + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? key, + Expression? value, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (value != null) 'value': value, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + SettingsCompanion copyWith({ + Value? key, + Value? value, + Value? updatedAt, + Value? rowid, + }) { + return SettingsCompanion( + key: key ?? this.key, + value: value ?? this.value, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SettingsCompanion(') + ..write('key: $key, ') + ..write('value: $value, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $AccountsTable accounts = $AccountsTable(this); + late final $CachedProfilesTable cachedProfiles = $CachedProfilesTable(this); + late final $CachedPostsTable cachedPosts = $CachedPostsTable(this); + late final $SettingsTable settings = $SettingsTable(this); + @override + Iterable> get allTables => allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [accounts, cachedProfiles, cachedPosts, settings]; +} + +typedef $$AccountsTableCreateCompanionBuilder = + AccountsCompanion Function({ + required String did, + required String handle, + Value displayName, + Value service, + required String accessToken, + Value refreshToken, + Value dpopPublicKey, + Value dpopPrivateKey, + Value dpopNonce, + Value expiresAt, + Value createdAt, + Value updatedAt, + Value rowid, + }); +typedef $$AccountsTableUpdateCompanionBuilder = + AccountsCompanion Function({ + Value did, + Value handle, + Value displayName, + Value service, + Value accessToken, + Value refreshToken, + Value dpopPublicKey, + Value dpopPrivateKey, + Value dpopNonce, + Value expiresAt, + Value createdAt, + Value updatedAt, + Value rowid, + }); + +class $$AccountsTableFilterComposer extends Composer<_$AppDatabase, $AccountsTable> { + $$AccountsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get did => $composableBuilder(column: $table.did, builder: (column) => ColumnFilters(column)); + + ColumnFilters get handle => $composableBuilder(column: $table.handle, builder: (column) => ColumnFilters(column)); ColumnFilters get displayName => $composableBuilder(column: $table.displayName, builder: (column) => ColumnFilters(column)); + ColumnFilters get service => + $composableBuilder(column: $table.service, builder: (column) => ColumnFilters(column)); + ColumnFilters get accessToken => $composableBuilder(column: $table.accessToken, builder: (column) => ColumnFilters(column)); ColumnFilters get refreshToken => $composableBuilder(column: $table.refreshToken, builder: (column) => ColumnFilters(column)); + ColumnFilters get dpopPublicKey => + $composableBuilder(column: $table.dpopPublicKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get dpopPrivateKey => $composableBuilder(column: $table.dpopPrivateKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get dpopNonce => + $composableBuilder(column: $table.dpopNonce, builder: (column) => ColumnFilters(column)); + ColumnFilters get expiresAt => $composableBuilder(column: $table.expiresAt, builder: (column) => ColumnFilters(column)); @@ -789,15 +1510,24 @@ class $$AccountsTableOrderingComposer extends Composer<_$AppDatabase, $AccountsT ColumnOrderings get displayName => $composableBuilder(column: $table.displayName, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get service => + $composableBuilder(column: $table.service, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get accessToken => $composableBuilder(column: $table.accessToken, builder: (column) => ColumnOrderings(column)); ColumnOrderings get refreshToken => $composableBuilder(column: $table.refreshToken, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get dpopPublicKey => + $composableBuilder(column: $table.dpopPublicKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get dpopPrivateKey => $composableBuilder(column: $table.dpopPrivateKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get dpopNonce => + $composableBuilder(column: $table.dpopNonce, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get expiresAt => $composableBuilder(column: $table.expiresAt, builder: (column) => ColumnOrderings(column)); @@ -823,15 +1553,22 @@ class $$AccountsTableAnnotationComposer extends Composer<_$AppDatabase, $Account GeneratedColumn get displayName => $composableBuilder(column: $table.displayName, builder: (column) => column); + GeneratedColumn get service => $composableBuilder(column: $table.service, builder: (column) => column); + GeneratedColumn get accessToken => $composableBuilder(column: $table.accessToken, builder: (column) => column); GeneratedColumn get refreshToken => $composableBuilder(column: $table.refreshToken, builder: (column) => column); + GeneratedColumn get dpopPublicKey => + $composableBuilder(column: $table.dpopPublicKey, builder: (column) => column); + GeneratedColumn get dpopPrivateKey => $composableBuilder(column: $table.dpopPrivateKey, builder: (column) => column); + GeneratedColumn get dpopNonce => $composableBuilder(column: $table.dpopNonce, builder: (column) => column); + GeneratedColumn get expiresAt => $composableBuilder(column: $table.expiresAt, builder: (column) => column); GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); @@ -867,9 +1604,12 @@ class $$AccountsTableTableManager Value did = const Value.absent(), Value handle = const Value.absent(), Value displayName = const Value.absent(), + Value service = const Value.absent(), Value accessToken = const Value.absent(), Value refreshToken = const Value.absent(), + Value dpopPublicKey = const Value.absent(), Value dpopPrivateKey = const Value.absent(), + Value dpopNonce = const Value.absent(), Value expiresAt = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), @@ -878,9 +1618,12 @@ class $$AccountsTableTableManager did: did, handle: handle, displayName: displayName, + service: service, accessToken: accessToken, refreshToken: refreshToken, + dpopPublicKey: dpopPublicKey, dpopPrivateKey: dpopPrivateKey, + dpopNonce: dpopNonce, expiresAt: expiresAt, createdAt: createdAt, updatedAt: updatedAt, @@ -891,9 +1634,12 @@ class $$AccountsTableTableManager required String did, required String handle, Value displayName = const Value.absent(), + Value service = const Value.absent(), required String accessToken, Value refreshToken = const Value.absent(), + Value dpopPublicKey = const Value.absent(), Value dpopPrivateKey = const Value.absent(), + Value dpopNonce = const Value.absent(), Value expiresAt = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), @@ -902,9 +1648,12 @@ class $$AccountsTableTableManager did: did, handle: handle, displayName: displayName, + service: service, accessToken: accessToken, refreshToken: refreshToken, + dpopPublicKey: dpopPublicKey, dpopPrivateKey: dpopPrivateKey, + dpopNonce: dpopNonce, expiresAt: expiresAt, createdAt: createdAt, updatedAt: updatedAt, @@ -930,6 +1679,312 @@ typedef $$AccountsTableProcessedTableManager = Account, PrefetchHooks Function() >; +typedef $$CachedProfilesTableCreateCompanionBuilder = + CachedProfilesCompanion Function({ + required String did, + required String handle, + required String payload, + Value fetchedAt, + Value rowid, + }); +typedef $$CachedProfilesTableUpdateCompanionBuilder = + CachedProfilesCompanion Function({ + Value did, + Value handle, + Value payload, + Value fetchedAt, + Value rowid, + }); + +class $$CachedProfilesTableFilterComposer extends Composer<_$AppDatabase, $CachedProfilesTable> { + $$CachedProfilesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get did => $composableBuilder(column: $table.did, builder: (column) => ColumnFilters(column)); + + ColumnFilters get handle => + $composableBuilder(column: $table.handle, builder: (column) => ColumnFilters(column)); + + ColumnFilters get payload => + $composableBuilder(column: $table.payload, builder: (column) => ColumnFilters(column)); + + ColumnFilters get fetchedAt => + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnFilters(column)); +} + +class $$CachedProfilesTableOrderingComposer extends Composer<_$AppDatabase, $CachedProfilesTable> { + $$CachedProfilesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get did => + $composableBuilder(column: $table.did, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get handle => + $composableBuilder(column: $table.handle, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get payload => + $composableBuilder(column: $table.payload, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get fetchedAt => + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnOrderings(column)); +} + +class $$CachedProfilesTableAnnotationComposer extends Composer<_$AppDatabase, $CachedProfilesTable> { + $$CachedProfilesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get did => $composableBuilder(column: $table.did, builder: (column) => column); + + GeneratedColumn get handle => $composableBuilder(column: $table.handle, builder: (column) => column); + + GeneratedColumn get payload => $composableBuilder(column: $table.payload, builder: (column) => column); + + GeneratedColumn get fetchedAt => $composableBuilder(column: $table.fetchedAt, builder: (column) => column); +} + +class $$CachedProfilesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $CachedProfilesTable, + CachedProfile, + $$CachedProfilesTableFilterComposer, + $$CachedProfilesTableOrderingComposer, + $$CachedProfilesTableAnnotationComposer, + $$CachedProfilesTableCreateCompanionBuilder, + $$CachedProfilesTableUpdateCompanionBuilder, + (CachedProfile, BaseReferences<_$AppDatabase, $CachedProfilesTable, CachedProfile>), + CachedProfile, + PrefetchHooks Function() + > { + $$CachedProfilesTableTableManager(_$AppDatabase db, $CachedProfilesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$CachedProfilesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$CachedProfilesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$CachedProfilesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value did = const Value.absent(), + Value handle = const Value.absent(), + Value payload = const Value.absent(), + Value fetchedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => CachedProfilesCompanion( + did: did, + handle: handle, + payload: payload, + fetchedAt: fetchedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String did, + required String handle, + required String payload, + Value fetchedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => CachedProfilesCompanion.insert( + did: did, + handle: handle, + payload: payload, + fetchedAt: fetchedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CachedProfilesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $CachedProfilesTable, + CachedProfile, + $$CachedProfilesTableFilterComposer, + $$CachedProfilesTableOrderingComposer, + $$CachedProfilesTableAnnotationComposer, + $$CachedProfilesTableCreateCompanionBuilder, + $$CachedProfilesTableUpdateCompanionBuilder, + (CachedProfile, BaseReferences<_$AppDatabase, $CachedProfilesTable, CachedProfile>), + CachedProfile, + PrefetchHooks Function() + >; +typedef $$CachedPostsTableCreateCompanionBuilder = + CachedPostsCompanion Function({ + required String uri, + required String authorDid, + required String payload, + Value createdAt, + Value fetchedAt, + Value rowid, + }); +typedef $$CachedPostsTableUpdateCompanionBuilder = + CachedPostsCompanion Function({ + Value uri, + Value authorDid, + Value payload, + Value createdAt, + Value fetchedAt, + Value rowid, + }); + +class $$CachedPostsTableFilterComposer extends Composer<_$AppDatabase, $CachedPostsTable> { + $$CachedPostsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get uri => $composableBuilder(column: $table.uri, builder: (column) => ColumnFilters(column)); + + ColumnFilters get authorDid => + $composableBuilder(column: $table.authorDid, builder: (column) => ColumnFilters(column)); + + ColumnFilters get payload => + $composableBuilder(column: $table.payload, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get fetchedAt => + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnFilters(column)); +} + +class $$CachedPostsTableOrderingComposer extends Composer<_$AppDatabase, $CachedPostsTable> { + $$CachedPostsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get uri => + $composableBuilder(column: $table.uri, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get authorDid => + $composableBuilder(column: $table.authorDid, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get payload => + $composableBuilder(column: $table.payload, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get fetchedAt => + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnOrderings(column)); +} + +class $$CachedPostsTableAnnotationComposer extends Composer<_$AppDatabase, $CachedPostsTable> { + $$CachedPostsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get uri => $composableBuilder(column: $table.uri, builder: (column) => column); + + GeneratedColumn get authorDid => $composableBuilder(column: $table.authorDid, builder: (column) => column); + + GeneratedColumn get payload => $composableBuilder(column: $table.payload, builder: (column) => column); + + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get fetchedAt => $composableBuilder(column: $table.fetchedAt, builder: (column) => column); +} + +class $$CachedPostsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $CachedPostsTable, + CachedPost, + $$CachedPostsTableFilterComposer, + $$CachedPostsTableOrderingComposer, + $$CachedPostsTableAnnotationComposer, + $$CachedPostsTableCreateCompanionBuilder, + $$CachedPostsTableUpdateCompanionBuilder, + (CachedPost, BaseReferences<_$AppDatabase, $CachedPostsTable, CachedPost>), + CachedPost, + PrefetchHooks Function() + > { + $$CachedPostsTableTableManager(_$AppDatabase db, $CachedPostsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$CachedPostsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$CachedPostsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$CachedPostsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value uri = const Value.absent(), + Value authorDid = const Value.absent(), + Value payload = const Value.absent(), + Value createdAt = const Value.absent(), + Value fetchedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => CachedPostsCompanion( + uri: uri, + authorDid: authorDid, + payload: payload, + createdAt: createdAt, + fetchedAt: fetchedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String uri, + required String authorDid, + required String payload, + Value createdAt = const Value.absent(), + Value fetchedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => CachedPostsCompanion.insert( + uri: uri, + authorDid: authorDid, + payload: payload, + createdAt: createdAt, + fetchedAt: fetchedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CachedPostsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $CachedPostsTable, + CachedPost, + $$CachedPostsTableFilterComposer, + $$CachedPostsTableOrderingComposer, + $$CachedPostsTableAnnotationComposer, + $$CachedPostsTableCreateCompanionBuilder, + $$CachedPostsTableUpdateCompanionBuilder, + (CachedPost, BaseReferences<_$AppDatabase, $CachedPostsTable, CachedPost>), + CachedPost, + PrefetchHooks Function() + >; typedef $$SettingsTableCreateCompanionBuilder = SettingsCompanion Function({ required String key, @@ -1052,5 +2107,7 @@ class $AppDatabaseManager { final _$AppDatabase _db; $AppDatabaseManager(this._db); $$AccountsTableTableManager get accounts => $$AccountsTableTableManager(_db, _db.accounts); + $$CachedProfilesTableTableManager get cachedProfiles => $$CachedProfilesTableTableManager(_db, _db.cachedProfiles); + $$CachedPostsTableTableManager get cachedPosts => $$CachedPostsTableTableManager(_db, _db.cachedPosts); $$SettingsTableTableManager get settings => $$SettingsTableTableManager(_db, _db.settings); } diff --git a/lib/core/database/tables.dart b/lib/core/database/tables.dart index a96668a..7cef21c 100644 --- a/lib/core/database/tables.dart +++ b/lib/core/database/tables.dart @@ -5,9 +5,12 @@ class Accounts extends Table { TextColumn get did => text()(); TextColumn get handle => text()(); TextColumn get displayName => text().nullable()(); + TextColumn get service => text().nullable()(); TextColumn get accessToken => text()(); TextColumn get refreshToken => text().nullable()(); + TextColumn get dpopPublicKey => text().nullable()(); TextColumn get dpopPrivateKey => text().nullable()(); + TextColumn get dpopNonce => text().nullable()(); DateTimeColumn get expiresAt => dateTime().nullable()(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); @@ -16,6 +19,29 @@ class Accounts extends Table { Set get primaryKey => {did}; } +@DataClassName('CachedProfile') +class CachedProfiles extends Table { + TextColumn get did => text()(); + TextColumn get handle => text()(); + TextColumn get payload => text()(); + DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {did}; +} + +@DataClassName('CachedPost') +class CachedPosts extends Table { + TextColumn get uri => text()(); + TextColumn get authorDid => text()(); + TextColumn get payload => text()(); + DateTimeColumn get createdAt => dateTime().nullable()(); + DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {uri}; +} + @DataClassName('SettingsEntry') class Settings extends Table { TextColumn get key => text()(); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index e494c54..f84d874 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/presentation/home_screen.dart'; import 'package:lazurite/features/auth/presentation/login_screen.dart'; +import 'package:lazurite/features/profile/presentation/profile_screen.dart'; import 'package:lazurite/features/settings/presentation/settings_screen.dart'; class AppRouter { @@ -30,6 +31,7 @@ class AppRouter { routes: [ GoRoute(path: '/', builder: (context, state) => const HomeScreen()), GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), + GoRoute(path: '/profile', builder: (context, state) => const ProfileScreen()), GoRoute(path: '/settings', builder: (context, state) => const SettingsScreen()), ], ); diff --git a/lib/features/auth/bloc/auth_bloc.dart b/lib/features/auth/bloc/auth_bloc.dart index fd1f929..e3fbcdd 100644 --- a/lib/features/auth/bloc/auth_bloc.dart +++ b/lib/features/auth/bloc/auth_bloc.dart @@ -7,52 +7,49 @@ part 'auth_event.dart'; part 'auth_state.dart'; class AuthBloc extends Bloc { - AuthBloc({required AuthRepository authRepository}) + AuthBloc({required AuthRepository authRepository, AuthState initialState = const AuthState.unauthenticated()}) : _authRepository = authRepository, - super(const AuthState.unauthenticated()) { - on(_onEvent); + super(initialState) { + on(_onLoginRequested); + on(_onOAuthLoginRequested); + on(_onLogoutRequested); + on(_onSessionRestored); + on(_onCheckSessionRequested); } - final AuthRepository _authRepository; - Future _onEvent(AuthEvent event, Emitter emit) async { - if (event is LoginRequested) { - await _onLoginRequested(event, emit); - } else if (event is OAuthLoginRequested) { - await _onOAuthLoginRequested(event, emit); - } else if (event is LogoutRequested) { - await _onLogoutRequested(event, emit); - } else if (event is SessionRestored) { - await _onSessionRestored(event, emit); - } else if (event is CheckSessionRequested) { - await _onCheckSessionRequested(event, emit); - } - } + final AuthRepository _authRepository; Future _onLoginRequested(LoginRequested event, Emitter emit) async { emit(const AuthState.authenticating()); + try { final tokens = await _authRepository.loginWithAppPassword(event.handle, event.appPassword); - if (tokens != null) { - emit(AuthState.authenticated(tokens)); - } else { - emit(const AuthState.authError('Login failed')); + + if (tokens == null) { + emit(const AuthState.authError('Login failed.')); + return; } - } catch (e) { - emit(const AuthState.authError('Login failed: \$e')); + + emit(AuthState.authenticated(tokens)); + } catch (error) { + emit(AuthState.authError('Login failed: $error')); } } Future _onOAuthLoginRequested(OAuthLoginRequested event, Emitter emit) async { emit(const AuthState.authenticating()); + try { final tokens = await _authRepository.loginWithOAuth(event.handle); - if (tokens != null) { - emit(AuthState.authenticated(tokens)); - } else { - emit(const AuthState.authError('OAuth login failed')); + + if (tokens == null) { + emit(const AuthState.authError('OAuth login failed.')); + return; } - } catch (e) { - emit(const AuthState.authError('OAuth login failed: \$e')); + + emit(AuthState.authenticated(tokens)); + } catch (error) { + emit(AuthState.authError('OAuth login failed: $error')); } } @@ -60,8 +57,8 @@ class AuthBloc extends Bloc { try { await _authRepository.logout(); emit(const AuthState.unauthenticated()); - } catch (e) { - emit(const AuthState.authError('Logout failed: \$e')); + } catch (error) { + emit(AuthState.authError('Logout failed: $error')); } } @@ -70,23 +67,17 @@ class AuthBloc extends Bloc { } Future _onCheckSessionRequested(CheckSessionRequested event, Emitter emit) async { + emit(const AuthState.authenticating()); + try { - final tokens = await _authRepository.getStoredSession(); - if (tokens != null) { - if (tokens.isExpired && tokens.refreshToken != null) { - final refreshed = await _authRepository.refreshSession(tokens.refreshToken!); - if (refreshed != null) { - emit(AuthState.authenticated(refreshed)); - } else { - emit(const AuthState.unauthenticated()); - } - } else { - emit(AuthState.authenticated(tokens)); - } - } else { + final tokens = await _authRepository.restoreSession(); + if (tokens == null) { emit(const AuthState.unauthenticated()); + return; } - } catch (e) { + + emit(AuthState.authenticated(tokens)); + } catch (_) { emit(const AuthState.unauthenticated()); } } diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index a7cb237..d58ab99 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -1,28 +1,36 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; -import 'dart:math'; +import 'package:atproto/atproto.dart' as atp; +import 'package:atproto_core/atproto_core.dart' as atcore; import 'package:atproto_oauth/atproto_oauth.dart'; -import 'package:bluesky/atproto.dart' as atp; -import 'package:crypto/crypto.dart'; +import 'package:bluesky/bluesky.dart'; import 'package:drift/drift.dart'; +import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../../../core/database/app_database.dart'; - class AuthRepository { AuthRepository({required AppDatabase database}) : _database = database; - static const String _clientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; + + static const String kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; + static const String _fallbackService = 'bsky.social'; final AppDatabase _database; + HttpServer? _callbackServer; Completer? _oauthCompleter; + OAuthClient? _pendingOAuthClient; + OAuthContext? _pendingOAuthContext; + Uri? _pendingRedirectUri; + String? _pendingHandle; + String? _pendingService; Future getStoredSession() async { final account = await _database.getActiveAccount(); - if (account == null) return null; + if (account == null) { + return null; + } return AuthTokens( accessToken: account.accessToken, @@ -31,18 +39,53 @@ class AuthRepository { did: account.did, handle: account.handle, displayName: account.displayName, + service: account.service, + dpopNonce: account.dpopNonce, + dpopPublicKey: account.dpopPublicKey, + dpopPrivateKey: account.dpopPrivateKey, + authMethod: account.dpopPrivateKey != null && account.dpopPublicKey != null + ? AuthMethod.oauth + : AuthMethod.appPassword, ); } + Future restoreSession() async { + final storedSession = await getStoredSession(); + if (storedSession == null) { + return null; + } + + if (!storedSession.isExpired) { + return storedSession; + } + + if (storedSession.refreshToken == null) { + await clearSession(); + return null; + } + + try { + return await refreshSession(storedSession); + } catch (_) { + await clearSession(); + return null; + } + } + Future saveSession(AuthTokens tokens) async { await _database.insertAccount( AccountsCompanion( did: Value(tokens.did), handle: Value(tokens.handle), displayName: tokens.displayName != null ? Value(tokens.displayName) : const Value.absent(), + service: tokens.service != null ? Value(tokens.service) : const Value.absent(), accessToken: Value(tokens.accessToken), refreshToken: tokens.refreshToken != null ? Value(tokens.refreshToken) : const Value.absent(), - expiresAt: tokens.expiresAt != null ? Value(tokens.expiresAt!) : const Value.absent(), + dpopPublicKey: tokens.dpopPublicKey != null ? Value(tokens.dpopPublicKey) : const Value.absent(), + dpopPrivateKey: tokens.dpopPrivateKey != null ? Value(tokens.dpopPrivateKey) : const Value.absent(), + dpopNonce: tokens.dpopNonce != null ? Value(tokens.dpopNonce) : const Value.absent(), + expiresAt: tokens.expiresAt != null ? Value(tokens.expiresAt) : const Value.absent(), + updatedAt: Value(DateTime.now()), ), ); } @@ -54,122 +97,259 @@ class AuthRepository { Future loginWithOAuth(String handle) async { try { _oauthCompleter = Completer(); + _pendingHandle = handle.trim(); + _pendingService = await _resolveServiceForIdentifier(_pendingHandle!); - await _startCallbackServer(); - - final metadata = await getClientMetadata(_clientId); - final oauthClient = OAuthClient(metadata); + final metadata = await getClientMetadata(kClientId); + final redirectUri = Uri.parse(metadata.redirectUris.first); + final oauthClient = OAuthClient(metadata, service: _pendingService!); + final (authorizationUrl, context) = await oauthClient.authorize(_pendingHandle); - final result = await oauthClient.authorize(handle); - final authorizationUrl = result.$1; + _pendingOAuthClient = oauthClient; + _pendingOAuthContext = context; + _pendingRedirectUri = redirectUri; + await _startCallbackServer(redirectUri); await _launchUrl(authorizationUrl); - final tokens = await _oauthCompleter!.future; - return tokens; - } catch (e) { + return await _oauthCompleter!.future; + } catch (error) { await _stopCallbackServer(); - rethrow; + _resetPendingOAuthState(); + throw Exception('Failed to login with OAuth: $error'); } } Future loginWithAppPassword(String handle, String appPassword) async { try { - final session = await atp.createSession(identifier: handle, password: appPassword); + final service = await _resolveServiceForIdentifier(handle); + final session = await atp.createSession(identifier: handle, password: appPassword, service: service); final tokens = AuthTokens( accessToken: session.data.accessJwt, refreshToken: session.data.refreshJwt, + expiresAt: session.data.accessTokenJwt.exp, did: session.data.did, handle: session.data.handle, displayName: null, + service: service, + authMethod: AuthMethod.appPassword, ); await saveSession(tokens); return tokens; - } catch (e) { - throw Exception('Failed to login with app password: $e'); + } catch (error) { + throw Exception('Failed to login with app password: $error'); } } - Future refreshSession(String refreshToken) async { + Future refreshSession(AuthTokens currentSession) async { + if (currentSession.refreshToken == null) { + throw Exception('No refresh token available for session refresh'); + } + + if (currentSession.usesOAuth) { + final publicKey = currentSession.dpopPublicKey; + final privateKey = currentSession.dpopPrivateKey; + if (publicKey == null || privateKey == null) { + throw Exception('Stored OAuth session is missing DPoP keys'); + } + + try { + final metadata = await getClientMetadata(kClientId); + final oauthClient = OAuthClient(metadata, service: currentSession.service ?? _fallbackService); + final restoredSession = atcore.restoreOAuthSession( + accessToken: currentSession.accessToken, + refreshToken: currentSession.refreshToken!, + dPoPNonce: currentSession.dpopNonce, + publicKey: publicKey, + privateKey: privateKey, + ); + + final refreshedSession = await oauthClient.refresh(restoredSession); + final refreshedTokens = await _buildOAuthTokens( + refreshedSession, + fallbackHandle: currentSession.handle, + service: currentSession.service ?? _fallbackService, + ); + + await saveSession(refreshedTokens); + return refreshedTokens; + } catch (error) { + await clearSession(); + throw Exception('Failed to refresh OAuth session: $error'); + } + } + try { - final refreshed = await atp.refreshSession(refreshJwt: refreshToken); + final refreshed = await atp.refreshSession( + refreshJwt: currentSession.refreshToken!, + service: currentSession.service, + ); final tokens = AuthTokens( accessToken: refreshed.data.accessJwt, refreshToken: refreshed.data.refreshJwt, + expiresAt: refreshed.data.accessTokenJwt.exp, did: refreshed.data.did, handle: refreshed.data.handle, - displayName: null, + displayName: currentSession.displayName, + service: currentSession.service, + authMethod: AuthMethod.appPassword, ); await saveSession(tokens); return tokens; - } catch (e) { + } catch (error) { await clearSession(); - throw Exception('Failed to refresh session: $e'); + throw Exception('Failed to refresh session: $error'); } } Future logout() async { - await clearSession(); + final storedSession = await getStoredSession(); + + try { + if (storedSession?.refreshToken != null && storedSession?.usesOAuth == false) { + await atp.deleteSession(refreshJwt: storedSession!.refreshToken!, service: storedSession.service); + } + } finally { + await clearSession(); + } } - Future _startCallbackServer() async { - _callbackServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + Future _startCallbackServer(Uri redirectUri) async { + final requestedPort = redirectUri.hasPort ? redirectUri.port : 80; - _callbackServer!.listen((request) async { - final uri = request.requestedUri; + _callbackServer = await HttpServer.bind(InternetAddress.loopbackIPv4, requestedPort); - if (uri.path == '/callback') { - final code = uri.queryParameters['code']; - final error = uri.queryParameters['error']; + unawaited( + _callbackServer!.forEach((request) async { + final uri = request.requestedUri; + + if (uri.path != redirectUri.path) { + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + return; + } request.response - ..statusCode = 200 + ..statusCode = HttpStatus.ok ..headers.contentType = ContentType.html - ..write(''' - - - Authentication Complete - -

Authentication Complete

-

You can close this window and return to the app.

- - - '''); - + ..write(_callbackPageHtml); await request.response.close(); + + final callbackUrl = uri.replace( + scheme: redirectUri.scheme, + host: redirectUri.host, + port: _pendingRedirectUri?.hasPort == true ? _pendingRedirectUri!.port : null, + ); + await _stopCallbackServer(); - if (error != null) { - _oauthCompleter?.completeError(Exception('OAuth error: $error')); - } else if (code != null) { - try { - final tokens = await _exchangeCodeForTokens(code); - await saveSession(tokens); - _oauthCompleter?.complete(tokens); - } catch (e) { - _oauthCompleter?.completeError(e); - } - } else { - _oauthCompleter?.completeError(Exception('No authorization code received')); + try { + final tokens = await _handleOAuthCallback(callbackUrl.toString()); + _oauthCompleter?.complete(tokens); + } catch (error) { + _oauthCompleter?.completeError(error); + } finally { + _resetPendingOAuthState(); } - } else { - request.response.statusCode = 404; - await request.response.close(); - } - }); + }), + ); + } + + Future _handleOAuthCallback(String callbackUrl) async { + final oauthClient = _pendingOAuthClient; + final oauthContext = _pendingOAuthContext; + final fallbackHandle = _pendingHandle; + final service = _pendingService; + + if (oauthClient == null || oauthContext == null || fallbackHandle == null || service == null) { + throw StateError('OAuth callback received without an active auth flow'); + } + + final oauthSession = await oauthClient.callback(callbackUrl, oauthContext); + final tokens = await _buildOAuthTokens(oauthSession, fallbackHandle: fallbackHandle, service: service); + await saveSession(tokens); + return tokens; + } + + Future _buildOAuthTokens( + OAuthSession session, { + required String fallbackHandle, + required String service, + }) async { + var resolvedHandle = fallbackHandle; + String? displayName; + + try { + final authSession = await atp.ATProto.fromOAuthSession(session, service: service).server.getSession(); + resolvedHandle = authSession.data.handle; + } catch (_) { + // Fall back to the login hint if the server session lookup fails. + } + + try { + final profile = await Bluesky.fromOAuthSession(session, service: service).actor.getProfile(actor: session.sub); + displayName = profile.data.displayName; + } catch (_) { + // Display name is optional and should not block session persistence. + } + + return AuthTokens( + accessToken: session.accessToken, + refreshToken: session.refreshToken, + expiresAt: session.expiresAt, + did: session.sub, + handle: resolvedHandle, + displayName: displayName, + service: service, + dpopNonce: session.$dPoPNonce, + dpopPublicKey: session.$publicKey, + dpopPrivateKey: session.$privateKey, + authMethod: AuthMethod.oauth, + ); } Future _stopCallbackServer() async { - await _callbackServer?.close(); + await _callbackServer?.close(force: true); _callbackServer = null; } - Future _exchangeCodeForTokens(String code) async { - throw UnimplementedError('OAuth token exchange not yet implemented'); + Future _resolveServiceForIdentifier(String identifier) async { + final client = atp.ATProto.anonymous(service: _fallbackService); + + final did = identifier.startsWith('did:') + ? identifier + : (await client.identity.resolveHandle(handle: identifier)).data.did; + + final didDoc = (await client.identity.resolveDid(did: did)).data.didDoc; + return _extractServiceEndpoint(didDoc) ?? _fallbackService; + } + + String? _extractServiceEndpoint(Map didDoc) { + final services = didDoc['service']; + if (services is! List) { + return null; + } + + for (final service in services) { + if (service is! Map) { + continue; + } + + if (service['id'] == '#atproto_pds' && + service['type'] == 'AtprotoPersonalDataServer' && + service['serviceEndpoint'] is String) { + final endpoint = Uri.tryParse(service['serviceEndpoint'] as String); + if (endpoint != null && endpoint.host.isNotEmpty) { + return endpoint.host; + } + } + } + + return null; } Future _launchUrl(Uri url) async { @@ -178,17 +358,59 @@ class AuthRepository { } } + void _resetPendingOAuthState() { + _pendingOAuthClient = null; + _pendingOAuthContext = null; + _pendingRedirectUri = null; + _pendingHandle = null; + _pendingService = null; + } + int get callbackPort => _callbackServer?.port ?? 0; } -String generateCodeVerifier() { - final random = Random.secure(); - final values = List.generate(32, (_) => random.nextInt(256)); - return base64Url.encode(values).replaceAll('=', ''); -} +const String _callbackPageHtml = ''' + + + + + + Lazurite Authentication Complete + + + +
+

Authentication Complete

+

You can close this window and return to Lazurite.

+
+ + +'''; diff --git a/lib/features/auth/data/models/auth_models.dart b/lib/features/auth/data/models/auth_models.dart index ddec9dd..1d27f26 100644 --- a/lib/features/auth/data/models/auth_models.dart +++ b/lib/features/auth/data/models/auth_models.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +enum AuthMethod { appPassword, oauth } + class AuthTokens extends Equatable { const AuthTokens({ required this.accessToken, @@ -8,6 +10,11 @@ class AuthTokens extends Equatable { required this.did, required this.handle, this.displayName, + this.service, + this.dpopNonce, + this.dpopPublicKey, + this.dpopPrivateKey, + this.authMethod = AuthMethod.appPassword, }); final String accessToken; final String? refreshToken; @@ -15,6 +22,11 @@ class AuthTokens extends Equatable { final String did; final String handle; final String? displayName; + final String? service; + final String? dpopNonce; + final String? dpopPublicKey; + final String? dpopPrivateKey; + final AuthMethod authMethod; AuthTokens copyWith({ String? accessToken, @@ -23,6 +35,11 @@ class AuthTokens extends Equatable { String? did, String? handle, String? displayName, + String? service, + String? dpopNonce, + String? dpopPublicKey, + String? dpopPrivateKey, + AuthMethod? authMethod, }) { return AuthTokens( accessToken: accessToken ?? this.accessToken, @@ -31,16 +48,35 @@ class AuthTokens extends Equatable { did: did ?? this.did, handle: handle ?? this.handle, displayName: displayName ?? this.displayName, + service: service ?? this.service, + dpopNonce: dpopNonce ?? this.dpopNonce, + dpopPublicKey: dpopPublicKey ?? this.dpopPublicKey, + dpopPrivateKey: dpopPrivateKey ?? this.dpopPrivateKey, + authMethod: authMethod ?? this.authMethod, ); } + bool get usesOAuth => authMethod == AuthMethod.oauth; + bool get isExpired { if (expiresAt == null) return false; return DateTime.now().isAfter(expiresAt!.subtract(const Duration(minutes: 5))); } @override - List get props => [accessToken, refreshToken, expiresAt, did, handle, displayName]; + List get props => [ + accessToken, + refreshToken, + expiresAt, + did, + handle, + displayName, + service, + dpopNonce, + dpopPublicKey, + dpopPrivateKey, + authMethod, + ]; } class User extends Equatable { diff --git a/lib/features/auth/presentation/home_screen.dart b/lib/features/auth/presentation/home_screen.dart index d7cc979..9427d62 100644 --- a/lib/features/auth/presentation/home_screen.dart +++ b/lib/features/auth/presentation/home_screen.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; - -import '../../../features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @@ -13,7 +12,8 @@ class HomeScreen extends StatelessWidget { appBar: AppBar( title: const Text('Lazurite'), actions: [ - IconButton(icon: const Icon(Icons.settings), onPressed: () => context.push('/settings')), + IconButton(icon: const Icon(Icons.person_outline), onPressed: () => context.push('/profile')), + IconButton(icon: const Icon(Icons.settings_outlined), onPressed: () => context.push('/settings')), IconButton( icon: const Icon(Icons.logout), onPressed: () { @@ -24,19 +24,28 @@ class HomeScreen extends StatelessWidget { ), body: BlocBuilder( builder: (context, state) { - if (!state.isAuthenticated || state.tokens == null) { + final tokens = state.tokens; + if (!state.isAuthenticated || tokens == null) { return const Center(child: Text('Not authenticated')); } return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Welcome!', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 16), - const Text('Handle: @\${state.tokens!.handle}'), - const Text('DID: \${state.tokens!.did}'), - ], + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + tokens.displayName ?? 'Welcome', + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text('@${tokens.handle}'), + const SizedBox(height: 8), + SelectableText(tokens.did, textAlign: TextAlign.center), + ], + ), ), ); }, diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index 0f1c0e3..90017f7 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -1,8 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -25,136 +24,187 @@ class _LoginScreenState extends State { } void _onOAuthLogin() { - if (_formKey.currentState?.validate() ?? false) { - final handle = _handleController.text.trim(); - context.read().add(OAuthLoginRequested(handle: handle)); + if (!_isHandleValid()) { + return; } + + context.read().add(OAuthLoginRequested(handle: _handleController.text.trim())); } void _onAppPasswordLogin() { - if (_formKey.currentState?.validate() ?? false) { - final handle = _handleController.text.trim(); - final appPassword = _appPasswordController.text.trim(); - context.read().add(LoginRequested(handle: handle, appPassword: appPassword)); + if (!(_formKey.currentState?.validate() ?? false)) { + return; } + + context.read().add( + LoginRequested(handle: _handleController.text.trim(), appPassword: _appPasswordController.text.trim()), + ); + } + + bool _isHandleValid() { + return _formKey.currentState?.validate() ?? false; } @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Scaffold( - body: BlocListener( - listener: (context, state) { - if (state.hasError && state.errorMessage != null) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.errorMessage!))); - } - }, - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon(Icons.cloud, size: 80, color: Colors.blue), - const SizedBox(height: 24), - Text('Lazurite', style: Theme.of(context).textTheme.headlineLarge, textAlign: TextAlign.center), - const SizedBox(height: 8), - Text('Sign in to BlueSky', style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center), - const SizedBox(height: 32), - TextFormField( - controller: _handleController, - decoration: const InputDecoration( - labelText: 'Handle', - hintText: 'your-handle.bsky.social', - prefixIcon: Icon(Icons.person), - border: OutlineInputBorder(), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your handle'; - } - return null; - }, - textInputAction: TextInputAction.next, - autocorrect: false, - ), - const SizedBox(height: 24), - BlocBuilder( - builder: (context, state) { - return ElevatedButton.icon( - onPressed: state.isLoading ? null : _onOAuthLogin, - icon: state.isLoading - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.login), - label: Text(state.isLoading ? 'Signing in...' : 'Sign in with BlueSky'), - style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), - ); - }, - ), - if (kDebugMode) ...[ - const SizedBox(height: 16), - TextButton( - onPressed: () { - setState(() { - _showDebugForm = !_showDebugForm; - }); - }, - child: Text(_showDebugForm ? 'Hide Debug Login' : 'Show Debug Login (App Password)'), - ), - if (_showDebugForm) ...[ - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 16), - Text( - 'Debug Login (App Password)', - style: Theme.of(context).textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - TextFormField( - controller: _appPasswordController, - decoration: const InputDecoration( - labelText: 'App Password', - hintText: 'xxxx-xxxx-xxxx-xxxx', - prefixIcon: Icon(Icons.lock), - border: OutlineInputBorder(), + body: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [colorScheme.surface, colorScheme.surfaceContainerLowest], + ), + ), + child: SafeArea( + child: BlocListener( + listener: (context, state) { + if (state.hasError && state.errorMessage != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.errorMessage!))); + } + }, + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + _LogoCard(colorScheme: colorScheme), + const SizedBox(height: 24), + Text( + 'Lazurite', + textAlign: TextAlign.center, + style: theme.textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + 'Connect with BlueSky', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant), ), - obscureText: true, - validator: (value) { - if (_showDebugForm && (value == null || value.isEmpty)) { - return 'Please enter your app password'; - } - return null; - }, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _onAppPasswordLogin(), - ), - const SizedBox(height: 16), - BlocBuilder( - builder: (context, state) { - return ElevatedButton.icon( - onPressed: state.isLoading ? null : _onAppPasswordLogin, - icon: state.isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.bug_report), - label: Text(state.isLoading ? 'Signing in...' : 'Debug Sign In'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.orange, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), + const SizedBox(height: 32), + TextFormField( + controller: _handleController, + decoration: const InputDecoration( + labelText: 'Handle or DID', + hintText: 'username.bsky.social or did:plc:...', + prefixIcon: Icon(Icons.person_outline), + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + autocorrect: false, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Enter your BlueSky handle or DID'; + } + return null; + }, + ), + const SizedBox(height: 20), + BlocBuilder( + builder: (context, state) { + return FilledButton.icon( + onPressed: state.isLoading ? null : _onOAuthLogin, + icon: state.isLoading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.language), + label: Text(state.isLoading ? 'Starting sign in...' : 'Continue with BlueSky OAuth'), + style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 18)), + ); + }, + ), + if (kDebugMode) ...[ + const SizedBox(height: 24), + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text('Debug', style: theme.textTheme.labelMedium), + ), + const Expanded(child: Divider()), + ], + ), + const SizedBox(height: 16), + Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon(Icons.bug_report_outlined), + const SizedBox(width: 8), + Text('App Password Login', style: theme.textTheme.titleMedium), + const Spacer(), + TextButton( + onPressed: () { + setState(() { + _showDebugForm = !_showDebugForm; + }); + }, + child: Text(_showDebugForm ? 'Hide' : 'Show'), + ), + ], + ), + if (_showDebugForm) ...[ + const SizedBox(height: 12), + TextFormField( + controller: _appPasswordController, + decoration: const InputDecoration( + labelText: 'App Password', + hintText: 'xxxx-xxxx-xxxx-xxxx', + prefixIcon: Icon(Icons.lock_outline), + border: OutlineInputBorder(), + ), + obscureText: true, + validator: (value) { + if (_showDebugForm && (value == null || value.trim().isEmpty)) { + return 'Enter your app password'; + } + return null; + }, + onFieldSubmitted: (_) => _onAppPasswordLogin(), + ), + const SizedBox(height: 12), + BlocBuilder( + builder: (context, state) { + return OutlinedButton.icon( + onPressed: state.isLoading ? null : _onAppPasswordLogin, + icon: const Icon(Icons.login), + label: const Text('Sign In'), + ); + }, + ), + const SizedBox(height: 8), + Text( + 'Generate app passwords from BlueSky Settings -> App Passwords.', + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ], + ), ), - ); - }, - ), - ], - ], - ], + ), + ], + ], + ), + ), + ), ), ), ), @@ -163,3 +213,27 @@ class _LoginScreenState extends State { ); } } + +class _LogoCard extends StatelessWidget { + const _LogoCard({required this.colorScheme}); + + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 88, + height: 88, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient(colors: [colorScheme.primary, colorScheme.secondary]), + boxShadow: [ + BoxShadow(color: colorScheme.primary.withValues(alpha: 0.24), blurRadius: 28, offset: const Offset(0, 12)), + ], + ), + child: const Icon(Icons.layers_outlined, color: Colors.white, size: 42), + ), + ); + } +} diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart new file mode 100644 index 0000000..16c22b9 --- /dev/null +++ b/lib/features/profile/presentation/profile_screen.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; + +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + final tokens = state.tokens; + + return Scaffold( + appBar: AppBar(title: const Text('Profile')), + body: tokens == null + ? const Center(child: Text('No active account')) + : Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(tokens.displayName ?? tokens.handle, style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 8), + Text('@${tokens.handle}', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + SelectableText(tokens.did, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 24), + Text( + 'Phase 1 milestone 0 requires the profile route and feature structure. Full profile rendering is scheduled in milestone 2.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index a04da18..93c98d7 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; - -import '../../../features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -15,14 +14,18 @@ class SettingsScreen extends StatelessWidget { children: [ BlocBuilder( builder: (context, state) { - if (state.isAuthenticated && state.tokens != null) { - return ListTile( - leading: const Icon(Icons.person), - title: const Text('@\${state.tokens!.handle}'), - subtitle: Text(state.tokens!.displayName ?? 'No display name'), - ); + final tokens = state.tokens; + if (!state.isAuthenticated || tokens == null) { + return const SizedBox.shrink(); } - return const SizedBox.shrink(); + + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(tokens.displayName ?? tokens.handle), + subtitle: Text('@${tokens.handle}'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/profile'), + ); }, ), const Divider(), diff --git a/lib/main.dart b/lib/main.dart index 58b3108..6bbeca2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,25 +1,29 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/router/app_router.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/data/auth_repository.dart'; -import 'core/database/app_database.dart'; -import 'core/router/app_router.dart'; -import 'features/auth/bloc/auth_bloc.dart'; -import 'features/auth/data/auth_repository.dart'; - -void main() async { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); final database = AppDatabase(); final authRepository = AuthRepository(database: database); - final authBloc = AuthBloc(authRepository: authRepository); - - authBloc.add(const CheckSessionRequested()); + final restoredSession = await authRepository.restoreSession(); + final authBloc = AuthBloc( + authRepository: authRepository, + initialState: restoredSession != null + ? AuthState.authenticated(restoredSession) + : const AuthState.unauthenticated(), + ); runApp(LazuriteApp(authBloc: authBloc)); } class LazuriteApp extends StatelessWidget { const LazuriteApp({super.key, required this.authBloc}); + final AuthBloc authBloc; @override diff --git a/pubspec.lock b/pubspec.lock index 3f7bf9a..9e739a2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -42,7 +42,7 @@ packages: source: hosted version: "1.0.0" atproto: - dependency: transitive + dependency: "direct main" description: name: atproto sha256: "115944b8e4859bdaa0fe0d5162725ddf80269f14e79a33ab7ee05db972932ef9" @@ -50,7 +50,7 @@ packages: source: hosted version: "1.4.1" atproto_core: - dependency: transitive + dependency: "direct main" description: name: atproto_core sha256: "76b8c6237cf0c446c2f056cd76723eb5d7993ecaf7f7cc417b1b6bf26d8f24ce" @@ -821,22 +821,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - sqflite_common_ffi: - dependency: "direct dev" - description: - name: sqflite_common_ffi - sha256: "8d7b8749a516cbf6e9057f9b480b716ad14fc4f3d3873ca6938919cc626d9025" - url: "https://pub.dev" - source: hosted - version: "2.3.7+1" sqlite3: dependency: transitive description: @@ -893,14 +877,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 - url: "https://pub.dev" - source: hosted - version: "3.4.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9731f45..459abd1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: lazurite -description: "A new Flutter project." +description: A material BlueSky client. publish_to: "none" version: 1.0.0+1 @@ -10,6 +10,8 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.8 + atproto: ^1.4.1 + atproto_core: ^1.2.0 bluesky: ^1.4.1 bluesky_text: ^1.1.1 atproto_oauth: ^0.2.0 @@ -35,7 +37,6 @@ dev_dependencies: json_serializable: ^6.8.0 bloc_test: ^10.0.0 mocktail: ^1.0.4 - sqflite_common_ffi: ^2.3.5 flutter: uses-material-design: true diff --git a/scripts/.python-version b/scripts/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/scripts/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..cb513de --- /dev/null +++ b/scripts/README.md @@ -0,0 +1 @@ +# ATProto/BlueSky API Scripts diff --git a/scripts/main.py b/scripts/main.py new file mode 100644 index 0000000..00f5518 --- /dev/null +++ b/scripts/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from scripts!") + + +if __name__ == "__main__": + main() diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml new file mode 100644 index 0000000..5913644 --- /dev/null +++ b/scripts/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "scripts" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart index fdd6151..acd1c1a 100644 --- a/test/core/database/app_database_test.dart +++ b/test/core/database/app_database_test.dart @@ -1,17 +1,12 @@ +import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/database/app_database.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; void main() { late AppDatabase database; - setUpAll(() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - }); - setUp(() async { - database = AppDatabase(); + database = AppDatabase(executor: NativeDatabase.memory()); }); tearDown(() async { @@ -125,6 +120,7 @@ void main() { 'did:plc:abc123', accessToken: 'new_token', refreshToken: 'new_refresh', + dpopNonce: 'nonce-1', ); expect(updated, isTrue); @@ -132,6 +128,32 @@ void main() { final retrieved = await database.getAccount('did:plc:abc123'); expect(retrieved!.accessToken, equals('new_token')); expect(retrieved.refreshToken, equals('new_refresh')); + expect(retrieved.dpopNonce, equals('nonce-1')); + }); + }); + + group('Cache operations', () { + test('should cache a profile payload', () async { + await database.cacheProfile( + did: 'did:plc:abc123', + handle: 'user.bsky.social', + payload: '{"did":"did:plc:abc123"}', + ); + + final cached = await database.select(database.cachedProfiles).getSingle(); + expect(cached.did, equals('did:plc:abc123')); + expect(cached.handle, equals('user.bsky.social')); + }); + + test('should cache a post payload', () async { + await database.cachePost( + uri: 'at://did:plc:abc123/app.bsky.feed.post/123', + authorDid: 'did:plc:abc123', + payload: '{"uri":"at://did:plc:abc123/app.bsky.feed.post/123"}', + ); + + final cached = await database.select(database.cachedPosts).getSingle(); + expect(cached.authorDid, equals('did:plc:abc123')); }); }); diff --git a/test/features/auth/bloc/auth_bloc_test.dart b/test/features/auth/bloc/auth_bloc_test.dart index 71c6b7a..01a9e00 100644 --- a/test/features/auth/bloc/auth_bloc_test.dart +++ b/test/features/auth/bloc/auth_bloc_test.dart @@ -1,9 +1,9 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/auth_repository.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:mocktail/mocktail.dart'; class MockAuthRepository extends Mock implements AuthRepository {} @@ -87,20 +87,20 @@ void main() { 'emits [unauthenticated] when CheckSessionRequested is added and no session exists', build: () => AuthBloc(authRepository: mockAuthRepository), setUp: () { - when(() => mockAuthRepository.getStoredSession()).thenAnswer((_) async => null); + when(() => mockAuthRepository.restoreSession()).thenAnswer((_) async => null); }, act: (bloc) => bloc.add(const CheckSessionRequested()), - expect: () => [const AuthState.unauthenticated()], + expect: () => [const AuthState.authenticating(), const AuthState.unauthenticated()], ); blocTest( 'emits [authenticated] when CheckSessionRequested is added and valid session exists', build: () => AuthBloc(authRepository: mockAuthRepository), setUp: () { - when(() => mockAuthRepository.getStoredSession()).thenAnswer((_) async => tokens); + when(() => mockAuthRepository.restoreSession()).thenAnswer((_) async => tokens); }, act: (bloc) => bloc.add(const CheckSessionRequested()), - expect: () => [const AuthState.authenticated(tokens)], + expect: () => [const AuthState.authenticating(), const AuthState.authenticated(tokens)], ); }); } diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index d31e663..ae100dc 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -1,8 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/features/auth/data/auth_repository.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:mocktail/mocktail.dart'; class MockAppDatabase extends Mock implements AppDatabase {} @@ -36,9 +36,14 @@ void main() { final account = Account( did: 'did:plc:abc123', handle: 'user.bsky.social', + service: 'bsky.social', accessToken: 'access_token', refreshToken: 'refresh_token', + dpopPublicKey: null, + dpopPrivateKey: null, + dpopNonce: null, displayName: 'User Name', + expiresAt: null, createdAt: DateTime.now(), updatedAt: DateTime.now(), ); @@ -53,6 +58,8 @@ void main() { expect(result.accessToken, equals('access_token')); expect(result.refreshToken, equals('refresh_token')); expect(result.displayName, equals('User Name')); + expect(result.service, equals('bsky.social')); + expect(result.authMethod, AuthMethod.appPassword); }); }); @@ -64,6 +71,7 @@ void main() { did: 'did:plc:abc123', handle: 'user.bsky.social', displayName: 'User Name', + service: 'bsky.social', ); when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); @@ -74,6 +82,33 @@ void main() { }); }); + group('restoreSession', () { + test('should return stored session when it is still valid', () async { + final futureExpiry = DateTime.now().add(const Duration(hours: 1)); + final account = Account( + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'bsky.social', + accessToken: 'access_token', + refreshToken: 'refresh_token', + dpopPublicKey: null, + dpopPrivateKey: null, + dpopNonce: null, + displayName: 'User Name', + expiresAt: futureExpiry, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + when(() => mockDatabase.getActiveAccount()).thenAnswer((_) async => account); + + final restored = await authRepository.restoreSession(); + + expect(restored, isNotNull); + expect(restored!.handle, equals('user.bsky.social')); + }); + }); + group('clearSession', () { test('should delete all accounts', () async { when(() => mockDatabase.deleteAllAccounts()).thenAnswer((_) async => 1); @@ -86,10 +121,12 @@ void main() { group('logout', () { test('should clear session', () async { + when(() => mockDatabase.getActiveAccount()).thenAnswer((_) async => null); when(() => mockDatabase.deleteAllAccounts()).thenAnswer((_) async => 1); await authRepository.logout(); + verify(() => mockDatabase.getActiveAccount()).called(1); verify(() => mockDatabase.deleteAllAccounts()).called(1); }); }); diff --git a/test/features/auth/data/models/auth_models_test.dart b/test/features/auth/data/models/auth_models_test.dart index b4fe88e..c2afc57 100644 --- a/test/features/auth/data/models/auth_models_test.dart +++ b/test/features/auth/data/models/auth_models_test.dart @@ -10,6 +10,7 @@ void main() { did: 'did:plc:abc123', handle: 'user.bsky.social', displayName: 'User Name', + service: 'bsky.social', ); expect(tokens.accessToken, equals('access_token')); @@ -17,6 +18,7 @@ void main() { expect(tokens.did, equals('did:plc:abc123')); expect(tokens.handle, equals('user.bsky.social')); expect(tokens.displayName, equals('User Name')); + expect(tokens.service, equals('bsky.social')); }); test('should create AuthTokens without optional fields', () { @@ -39,6 +41,20 @@ void main() { expect(newTokens.displayName, equals('New Name')); }); + test('should identify oauth-backed sessions', () { + const tokens = AuthTokens( + accessToken: 'access_token', + refreshToken: 'refresh_token', + did: 'did:plc:abc123', + handle: 'user.bsky.social', + dpopPublicKey: 'public', + dpopPrivateKey: 'private', + authMethod: AuthMethod.oauth, + ); + + expect(tokens.usesOAuth, isTrue); + }); + test('should check if tokens are expired', () { final expiredDate = DateTime.now().subtract(const Duration(hours: 1)); final tokens = AuthTokens(