diff --git a/docs/tasks/phase-3.md b/docs/tasks/phase-3.md index fc07bf2..4269fcc 100644 --- a/docs/tasks/phase-3.md +++ b/docs/tasks/phase-3.md @@ -31,25 +31,25 @@ ## M10 — Post & Profile Actions -- [ ] `PostActionRepository` — like, repost, delete via `com.atproto.repo.createRecord` / `deleteRecord` -- [ ] `PostActionCubit` — optimistic state updates for like / repost toggle with rollback on failure -- [ ] Like toggle: create `app.bsky.feed.like` record or delete by rkey; update `viewer.like` and `likeCount` -- [ ] Repost toggle: create `app.bsky.feed.repost` record or delete by rkey; update `viewer.repost` and `repostCount` -- [ ] Post action bar UI — like, repost, reply, share buttons with animated state transitions -- [ ] `ProfileActionRepository` — follow, mute, block, report -- [ ] `ProfileActionCubit` — optimistic follow/mute/block state with rollback -- [ ] Follow toggle: create `app.bsky.graph.follow` record or delete by rkey; update `viewer.following` -- [ ] Mute toggle via `app.bsky.graph.muteActor` / `unmuteActor`; update `viewer.muted` -- [ ] Block toggle: create `app.bsky.graph.block` record or delete by rkey; update `viewer.blocking` -- [ ] Profile action buttons: Follow / Following / Mute / Block in profile header and overflow menu -- [ ] Report dialog: reason picker + optional description, submit via `com.atproto.moderation.createReport` -- [ ] Report for both posts (RepoStrongRef subject) and accounts (RepoRef subject) -- [ ] Confirmation dialog before mute / block actions -- [ ] Thread muting via `app.bsky.feed.threadgate` awareness (show muted-thread indicator) +- [x] `PostActionRepository` — like, repost, delete via `com.atproto.repo.createRecord` / `deleteRecord` +- [x] `PostActionCubit` — optimistic state updates for like / repost toggle with rollback on failure +- [x] Like toggle: create `app.bsky.feed.like` record or delete by rkey; update `viewer.like` and `likeCount` +- [x] Repost toggle: create `app.bsky.feed.repost` record or delete by rkey; update `viewer.repost` and `repostCount` +- [x] Post action bar UI — like, repost, reply, share buttons with animated state transitions +- [x] `ProfileActionRepository` — follow, mute, block, report +- [x] `ProfileActionCubit` — optimistic follow/mute/block state with rollback +- [x] Follow toggle: create `app.bsky.graph.follow` record or delete by rkey; update `viewer.following` +- [x] Mute toggle via `app.bsky.graph.muteActor` / `unmuteActor`; update `viewer.muted` +- [x] Block toggle: create `app.bsky.graph.block` record or delete by rkey; update `viewer.blocking` +- [x] Profile action buttons: Follow / Following / Mute / Block in profile header and overflow menu +- [x] Report dialog: reason picker + optional description, submit via `com.atproto.moderation.createReport` +- [x] Report for both posts (RepoStrongRef subject) and accounts (RepoRef subject) +- [x] Confirmation dialog before mute / block actions +- [x] Thread muting via `app.bsky.feed.threadgate` awareness (show muted-thread indicator) ## M11 — Saved Posts -- [ ] Drift migration: add `saved_posts` table (id, account_did, post_uri, post_json, saved_at) with unique constraint on (account_did, post_uri) -- [ ] `SavedPostsCubit` — read/write saved posts, expose stream of saved URIs for icon state -- [ ] Bookmark icon on post action bar — toggle saved state -- [ ] Saved posts list screen accessible from profile or settings +- [x] Drift migration: add `saved_posts` table (id, account_did, post_uri, post_json, saved_at) with unique constraint on (account_did, post_uri) +- [x] `SavedPostsCubit` — read/write saved posts, expose stream of saved URIs for icon state +- [x] Bookmark icon on post action bar — toggle saved state +- [x] Saved posts list screen accessible from profile or settings diff --git a/docs/tasks/phase-4.md b/docs/tasks/phase-4.md index 5bb21c8..f269236 100644 --- a/docs/tasks/phase-4.md +++ b/docs/tasks/phase-4.md @@ -3,23 +3,23 @@ ## M12 — Direct Messages - [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination -- [ ] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted` -- [ ] Primary / Requests tab filtering on conversation list +- [x] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted` +- [x] Primary / Requests tab filtering on conversation list - [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination -- [ ] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead` +- [x] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead` - [ ] Chat bubble layout — current user right-aligned, others left-aligned -- [ ] Send messages via `chat.bsky.convo.sendMessage` -- [ ] New conversation via `chat.bsky.convo.getConvoForMembers` +- [x] Send messages via `chat.bsky.convo.sendMessage` +- [x] New conversation via `chat.bsky.convo.getConvoForMembers` - [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread -- [ ] Mute / unmute conversations -- [ ] Mark conversation as read via `chat.bsky.convo.updateRead` +- [x] Mute / unmute conversations +- [x] Mark conversation as read via `chat.bsky.convo.updateRead` ## M13 — Account Switching -- [ ] `AccountSwitcherCubit` exposing account list and active DID +- [x] `AccountSwitcherCubit` exposing account list and active DID - [ ] Account switcher bottom sheet UI — list accounts with avatars and handles -- [ ] Store `active_account_did` in Drift `settings` table -- [ ] Drift migration: add `account_did` column to `cached_posts` if not present +- [x] Store `active_account_did` in Drift `settings` table +- [x] Drift migration: add `account_did` column to `cached_posts` if not present - [ ] All user-scoped queries filter by active account DID - [ ] Broadcast `AccountSwitched` event to all Blocs on switch - [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row @@ -27,7 +27,7 @@ ## M14 — Offline Reading & Network Resilience -- [ ] `ConnectivityCubit` via **connectivity_plus** — expose network state stream +- [x] `ConnectivityCubit` via **connectivity_plus** — expose network state stream - [ ] Cache last-fetched feed page as serialised JSON in Drift - [ ] Display cached data immediately on launch, refresh in background - [ ] "You're offline" banner when connectivity is lost @@ -43,9 +43,9 @@ ## M16 — Labelers & Content Moderation -- [ ] Fetch user's labeler subscriptions from preferences via `app.bsky.actor.getPreferences` (`labelersPref`) +- [x] Fetch user's labeler subscriptions from preferences via `app.bsky.actor.getPreferences` (`labelersPref`) - [ ] Include subscribed labeler DIDs in `atproto-accept-labelers` header on all XRPC requests -- [ ] `ModerationService` — wraps the `bluesky` package's `moderatePost`, `moderateProfile`, `moderateNotification` functions +- [x] `ModerationService` — wraps the `bluesky` package's `moderatePost`, `moderateProfile`, `moderateNotification` functions - [ ] Run moderation decisions on all displayed posts and profiles - [ ] Apply `ModerationUI` results: filter, blur, alert, inform per display context (contentList, contentView, contentMedia, avatar, profileList, profileView) - [ ] Blur overlay on posts/media with click-through "Show content" button @@ -58,4 +58,4 @@ - [ ] Adult content toggle (requires `adultContentEnabled` preference) - [ ] Self-label support — render self-labels embedded in posts and profiles - [ ] Labeler detail screen: show labeler creator, policies, and custom label definitions with localised names -- [ ] Drift table: `labeler_cache` (labeler_did, policies_json, fetched_at) for offline label definition lookup +- [x] Drift table: `labeler_cache` (labeler_did, policies_json, fetched_at) for offline label definition lookup diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 830f9b4..b4c94c2 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -6,12 +6,24 @@ import 'package:lazurite/core/database/tables.dart'; part 'app_database.g.dart'; -@DriftDatabase(tables: [Accounts, CachedProfiles, CachedPosts, Settings, SavedFeeds, SearchHistory, Drafts, SavedPosts]) +@DriftDatabase( + tables: [ + Accounts, + CachedProfiles, + CachedPosts, + Settings, + SavedFeeds, + SearchHistory, + Drafts, + SavedPosts, + LabelerCache, + ], +) class AppDatabase extends _$AppDatabase { AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); @override - int get schemaVersion => 7; + int get schemaVersion => 9; @override MigrationStrategy get migration => MigrationStrategy( @@ -41,6 +53,12 @@ class AppDatabase extends _$AppDatabase { if (from < 7) { await migrator.addColumn(savedPosts, savedPosts.saveType); } + if (from < 8) { + await migrator.addColumn(cachedPosts, cachedPosts.accountDid); + } + if (from < 9) { + await migrator.createTable(labelerCache); + } }, ); @@ -285,4 +303,21 @@ class AppDatabase extends _$AppDatabase { (posts) => {for (final p in posts) p.postUri: p.saveType}, ); } + + Future getLabelerCache(String labelerDid) => + (select(labelerCache)..where((l) => l.labelerDid.equals(labelerDid))).getSingleOrNull(); + + Future> getAllLabelerCache() => select(labelerCache).get(); + + Future upsertLabelerCache(String labelerDid, String policiesJson) => into(labelerCache).insert( + LabelerCacheCompanion( + labelerDid: Value(labelerDid), + policiesJson: Value(policiesJson), + fetchedAt: Value(DateTime.now()), + ), + mode: InsertMode.replace, + ); + + Future deleteLabelerCache(String labelerDid) => + (delete(labelerCache)..where((l) => l.labelerDid.equals(labelerDid))).go(); } diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index bf756f5..7828397 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -894,6 +894,15 @@ class $CachedPostsTable extends CachedPosts with TableInfo<$CachedPostsTable, Ca type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _accountDidMeta = const VerificationMeta('accountDid'); + @override + late final GeneratedColumn accountDid = GeneratedColumn( + 'account_did', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _payloadMeta = const VerificationMeta('payload'); @override late final GeneratedColumn payload = GeneratedColumn( @@ -923,7 +932,7 @@ class $CachedPostsTable extends CachedPosts with TableInfo<$CachedPostsTable, Ca defaultValue: currentDateAndTime, ); @override - List get $columns => [uri, authorDid, payload, createdAt, fetchedAt]; + List get $columns => [uri, authorDid, accountDid, payload, createdAt, fetchedAt]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -943,6 +952,9 @@ class $CachedPostsTable extends CachedPosts with TableInfo<$CachedPostsTable, Ca } else if (isInserting) { context.missing(_authorDidMeta); } + if (data.containsKey('account_did')) { + context.handle(_accountDidMeta, accountDid.isAcceptableOrUnknown(data['account_did']!, _accountDidMeta)); + } if (data.containsKey('payload')) { context.handle(_payloadMeta, payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta)); } else if (isInserting) { @@ -965,6 +977,7 @@ class $CachedPostsTable extends CachedPosts with TableInfo<$CachedPostsTable, Ca return CachedPost( uri: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}uri'])!, authorDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}author_did'])!, + accountDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}account_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'])!, @@ -980,12 +993,14 @@ class $CachedPostsTable extends CachedPosts with TableInfo<$CachedPostsTable, Ca class CachedPost extends DataClass implements Insertable { final String uri; final String authorDid; + final String? accountDid; final String payload; final DateTime? createdAt; final DateTime fetchedAt; const CachedPost({ required this.uri, required this.authorDid, + this.accountDid, required this.payload, this.createdAt, required this.fetchedAt, @@ -995,6 +1010,9 @@ class CachedPost extends DataClass implements Insertable { final map = {}; map['uri'] = Variable(uri); map['author_did'] = Variable(authorDid); + if (!nullToAbsent || accountDid != null) { + map['account_did'] = Variable(accountDid); + } map['payload'] = Variable(payload); if (!nullToAbsent || createdAt != null) { map['created_at'] = Variable(createdAt); @@ -1007,6 +1025,7 @@ class CachedPost extends DataClass implements Insertable { return CachedPostsCompanion( uri: Value(uri), authorDid: Value(authorDid), + accountDid: accountDid == null && nullToAbsent ? const Value.absent() : Value(accountDid), payload: Value(payload), createdAt: createdAt == null && nullToAbsent ? const Value.absent() : Value(createdAt), fetchedAt: Value(fetchedAt), @@ -1018,6 +1037,7 @@ class CachedPost extends DataClass implements Insertable { return CachedPost( uri: serializer.fromJson(json['uri']), authorDid: serializer.fromJson(json['authorDid']), + accountDid: serializer.fromJson(json['accountDid']), payload: serializer.fromJson(json['payload']), createdAt: serializer.fromJson(json['createdAt']), fetchedAt: serializer.fromJson(json['fetchedAt']), @@ -1029,6 +1049,7 @@ class CachedPost extends DataClass implements Insertable { return { 'uri': serializer.toJson(uri), 'authorDid': serializer.toJson(authorDid), + 'accountDid': serializer.toJson(accountDid), 'payload': serializer.toJson(payload), 'createdAt': serializer.toJson(createdAt), 'fetchedAt': serializer.toJson(fetchedAt), @@ -1038,12 +1059,14 @@ class CachedPost extends DataClass implements Insertable { CachedPost copyWith({ String? uri, String? authorDid, + Value accountDid = const Value.absent(), String? payload, Value createdAt = const Value.absent(), DateTime? fetchedAt, }) => CachedPost( uri: uri ?? this.uri, authorDid: authorDid ?? this.authorDid, + accountDid: accountDid.present ? accountDid.value : this.accountDid, payload: payload ?? this.payload, createdAt: createdAt.present ? createdAt.value : this.createdAt, fetchedAt: fetchedAt ?? this.fetchedAt, @@ -1052,6 +1075,7 @@ class CachedPost extends DataClass implements Insertable { return CachedPost( uri: data.uri.present ? data.uri.value : this.uri, authorDid: data.authorDid.present ? data.authorDid.value : this.authorDid, + accountDid: data.accountDid.present ? data.accountDid.value : this.accountDid, 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, @@ -1063,6 +1087,7 @@ class CachedPost extends DataClass implements Insertable { return (StringBuffer('CachedPost(') ..write('uri: $uri, ') ..write('authorDid: $authorDid, ') + ..write('accountDid: $accountDid, ') ..write('payload: $payload, ') ..write('createdAt: $createdAt, ') ..write('fetchedAt: $fetchedAt') @@ -1071,13 +1096,14 @@ class CachedPost extends DataClass implements Insertable { } @override - int get hashCode => Object.hash(uri, authorDid, payload, createdAt, fetchedAt); + int get hashCode => Object.hash(uri, authorDid, accountDid, payload, createdAt, fetchedAt); @override bool operator ==(Object other) => identical(this, other) || (other is CachedPost && other.uri == this.uri && other.authorDid == this.authorDid && + other.accountDid == this.accountDid && other.payload == this.payload && other.createdAt == this.createdAt && other.fetchedAt == this.fetchedAt); @@ -1086,6 +1112,7 @@ class CachedPost extends DataClass implements Insertable { class CachedPostsCompanion extends UpdateCompanion { final Value uri; final Value authorDid; + final Value accountDid; final Value payload; final Value createdAt; final Value fetchedAt; @@ -1093,6 +1120,7 @@ class CachedPostsCompanion extends UpdateCompanion { const CachedPostsCompanion({ this.uri = const Value.absent(), this.authorDid = const Value.absent(), + this.accountDid = const Value.absent(), this.payload = const Value.absent(), this.createdAt = const Value.absent(), this.fetchedAt = const Value.absent(), @@ -1101,6 +1129,7 @@ class CachedPostsCompanion extends UpdateCompanion { CachedPostsCompanion.insert({ required String uri, required String authorDid, + this.accountDid = const Value.absent(), required String payload, this.createdAt = const Value.absent(), this.fetchedAt = const Value.absent(), @@ -1111,6 +1140,7 @@ class CachedPostsCompanion extends UpdateCompanion { static Insertable custom({ Expression? uri, Expression? authorDid, + Expression? accountDid, Expression? payload, Expression? createdAt, Expression? fetchedAt, @@ -1119,6 +1149,7 @@ class CachedPostsCompanion extends UpdateCompanion { return RawValuesInsertable({ if (uri != null) 'uri': uri, if (authorDid != null) 'author_did': authorDid, + if (accountDid != null) 'account_did': accountDid, if (payload != null) 'payload': payload, if (createdAt != null) 'created_at': createdAt, if (fetchedAt != null) 'fetched_at': fetchedAt, @@ -1129,6 +1160,7 @@ class CachedPostsCompanion extends UpdateCompanion { CachedPostsCompanion copyWith({ Value? uri, Value? authorDid, + Value? accountDid, Value? payload, Value? createdAt, Value? fetchedAt, @@ -1137,6 +1169,7 @@ class CachedPostsCompanion extends UpdateCompanion { return CachedPostsCompanion( uri: uri ?? this.uri, authorDid: authorDid ?? this.authorDid, + accountDid: accountDid ?? this.accountDid, payload: payload ?? this.payload, createdAt: createdAt ?? this.createdAt, fetchedAt: fetchedAt ?? this.fetchedAt, @@ -1153,6 +1186,9 @@ class CachedPostsCompanion extends UpdateCompanion { if (authorDid.present) { map['author_did'] = Variable(authorDid.value); } + if (accountDid.present) { + map['account_did'] = Variable(accountDid.value); + } if (payload.present) { map['payload'] = Variable(payload.value); } @@ -1173,6 +1209,7 @@ class CachedPostsCompanion extends UpdateCompanion { return (StringBuffer('CachedPostsCompanion(') ..write('uri: $uri, ') ..write('authorDid: $authorDid, ') + ..write('accountDid: $accountDid, ') ..write('payload: $payload, ') ..write('createdAt: $createdAt, ') ..write('fetchedAt: $fetchedAt, ') @@ -3002,6 +3039,234 @@ class SavedPostsCompanion extends UpdateCompanion { } } +class $LabelerCacheTable extends LabelerCache with TableInfo<$LabelerCacheTable, LabelerCacheEntry> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $LabelerCacheTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _labelerDidMeta = const VerificationMeta('labelerDid'); + @override + late final GeneratedColumn labelerDid = GeneratedColumn( + 'labeler_did', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _policiesJsonMeta = const VerificationMeta('policiesJson'); + @override + late final GeneratedColumn policiesJson = GeneratedColumn( + 'policies_json', + 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, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [labelerDid, policiesJson, fetchedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'labeler_cache'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('labeler_did')) { + context.handle(_labelerDidMeta, labelerDid.isAcceptableOrUnknown(data['labeler_did']!, _labelerDidMeta)); + } else if (isInserting) { + context.missing(_labelerDidMeta); + } + if (data.containsKey('policies_json')) { + context.handle(_policiesJsonMeta, policiesJson.isAcceptableOrUnknown(data['policies_json']!, _policiesJsonMeta)); + } else if (isInserting) { + context.missing(_policiesJsonMeta); + } + if (data.containsKey('fetched_at')) { + context.handle(_fetchedAtMeta, fetchedAt.isAcceptableOrUnknown(data['fetched_at']!, _fetchedAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {labelerDid}; + @override + LabelerCacheEntry map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LabelerCacheEntry( + labelerDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}labeler_did'])!, + policiesJson: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}policies_json'])!, + fetchedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}fetched_at'])!, + ); + } + + @override + $LabelerCacheTable createAlias(String alias) { + return $LabelerCacheTable(attachedDatabase, alias); + } +} + +class LabelerCacheEntry extends DataClass implements Insertable { + final String labelerDid; + final String policiesJson; + final DateTime fetchedAt; + const LabelerCacheEntry({required this.labelerDid, required this.policiesJson, required this.fetchedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['labeler_did'] = Variable(labelerDid); + map['policies_json'] = Variable(policiesJson); + map['fetched_at'] = Variable(fetchedAt); + return map; + } + + LabelerCacheCompanion toCompanion(bool nullToAbsent) { + return LabelerCacheCompanion( + labelerDid: Value(labelerDid), + policiesJson: Value(policiesJson), + fetchedAt: Value(fetchedAt), + ); + } + + factory LabelerCacheEntry.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LabelerCacheEntry( + labelerDid: serializer.fromJson(json['labelerDid']), + policiesJson: serializer.fromJson(json['policiesJson']), + fetchedAt: serializer.fromJson(json['fetchedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'labelerDid': serializer.toJson(labelerDid), + 'policiesJson': serializer.toJson(policiesJson), + 'fetchedAt': serializer.toJson(fetchedAt), + }; + } + + LabelerCacheEntry copyWith({String? labelerDid, String? policiesJson, DateTime? fetchedAt}) => LabelerCacheEntry( + labelerDid: labelerDid ?? this.labelerDid, + policiesJson: policiesJson ?? this.policiesJson, + fetchedAt: fetchedAt ?? this.fetchedAt, + ); + LabelerCacheEntry copyWithCompanion(LabelerCacheCompanion data) { + return LabelerCacheEntry( + labelerDid: data.labelerDid.present ? data.labelerDid.value : this.labelerDid, + policiesJson: data.policiesJson.present ? data.policiesJson.value : this.policiesJson, + fetchedAt: data.fetchedAt.present ? data.fetchedAt.value : this.fetchedAt, + ); + } + + @override + String toString() { + return (StringBuffer('LabelerCacheEntry(') + ..write('labelerDid: $labelerDid, ') + ..write('policiesJson: $policiesJson, ') + ..write('fetchedAt: $fetchedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(labelerDid, policiesJson, fetchedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LabelerCacheEntry && + other.labelerDid == this.labelerDid && + other.policiesJson == this.policiesJson && + other.fetchedAt == this.fetchedAt); +} + +class LabelerCacheCompanion extends UpdateCompanion { + final Value labelerDid; + final Value policiesJson; + final Value fetchedAt; + final Value rowid; + const LabelerCacheCompanion({ + this.labelerDid = const Value.absent(), + this.policiesJson = const Value.absent(), + this.fetchedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + LabelerCacheCompanion.insert({ + required String labelerDid, + required String policiesJson, + this.fetchedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : labelerDid = Value(labelerDid), + policiesJson = Value(policiesJson); + static Insertable custom({ + Expression? labelerDid, + Expression? policiesJson, + Expression? fetchedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (labelerDid != null) 'labeler_did': labelerDid, + if (policiesJson != null) 'policies_json': policiesJson, + if (fetchedAt != null) 'fetched_at': fetchedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + LabelerCacheCompanion copyWith({ + Value? labelerDid, + Value? policiesJson, + Value? fetchedAt, + Value? rowid, + }) { + return LabelerCacheCompanion( + labelerDid: labelerDid ?? this.labelerDid, + policiesJson: policiesJson ?? this.policiesJson, + fetchedAt: fetchedAt ?? this.fetchedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (labelerDid.present) { + map['labeler_did'] = Variable(labelerDid.value); + } + if (policiesJson.present) { + map['policies_json'] = Variable(policiesJson.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('LabelerCacheCompanion(') + ..write('labelerDid: $labelerDid, ') + ..write('policiesJson: $policiesJson, ') + ..write('fetchedAt: $fetchedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -3013,6 +3278,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this); late final $DraftsTable drafts = $DraftsTable(this); late final $SavedPostsTable savedPosts = $SavedPostsTable(this); + late final $LabelerCacheTable labelerCache = $LabelerCacheTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override @@ -3025,6 +3291,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { searchHistory, drafts, savedPosts, + labelerCache, ]; } @@ -3441,6 +3708,7 @@ typedef $$CachedPostsTableCreateCompanionBuilder = CachedPostsCompanion Function({ required String uri, required String authorDid, + Value accountDid, required String payload, Value createdAt, Value fetchedAt, @@ -3450,6 +3718,7 @@ typedef $$CachedPostsTableUpdateCompanionBuilder = CachedPostsCompanion Function({ Value uri, Value authorDid, + Value accountDid, Value payload, Value createdAt, Value fetchedAt, @@ -3469,6 +3738,9 @@ class $$CachedPostsTableFilterComposer extends Composer<_$AppDatabase, $CachedPo ColumnFilters get authorDid => $composableBuilder(column: $table.authorDid, builder: (column) => ColumnFilters(column)); + ColumnFilters get accountDid => + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnFilters(column)); + ColumnFilters get payload => $composableBuilder(column: $table.payload, builder: (column) => ColumnFilters(column)); @@ -3493,6 +3765,9 @@ class $$CachedPostsTableOrderingComposer extends Composer<_$AppDatabase, $Cached ColumnOrderings get authorDid => $composableBuilder(column: $table.authorDid, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get accountDid => + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get payload => $composableBuilder(column: $table.payload, builder: (column) => ColumnOrderings(column)); @@ -3515,6 +3790,8 @@ class $$CachedPostsTableAnnotationComposer extends Composer<_$AppDatabase, $Cach GeneratedColumn get authorDid => $composableBuilder(column: $table.authorDid, builder: (column) => column); + GeneratedColumn get accountDid => $composableBuilder(column: $table.accountDid, builder: (column) => column); + GeneratedColumn get payload => $composableBuilder(column: $table.payload, builder: (column) => column); GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); @@ -3549,6 +3826,7 @@ class $$CachedPostsTableTableManager ({ Value uri = const Value.absent(), Value authorDid = const Value.absent(), + Value accountDid = const Value.absent(), Value payload = const Value.absent(), Value createdAt = const Value.absent(), Value fetchedAt = const Value.absent(), @@ -3556,6 +3834,7 @@ class $$CachedPostsTableTableManager }) => CachedPostsCompanion( uri: uri, authorDid: authorDid, + accountDid: accountDid, payload: payload, createdAt: createdAt, fetchedAt: fetchedAt, @@ -3565,6 +3844,7 @@ class $$CachedPostsTableTableManager ({ required String uri, required String authorDid, + Value accountDid = const Value.absent(), required String payload, Value createdAt = const Value.absent(), Value fetchedAt = const Value.absent(), @@ -3572,6 +3852,7 @@ class $$CachedPostsTableTableManager }) => CachedPostsCompanion.insert( uri: uri, authorDid: authorDid, + accountDid: accountDid, payload: payload, createdAt: createdAt, fetchedAt: fetchedAt, @@ -4472,6 +4753,140 @@ typedef $$SavedPostsTableProcessedTableManager = SavedPostEntry, PrefetchHooks Function() >; +typedef $$LabelerCacheTableCreateCompanionBuilder = + LabelerCacheCompanion Function({ + required String labelerDid, + required String policiesJson, + Value fetchedAt, + Value rowid, + }); +typedef $$LabelerCacheTableUpdateCompanionBuilder = + LabelerCacheCompanion Function({ + Value labelerDid, + Value policiesJson, + Value fetchedAt, + Value rowid, + }); + +class $$LabelerCacheTableFilterComposer extends Composer<_$AppDatabase, $LabelerCacheTable> { + $$LabelerCacheTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get labelerDid => + $composableBuilder(column: $table.labelerDid, builder: (column) => ColumnFilters(column)); + + ColumnFilters get policiesJson => + $composableBuilder(column: $table.policiesJson, builder: (column) => ColumnFilters(column)); + + ColumnFilters get fetchedAt => + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnFilters(column)); +} + +class $$LabelerCacheTableOrderingComposer extends Composer<_$AppDatabase, $LabelerCacheTable> { + $$LabelerCacheTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get labelerDid => + $composableBuilder(column: $table.labelerDid, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get policiesJson => + $composableBuilder(column: $table.policiesJson, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get fetchedAt => + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnOrderings(column)); +} + +class $$LabelerCacheTableAnnotationComposer extends Composer<_$AppDatabase, $LabelerCacheTable> { + $$LabelerCacheTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get labelerDid => $composableBuilder(column: $table.labelerDid, builder: (column) => column); + + GeneratedColumn get policiesJson => + $composableBuilder(column: $table.policiesJson, builder: (column) => column); + + GeneratedColumn get fetchedAt => $composableBuilder(column: $table.fetchedAt, builder: (column) => column); +} + +class $$LabelerCacheTableTableManager + extends + RootTableManager< + _$AppDatabase, + $LabelerCacheTable, + LabelerCacheEntry, + $$LabelerCacheTableFilterComposer, + $$LabelerCacheTableOrderingComposer, + $$LabelerCacheTableAnnotationComposer, + $$LabelerCacheTableCreateCompanionBuilder, + $$LabelerCacheTableUpdateCompanionBuilder, + (LabelerCacheEntry, BaseReferences<_$AppDatabase, $LabelerCacheTable, LabelerCacheEntry>), + LabelerCacheEntry, + PrefetchHooks Function() + > { + $$LabelerCacheTableTableManager(_$AppDatabase db, $LabelerCacheTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$LabelerCacheTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$LabelerCacheTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$LabelerCacheTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value labelerDid = const Value.absent(), + Value policiesJson = const Value.absent(), + Value fetchedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => LabelerCacheCompanion( + labelerDid: labelerDid, + policiesJson: policiesJson, + fetchedAt: fetchedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String labelerDid, + required String policiesJson, + Value fetchedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => LabelerCacheCompanion.insert( + labelerDid: labelerDid, + policiesJson: policiesJson, + fetchedAt: fetchedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$LabelerCacheTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $LabelerCacheTable, + LabelerCacheEntry, + $$LabelerCacheTableFilterComposer, + $$LabelerCacheTableOrderingComposer, + $$LabelerCacheTableAnnotationComposer, + $$LabelerCacheTableCreateCompanionBuilder, + $$LabelerCacheTableUpdateCompanionBuilder, + (LabelerCacheEntry, BaseReferences<_$AppDatabase, $LabelerCacheTable, LabelerCacheEntry>), + LabelerCacheEntry, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -4484,4 +4899,5 @@ class $AppDatabaseManager { $$SearchHistoryTableTableManager get searchHistory => $$SearchHistoryTableTableManager(_db, _db.searchHistory); $$DraftsTableTableManager get drafts => $$DraftsTableTableManager(_db, _db.drafts); $$SavedPostsTableTableManager get savedPosts => $$SavedPostsTableTableManager(_db, _db.savedPosts); + $$LabelerCacheTableTableManager get labelerCache => $$LabelerCacheTableTableManager(_db, _db.labelerCache); } diff --git a/lib/core/database/tables.dart b/lib/core/database/tables.dart index e72c7b5..b35ebdf 100644 --- a/lib/core/database/tables.dart +++ b/lib/core/database/tables.dart @@ -34,6 +34,7 @@ class CachedProfiles extends Table { class CachedPosts extends Table { TextColumn get uri => text()(); TextColumn get authorDid => text()(); + TextColumn get accountDid => text().nullable()(); TextColumn get payload => text()(); DateTimeColumn get createdAt => dateTime().nullable()(); DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); @@ -103,3 +104,13 @@ class SavedPosts extends Table { @override List get customConstraints => ['UNIQUE (account_did, post_uri)']; } + +@DataClassName('LabelerCacheEntry') +class LabelerCache extends Table { + TextColumn get labelerDid => text()(); + TextColumn get policiesJson => text()(); + DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {labelerDid}; +} diff --git a/lib/features/account/cubit/account_switcher_cubit.dart b/lib/features/account/cubit/account_switcher_cubit.dart new file mode 100644 index 0000000..ddc33d1 --- /dev/null +++ b/lib/features/account/cubit/account_switcher_cubit.dart @@ -0,0 +1,63 @@ +import 'package:drift/drift.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; + +part 'account_switcher_state.dart'; + +class AccountSwitcherCubit extends Cubit { + AccountSwitcherCubit({required AppDatabase database}) + : _database = database, + super(const AccountSwitcherState.initial()); + + final AppDatabase _database; + + static const String _keyActiveAccountDid = 'active_account_did'; + + Future loadAccounts() async { + emit(const AccountSwitcherState.loading()); + + try { + final accounts = await _database.getAllAccounts(); + final savedDid = await _database.getSetting(_keyActiveAccountDid); + + String? activeDid; + if (savedDid != null && accounts.any((a) => a.did == savedDid)) { + activeDid = savedDid; + } else if (accounts.isNotEmpty) { + activeDid = accounts.first.did; + } + + emit(AccountSwitcherState.ready(accounts: accounts, activeDid: activeDid)); + } catch (error) { + emit(const AccountSwitcherState.ready(accounts: [])); + } + } + + Future switchAccount(String did) async { + if (state.status != AccountSwitcherStatus.ready) return; + + await _database.setSetting(_keyActiveAccountDid, did); + emit(state.copyWith(activeDid: did)); + } + + Future addAccountCompleted(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(), + dpopPublicKey: tokens.dpopPublicKey != null ? Value(tokens.dpopPublicKey!) : const Value.absent(), + dpopNonce: tokens.dpopNonce != null ? Value(tokens.dpopNonce!) : const Value.absent(), + expiresAt: tokens.expiresAt != null ? Value(tokens.expiresAt!) : const Value.absent(), + ), + ); + + await loadAccounts(); + await switchAccount(tokens.did); + } +} diff --git a/lib/features/account/cubit/account_switcher_state.dart b/lib/features/account/cubit/account_switcher_state.dart new file mode 100644 index 0000000..3b65c58 --- /dev/null +++ b/lib/features/account/cubit/account_switcher_state.dart @@ -0,0 +1,31 @@ +part of 'account_switcher_cubit.dart'; + +enum AccountSwitcherStatus { initial, loading, ready } + +class AccountSwitcherState extends Equatable { + const AccountSwitcherState._({required this.status, this.accounts = const [], this.activeDid}); + + const AccountSwitcherState.initial() : this._(status: AccountSwitcherStatus.initial); + + const AccountSwitcherState.loading() : this._(status: AccountSwitcherStatus.loading); + + const AccountSwitcherState.ready({required List accounts, String? activeDid}) + : this._(status: AccountSwitcherStatus.ready, accounts: accounts, activeDid: activeDid); + + final AccountSwitcherStatus status; + final List accounts; + final String? activeDid; + + Account? get activeAccount => accounts.where((a) => a.did == activeDid).firstOrNull; + + AccountSwitcherState copyWith({AccountSwitcherStatus? status, List? accounts, String? activeDid}) { + return AccountSwitcherState._( + status: status ?? this.status, + accounts: accounts ?? this.accounts, + activeDid: activeDid ?? this.activeDid, + ); + } + + @override + List get props => [status, accounts, activeDid]; +} diff --git a/lib/features/compose/bloc/compose_bloc.dart b/lib/features/compose/bloc/compose_bloc.dart index 0038e67..9562bcc 100644 --- a/lib/features/compose/bloc/compose_bloc.dart +++ b/lib/features/compose/bloc/compose_bloc.dart @@ -84,7 +84,14 @@ class ComposeBloc extends Bloc { ..add(MediaAttachment(localPath: event.path, width: event.width, height: event.height)); final isEmpty = state.text.trim().isEmpty && attachments.isEmpty; - emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty, isDraftDirty: true)); + emit( + state.copyWith( + mediaAttachments: attachments, + isEmpty: isEmpty, + canSubmit: !state.isOverLimit && !isEmpty, + isDraftDirty: true, + ), + ); } Future _onMediaRemoved(MediaRemoved event, Emitter emit) async { @@ -93,7 +100,14 @@ class ComposeBloc extends Bloc { final attachments = List.from(state.mediaAttachments)..removeAt(event.index); final isEmpty = state.text.trim().isEmpty && attachments.isEmpty && state.videoAttachment == null; - emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty, isDraftDirty: true)); + emit( + state.copyWith( + mediaAttachments: attachments, + isEmpty: isEmpty, + canSubmit: !state.isOverLimit && !isEmpty, + isDraftDirty: true, + ), + ); } Future _onAltTextUpdated(AltTextUpdated event, Emitter emit) async { diff --git a/lib/features/compose/presentation/compose_screen.dart b/lib/features/compose/presentation/compose_screen.dart index 3e51c57..b3b55bd 100644 --- a/lib/features/compose/presentation/compose_screen.dart +++ b/lib/features/compose/presentation/compose_screen.dart @@ -324,7 +324,10 @@ class _ComposeScreenState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Center( - child: Text('No drafts saved', style: _theme.textTheme.bodyMedium?.copyWith(color: _theme.colorScheme.onSurfaceVariant)), + child: Text( + 'No drafts saved', + style: _theme.textTheme.bodyMedium?.copyWith(color: _theme.colorScheme.onSurfaceVariant), + ), ), ) else diff --git a/lib/features/connectivity/cubit/connectivity_cubit.dart b/lib/features/connectivity/cubit/connectivity_cubit.dart new file mode 100644 index 0000000..8a4786f --- /dev/null +++ b/lib/features/connectivity/cubit/connectivity_cubit.dart @@ -0,0 +1,38 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +part 'connectivity_state.dart'; + +class ConnectivityCubit extends Cubit { + ConnectivityCubit({Connectivity? connectivity}) + : _connectivity = connectivity ?? Connectivity(), + super(const ConnectivityState.online()) { + _init(); + } + + final Connectivity _connectivity; + StreamSubscription>? _subscription; + + void _init() { + _connectivity.checkConnectivity().then(_handleResults); + _subscription = _connectivity.onConnectivityChanged.listen(_handleResults); + } + + void _handleResults(List results) { + final isOnline = results.any((r) => r != ConnectivityResult.none); + if (isOnline) { + emit(const ConnectivityState.online()); + } else { + emit(const ConnectivityState.offline()); + } + } + + @override + Future close() { + _subscription?.cancel(); + return super.close(); + } +} diff --git a/lib/features/connectivity/cubit/connectivity_state.dart b/lib/features/connectivity/cubit/connectivity_state.dart new file mode 100644 index 0000000..9d0c7b1 --- /dev/null +++ b/lib/features/connectivity/cubit/connectivity_state.dart @@ -0,0 +1,18 @@ +part of 'connectivity_cubit.dart'; + +enum ConnectivityStatus { online, offline } + +class ConnectivityState extends Equatable { + const ConnectivityState({required this.isOnline}); + + const ConnectivityState.online() : this(isOnline: true); + + const ConnectivityState.offline() : this(isOnline: false); + + final bool isOnline; + + ConnectivityStatus get status => isOnline ? ConnectivityStatus.online : ConnectivityStatus.offline; + + @override + List get props => [isOnline]; +} diff --git a/lib/features/feed/presentation/widgets/post_card_with_actions.dart b/lib/features/feed/presentation/widgets/post_card_with_actions.dart index e69218e..112c828 100644 --- a/lib/features/feed/presentation/widgets/post_card_with_actions.dart +++ b/lib/features/feed/presentation/widgets/post_card_with_actions.dart @@ -58,13 +58,12 @@ class _PostCardWithActionsContent extends StatelessWidget { Widget build(BuildContext context) { return BlocListener( listenWhen: (previous, current) => - (previous.error != current.error && current.error != null) || - (!previous.isDeleted && current.isDeleted), + (previous.error != current.error && current.error != null) || (!previous.isDeleted && current.isDeleted), listener: (context, state) { if (state.isDeleted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Post deleted'), behavior: SnackBarBehavior.floating), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Post deleted'), behavior: SnackBarBehavior.floating)); onDeleted?.call(); return; } diff --git a/lib/features/messages/bloc/convo_list_bloc.dart b/lib/features/messages/bloc/convo_list_bloc.dart new file mode 100644 index 0000000..d9fdd85 --- /dev/null +++ b/lib/features/messages/bloc/convo_list_bloc.dart @@ -0,0 +1,76 @@ +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; + +part 'convo_list_event.dart'; +part 'convo_list_state.dart'; + +class ConvoListBloc extends Bloc { + ConvoListBloc({required ConvoRepository convoRepository}) + : _convoRepository = convoRepository, + super(const ConvoListState.initial()) { + on(_onConvosRequested); + on(_onConvosRefreshed); + on(_onConvoMuted); + on(_onConvoUnmuted); + } + + final ConvoRepository _convoRepository; + + Future _onConvosRequested(ConvosRequested event, Emitter emit) async { + emit(const ConvoListState.loading()); + + try { + final result = await _convoRepository.listConvos(limit: event.limit); + + emit(ConvoListState.loaded(convos: result.convos, cursor: result.cursor, hasMore: result.cursor != null)); + } catch (error) { + emit(ConvoListState.error('Failed to load conversations: $error')); + } + } + + Future _onConvosRefreshed(ConvosRefreshed event, Emitter emit) async { + if (state.status != ConvoListStatus.loaded) { + return; + } + + emit(state.copyWith(isRefreshing: true)); + + try { + final result = await _convoRepository.listConvos(limit: 20); + + emit( + state.copyWith( + convos: result.convos, + cursor: result.cursor, + hasMore: result.cursor != null, + isRefreshing: false, + ), + ); + } catch (error) { + emit(state.copyWith(isRefreshing: false)); + } + } + + Future _onConvoMuted(ConvoMuted event, Emitter emit) async { + try { + final updatedConvo = await _convoRepository.muteConvo(event.convoId); + final updatedConvos = state.convos.map((c) => c.id == event.convoId ? updatedConvo : c).toList(); + emit(state.copyWith(convos: updatedConvos)); + } catch (error) { + log.w('Failed to mute conversation ${event.convoId}: $error'); + } + } + + Future _onConvoUnmuted(ConvoUnmuted event, Emitter emit) async { + try { + final updatedConvo = await _convoRepository.unmuteConvo(event.convoId); + final updatedConvos = state.convos.map((c) => c.id == event.convoId ? updatedConvo : c).toList(); + emit(state.copyWith(convos: updatedConvos)); + } catch (error) { + log.w('Failed to unmute conversation ${event.convoId}: $error'); + } + } +} diff --git a/lib/features/messages/bloc/convo_list_event.dart b/lib/features/messages/bloc/convo_list_event.dart new file mode 100644 index 0000000..0d3a59f --- /dev/null +++ b/lib/features/messages/bloc/convo_list_event.dart @@ -0,0 +1,39 @@ +part of 'convo_list_bloc.dart'; + +sealed class ConvoListEvent extends Equatable { + const ConvoListEvent(); + + @override + List get props => []; +} + +class ConvosRequested extends ConvoListEvent { + const ConvosRequested({this.limit = 20}); + + final int limit; + + @override + List get props => [limit]; +} + +class ConvosRefreshed extends ConvoListEvent { + const ConvosRefreshed(); +} + +class ConvoMuted extends ConvoListEvent { + const ConvoMuted({required this.convoId}); + + final String convoId; + + @override + List get props => [convoId]; +} + +class ConvoUnmuted extends ConvoListEvent { + const ConvoUnmuted({required this.convoId}); + + final String convoId; + + @override + List get props => [convoId]; +} diff --git a/lib/features/messages/bloc/convo_list_state.dart b/lib/features/messages/bloc/convo_list_state.dart new file mode 100644 index 0000000..cdba281 --- /dev/null +++ b/lib/features/messages/bloc/convo_list_state.dart @@ -0,0 +1,65 @@ +part of 'convo_list_bloc.dart'; + +enum ConvoListStatus { initial, loading, loaded, error } + +enum ConvoTab { primary, requests } + +class ConvoListState extends Equatable { + const ConvoListState._({ + required this.status, + this.convos = const [], + this.cursor, + this.hasMore = false, + this.isLoadingMore = false, + this.isRefreshing = false, + this.activeTab = ConvoTab.primary, + this.errorMessage, + }); + + const ConvoListState.initial() : this._(status: ConvoListStatus.initial); + + const ConvoListState.loading() : this._(status: ConvoListStatus.loading); + + const ConvoListState.loaded({ + required List convos, + String? cursor, + bool hasMore = false, + ConvoTab activeTab = ConvoTab.primary, + }) : this._(status: ConvoListStatus.loaded, convos: convos, cursor: cursor, hasMore: hasMore, activeTab: activeTab); + + const ConvoListState.error(String message) : this._(status: ConvoListStatus.error, errorMessage: message); + + final ConvoListStatus status; + final List convos; + final String? cursor; + final bool hasMore; + final bool isLoadingMore; + final bool isRefreshing; + final ConvoTab activeTab; + final String? errorMessage; + + ConvoListState copyWith({ + ConvoListStatus? status, + List? convos, + String? cursor, + bool? hasMore, + bool? isLoadingMore, + bool? isRefreshing, + ConvoTab? activeTab, + String? errorMessage, + }) { + return ConvoListState._( + status: status ?? this.status, + convos: convos ?? this.convos, + cursor: cursor ?? this.cursor, + hasMore: hasMore ?? this.hasMore, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + isRefreshing: isRefreshing ?? this.isRefreshing, + activeTab: activeTab ?? this.activeTab, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [status, convos, cursor, hasMore, isLoadingMore, isRefreshing, activeTab, errorMessage]; +} diff --git a/lib/features/messages/bloc/message_bloc.dart b/lib/features/messages/bloc/message_bloc.dart new file mode 100644 index 0000000..8d2fc68 --- /dev/null +++ b/lib/features/messages/bloc/message_bloc.dart @@ -0,0 +1,119 @@ +import 'package:bluesky/chat_bsky_convo_getmessages.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; + +part 'message_event.dart'; +part 'message_state.dart'; + +class MessageBloc extends Bloc { + MessageBloc({required ConvoRepository convoRepository, required String currentUserDid}) + : _convoRepository = convoRepository, + _currentUserDid = currentUserDid, + super(const MessageState.initial()) { + on(_onMessagesRequested); + on(_onMessagesPageLoaded); + on(_onMessageSent); + on(_onMessageDeleted); + on(_onConvoMarkedRead); + } + + final ConvoRepository _convoRepository; + // FIXME: use this + final String _currentUserDid; + + Future _onMessagesRequested(MessagesRequested event, Emitter emit) async { + emit(const MessageState.loading()); + + try { + final result = await _convoRepository.getMessages(event.convoId, limit: event.limit); + + emit( + MessageState.loaded( + messages: result.messages, + cursor: result.cursor, + hasMore: result.cursor != null, + convoId: event.convoId, + ), + ); + } catch (error) { + emit(MessageState.error('Failed to load messages: $error')); + } + } + + Future _onMessagesPageLoaded(MessagesPageLoaded event, Emitter emit) async { + if (state.status != MessageStatus.loaded || state.cursor == null || state.isLoadingMore) { + return; + } + + emit(state.copyWith(isLoadingMore: true)); + + try { + final result = await _convoRepository.getMessages(state.convoId!, cursor: state.cursor, limit: event.limit); + + emit( + state.copyWith( + messages: [...state.messages, ...result.messages], + cursor: result.cursor, + hasMore: result.cursor != null, + isLoadingMore: false, + ), + ); + } catch (error) { + emit(state.copyWith(isLoadingMore: false, hasMore: false)); + } + } + + Future _onMessageSent(MessageSent event, Emitter emit) async { + if (state.status != MessageStatus.loaded || state.convoId == null) { + return; + } + + emit(state.copyWith(isSending: true)); + + try { + final messageView = await _convoRepository.sendMessage(state.convoId!, event.text); + final newMessage = UConvoGetMessagesMessages.messageView(data: messageView); + + emit(state.copyWith(messages: [newMessage, ...state.messages], isSending: false)); + } catch (error) { + emit(state.copyWith(isSending: false)); + log.w('Failed to send message: $error'); + } + } + + Future _onMessageDeleted(MessageDeleted event, Emitter emit) async { + if (state.status != MessageStatus.loaded || state.convoId == null) { + return; + } + + try { + final deletedView = await _convoRepository.deleteMessageForSelf(state.convoId!, event.messageId); + final deletedMessage = UConvoGetMessagesMessages.deletedMessageView(data: deletedView); + + final updatedMessages = state.messages.map((m) { + final isTarget = m.when( + messageView: (data) => data.id == event.messageId, + deletedMessageView: (_) => false, + unknown: (_) => false, + ); + return isTarget ? deletedMessage : m; + }).toList(); + + emit(state.copyWith(messages: updatedMessages)); + } catch (error) { + log.w('Failed to delete message ${event.messageId}: $error'); + } + } + + Future _onConvoMarkedRead(ConvoMarkedRead event, Emitter emit) async { + if (state.convoId == null) return; + + try { + await _convoRepository.updateRead(state.convoId!); + } catch (_) { + log.w('Failed to mark conversation as read'); + } + } +} diff --git a/lib/features/messages/bloc/message_event.dart b/lib/features/messages/bloc/message_event.dart new file mode 100644 index 0000000..662f72d --- /dev/null +++ b/lib/features/messages/bloc/message_event.dart @@ -0,0 +1,49 @@ +part of 'message_bloc.dart'; + +sealed class MessageEvent extends Equatable { + const MessageEvent(); + + @override + List get props => []; +} + +class MessagesRequested extends MessageEvent { + const MessagesRequested({required this.convoId, this.limit = 50}); + + final String convoId; + final int limit; + + @override + List get props => [convoId, limit]; +} + +class MessagesPageLoaded extends MessageEvent { + const MessagesPageLoaded({this.limit = 50}); + + final int limit; + + @override + List get props => [limit]; +} + +class MessageSent extends MessageEvent { + const MessageSent({required this.text}); + + final String text; + + @override + List get props => [text]; +} + +class MessageDeleted extends MessageEvent { + const MessageDeleted({required this.messageId}); + + final String messageId; + + @override + List get props => [messageId]; +} + +class ConvoMarkedRead extends MessageEvent { + const ConvoMarkedRead(); +} diff --git a/lib/features/messages/bloc/message_state.dart b/lib/features/messages/bloc/message_state.dart new file mode 100644 index 0000000..5cd48ec --- /dev/null +++ b/lib/features/messages/bloc/message_state.dart @@ -0,0 +1,63 @@ +part of 'message_bloc.dart'; + +enum MessageStatus { initial, loading, loaded, sending, error } + +class MessageState extends Equatable { + const MessageState._({ + required this.status, + this.messages = const [], + this.cursor, + this.hasMore = false, + this.convoId, + this.isSending = false, + this.isLoadingMore = false, + this.errorMessage, + }); + + const MessageState.initial() : this._(status: MessageStatus.initial); + + const MessageState.loading() : this._(status: MessageStatus.loading); + + const MessageState.loaded({ + required List messages, + String? cursor, + bool hasMore = false, + required String convoId, + }) : this._(status: MessageStatus.loaded, messages: messages, cursor: cursor, hasMore: hasMore, convoId: convoId); + + const MessageState.error(String message) : this._(status: MessageStatus.error, errorMessage: message); + + final MessageStatus status; + final List messages; + final String? cursor; + final bool hasMore; + final String? convoId; + final bool isSending; + final bool isLoadingMore; + final String? errorMessage; + + MessageState copyWith({ + MessageStatus? status, + List? messages, + String? cursor, + bool? hasMore, + String? convoId, + bool? isSending, + bool? isLoadingMore, + String? errorMessage, + }) { + return MessageState._( + status: status ?? this.status, + messages: messages ?? this.messages, + cursor: cursor ?? this.cursor, + hasMore: hasMore ?? this.hasMore, + convoId: convoId ?? this.convoId, + isSending: isSending ?? this.isSending, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [status, messages, cursor, hasMore, convoId, isSending, isLoadingMore, errorMessage]; +} diff --git a/lib/features/messages/data/convo_repository.dart b/lib/features/messages/data/convo_repository.dart new file mode 100644 index 0000000..80371a7 --- /dev/null +++ b/lib/features/messages/data/convo_repository.dart @@ -0,0 +1,65 @@ +import 'package:bluesky/bluesky_chat.dart'; +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:bluesky/chat_bsky_convo_getmessages.dart'; + +class ConvoRepository { + ConvoRepository({required BlueskyChat chat}) : _chat = chat; + + final BlueskyChat _chat; + + Future listConvos({String? cursor, int limit = 20}) async { + final response = await _chat.convo.listConvos(cursor: cursor, limit: limit); + return ConvoListResult(convos: response.data.convos, cursor: response.data.cursor); + } + + Future getConvoForMembers(List dids) async { + final response = await _chat.convo.getConvoForMembers(members: dids); + return response.data.convo; + } + + Future getMessages(String convoId, {String? cursor, int limit = 50}) async { + final response = await _chat.convo.getMessages(convoId: convoId, cursor: cursor, limit: limit); + return MessageListResult(messages: response.data.messages, cursor: response.data.cursor); + } + + Future sendMessage(String convoId, String text) async { + final response = await _chat.convo.sendMessage( + convoId: convoId, + message: MessageInput(text: text), + ); + return response.data; + } + + Future deleteMessageForSelf(String convoId, String messageId) async { + final response = await _chat.convo.deleteMessageForSelf(convoId: convoId, messageId: messageId); + return response.data; + } + + Future muteConvo(String convoId) async { + final response = await _chat.convo.muteConvo(convoId: convoId); + return response.data.convo; + } + + Future unmuteConvo(String convoId) async { + final response = await _chat.convo.unmuteConvo(convoId: convoId); + return response.data.convo; + } + + Future updateRead(String convoId) async { + await _chat.convo.updateRead(convoId: convoId); + } +} + +class ConvoListResult { + ConvoListResult({required this.convos, this.cursor}); + + final List convos; + final String? cursor; +} + +class MessageListResult { + MessageListResult({required this.messages, this.cursor}); + + final List messages; + final String? cursor; +} diff --git a/lib/features/moderation/data/moderation_service.dart b/lib/features/moderation/data/moderation_service.dart new file mode 100644 index 0000000..9fd3de7 --- /dev/null +++ b/lib/features/moderation/data/moderation_service.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:bluesky/bluesky.dart'; +import 'package:bluesky/moderation.dart' as bsky_moderation; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as notifications; +import 'package:lazurite/core/logging/app_logger.dart'; + +class ModerationService { + ModerationService({required Bluesky bluesky}) : _bluesky = bluesky; + + final Bluesky _bluesky; + bsky_moderation.ModerationOpts? _opts; + final _optsController = StreamController.broadcast(); + + Stream get optsStream => _optsController.stream; + bsky_moderation.ModerationOpts? get currentOpts => _opts; + + Future initialize() async { + await _rebuildOpts(); + } + + Future updatePreferences() async { + await _rebuildOpts(); + } + + Future _rebuildOpts() async { + try { + final prefsResponse = await _bluesky.actor.getPreferences(); + final prefs = prefsResponse.data.getModerationPrefs(); + + final labelDefs = await _bluesky.labeler.getLabelDefinitions(prefs); + + final opts = bsky_moderation.ModerationOpts(prefs: prefs, labelDefs: labelDefs); + + _opts = opts; + _optsController.add(opts); + } catch (error) { + log.w('Failed to build moderation opts: $error'); + } + } + + bsky_moderation.ModerationDecision moderatePost(PostView post) { + if (_opts == null) { + return bsky_moderation.ModerationDecision.merge([]); + } + return bsky_moderation.moderatePost(bsky_moderation.ModerationSubjectPost.postView(data: post), _opts!); + } + + bsky_moderation.ModerationDecision moderateProfile(ProfileView profile) { + if (_opts == null) { + return bsky_moderation.ModerationDecision.merge([]); + } + return bsky_moderation.moderateProfile(bsky_moderation.ModerationSubjectProfile.profileView(data: profile), _opts!); + } + + bsky_moderation.ModerationDecision moderateProfileBasic(ProfileViewBasic profile) { + if (_opts == null) { + return bsky_moderation.ModerationDecision.merge([]); + } + return bsky_moderation.moderateProfile( + bsky_moderation.ModerationSubjectProfile.profileViewBasic(data: profile), + _opts!, + ); + } + + bsky_moderation.ModerationDecision moderateProfileDetailed(ProfileViewDetailed profile) { + if (_opts == null) { + return bsky_moderation.ModerationDecision.merge([]); + } + return bsky_moderation.moderateProfile( + bsky_moderation.ModerationSubjectProfile.profileViewDetailed(data: profile), + _opts!, + ); + } + + bsky_moderation.ModerationDecision moderateNotification(notifications.Notification notification) { + if (_opts == null) { + return bsky_moderation.ModerationDecision.merge([]); + } + return bsky_moderation.moderateNotification( + bsky_moderation.ModerationSubjectNotification.notification(data: notification), + _opts!, + ); + } + + void dispose() { + _optsController.close(); + } +} diff --git a/pubspec.lock b/pubspec.lock index f74f08a..7aaa04c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -241,6 +241,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" convert: dependency: transitive description: @@ -297,6 +313,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" diff_match_patch: dependency: transitive description: @@ -752,6 +776,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" node_preamble: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 17b5731..a06f105 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: workmanager: ^0.5.2 plugin_platform_interface: ^2.1.8 url_launcher_platform_interface: ^2.3.2 + connectivity_plus: ^7.0.0 dev_dependencies: flutter_test: diff --git a/scripts/constants.ts b/scripts/constants.ts index afc7f1d..ccb77e3 100644 --- a/scripts/constants.ts +++ b/scripts/constants.ts @@ -1,4 +1,4 @@ -import type { ThemeColors } from "./types"; +import type { ThemeColors, ThemeKey } from "./types"; /** Splash screen sizes for Android */ export const ANDROID_SIZES = [ @@ -9,34 +9,30 @@ export const ANDROID_SIZES = [ { name: "xxxhdpi", size: 1280 }, ]; +const IPHONE_X = { name: "Default-Portrait-812h@3x", size: 1125, scale: 3, height: 2436 }; +const IPHONE_XR = { name: "Default-Portrait-896h@3x", size: 828, scale: 3, height: 1792 }; +/** Also 13, 14 */ +const IPHONE_12 = { name: "Default-Portrait-926h@3x", size: 1170, scale: 3, height: 2532 }; +const IPHONE_14_PRO = { name: "Default-Portrait-932h@3x", size: 1179, scale: 3, height: 2556 }; + /** Splash screen sizes for iOS */ -export const IOS_SIZES = [ +export const IOS_SIZES: { name: string; size: number; scale: number; height?: number }[] = [ + IPHONE_X, + IPHONE_XR, + IPHONE_12, + IPHONE_14_PRO, { name: "Default", size: 320, scale: 1 }, { name: "Default@2x", size: 640, scale: 2 }, { name: "Default-568h@2x", size: 640, scale: 2, height: 1136 }, { name: "Default-667h@2x", size: 750, scale: 2, height: 1334 }, { name: "Default-Portrait-736h@3x", size: 1242, scale: 3, height: 2208 }, { name: "Default-Landscape-736h@3x", size: 2208, scale: 3, height: 1242 }, - { name: "Default-Portrait-812h@3x", size: 1125, scale: 3, height: 2436 }, // iPhone X { name: "Default-Landscape-812h@3x", size: 2436, scale: 3, height: 1125 }, - { name: "Default-Portrait-896h@3x", size: 828, scale: 3, height: 1792 }, // iPhone XR { name: "Default-Landscape-896h@3x", size: 1792, scale: 3, height: 828 }, - { name: "Default-Portrait-926h@3x", size: 1170, scale: 3, height: 2532 }, // iPhone 12/13/14 { name: "Default-Landscape-926h@3x", size: 2532, scale: 3, height: 1170 }, - { name: "Default-Portrait-932h@3x", size: 1179, scale: 3, height: 2556 }, // iPhone 14 Pro { name: "Default-Landscape-932h@3x", size: 2556, scale: 3, height: 1179 }, ]; -type ThemeKey = - | "oxocarbon" - | "oxocarbon-light" - | "catppuccin" - | "catppuccin-light" - | "nord" - | "nord-light" - | "rosePine" - | "rosePine-light"; - export const THEMES: Record = { oxocarbon: { name: "Oxocarbon", variant: "dark", background: "#161616", text: "#f2f4f8", primary: "#78a9ff" }, "oxocarbon-light": { diff --git a/scripts/index.ts b/scripts/index.ts index 5920fa9..2527a15 100755 --- a/scripts/index.ts +++ b/scripts/index.ts @@ -26,10 +26,9 @@ async function generateSplashScreen( const minDim = Math.min(width, height); const logoSize = minDim * 0.22; - const gap = minDim * 0.04; // Gap between logo and text (like 16px in about screen) + const gap = minDim * 0.04; const fontSize = minDim * 0.07; - // Calculate total height of logo + gap + text to center the group const totalGroupHeight = logoSize + gap + fontSize; const groupCenterY = height / 2; const logoY = groupCenterY - totalGroupHeight / 2; diff --git a/scripts/types.ts b/scripts/types.ts index 321609c..5800963 100644 --- a/scripts/types.ts +++ b/scripts/types.ts @@ -1,3 +1,13 @@ export type ThemeVariant = "light" | "dark"; export type ThemeColors = { name: string; variant: ThemeVariant; background: string; text: string; primary: string }; + +export type ThemeKey = + | "oxocarbon" + | "oxocarbon-light" + | "catppuccin" + | "catppuccin-light" + | "nord" + | "nord-light" + | "rosePine" + | "rosePine-light"; diff --git a/test/features/account/cubit/account_switcher_cubit_test.dart b/test/features/account/cubit/account_switcher_cubit_test.dart new file mode 100644 index 0000000..ee6a37e --- /dev/null +++ b/test/features/account/cubit/account_switcher_cubit_test.dart @@ -0,0 +1,208 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAppDatabase extends Mock implements AppDatabase {} + +class AccountsCompanionFake extends Fake implements AccountsCompanion {} + +void main() { + late MockAppDatabase mockDatabase; + + setUpAll(() { + registerFallbackValue(AccountsCompanionFake()); + }); + + setUp(() { + mockDatabase = MockAppDatabase(); + }); + + Account makeAccount({required String did, String handle = 'user.bsky.social'}) { + return Account( + did: did, + handle: handle, + displayName: null, + service: null, + accessToken: 'token', + refreshToken: null, + dpopPublicKey: null, + dpopPrivateKey: null, + dpopNonce: null, + expiresAt: null, + createdAt: DateTime.utc(2026, 1, 1), + updatedAt: DateTime.utc(2026, 1, 1), + ); + } + + group('AccountSwitcherCubit', () { + group('loadAccounts', () { + blocTest( + 'emits loading then ready with accounts when accounts exist', + build: () => AccountSwitcherCubit(database: mockDatabase), + setUp: () { + final accounts = [makeAccount(did: 'did:plc:user1'), makeAccount(did: 'did:plc:user2')]; + when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => 'did:plc:user1'); + }, + act: (cubit) => cubit.loadAccounts(), + expect: () => [ + const AccountSwitcherState.loading(), + predicate( + (state) => + state.status == AccountSwitcherStatus.ready && + state.accounts.length == 2 && + state.activeDid == 'did:plc:user1', + ), + ], + ); + + blocTest( + 'defaults to first account when no saved active did', + build: () => AccountSwitcherCubit(database: mockDatabase), + setUp: () { + final accounts = [makeAccount(did: 'did:plc:user1'), makeAccount(did: 'did:plc:user2')]; + when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => null); + }, + act: (cubit) => cubit.loadAccounts(), + expect: () => [ + const AccountSwitcherState.loading(), + predicate( + (state) => state.status == AccountSwitcherStatus.ready && state.activeDid == 'did:plc:user1', + ), + ], + ); + + blocTest( + 'defaults to first account when saved did not in accounts', + build: () => AccountSwitcherCubit(database: mockDatabase), + setUp: () { + final accounts = [makeAccount(did: 'did:plc:user1')]; + when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => 'did:plc:unknown'); + }, + act: (cubit) => cubit.loadAccounts(), + expect: () => [ + const AccountSwitcherState.loading(), + predicate( + (state) => state.status == AccountSwitcherStatus.ready && state.activeDid == 'did:plc:user1', + ), + ], + ); + + blocTest( + 'emits ready with empty accounts on failure', + build: () => AccountSwitcherCubit(database: mockDatabase), + setUp: () { + when(() => mockDatabase.getAllAccounts()).thenThrow(Exception('DB error')); + }, + act: (cubit) => cubit.loadAccounts(), + expect: () => [ + const AccountSwitcherState.loading(), + predicate( + (state) => state.status == AccountSwitcherStatus.ready && state.accounts.isEmpty, + ), + ], + ); + }); + + group('switchAccount', () { + blocTest( + 'updates activeDid when switching accounts', + build: () => AccountSwitcherCubit(database: mockDatabase), + seed: () => AccountSwitcherState.ready( + accounts: [ + makeAccount(did: 'did:plc:user1'), + makeAccount(did: 'did:plc:user2'), + ], + activeDid: 'did:plc:user1', + ), + setUp: () { + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); + }, + act: (cubit) => cubit.switchAccount('did:plc:user2'), + expect: () => [predicate((state) => state.activeDid == 'did:plc:user2')], + verify: (_) { + verify(() => mockDatabase.setSetting('active_account_did', 'did:plc:user2')).called(1); + }, + ); + + blocTest( + 'does nothing when state is not ready', + build: () => AccountSwitcherCubit(database: mockDatabase), + act: (cubit) => cubit.switchAccount('did:plc:user1'), + expect: () => [], + verify: (_) { + verifyNever(() => mockDatabase.setSetting(any(), any())); + }, + ); + }); + + group('addAccountCompleted', () { + blocTest( + 'inserts account, reloads, and activeDid is set to the new account', + build: () => AccountSwitcherCubit(database: mockDatabase), + setUp: () { + when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); + when( + () => mockDatabase.getAllAccounts(), + ).thenAnswer((_) async => [makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social')]); + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => null); + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); + }, + act: (cubit) => cubit.addAccountCompleted( + const AuthTokens(accessToken: 'token', did: 'did:plc:newuser', handle: 'new.bsky.social'), + ), + expect: () => [ + const AccountSwitcherState.loading(), + predicate( + (state) => + state.status == AccountSwitcherStatus.ready && + state.accounts.length == 1 && + state.activeDid == 'did:plc:newuser', + ), + ], + verify: (_) { + verify(() => mockDatabase.insertAccount(any())).called(1); + verify(() => mockDatabase.setSetting('active_account_did', 'did:plc:newuser')).called(1); + }, + ); + + blocTest( + 'switches to newly added account even when another was active', + build: () => AccountSwitcherCubit(database: mockDatabase), + seed: () => AccountSwitcherState.ready( + accounts: [makeAccount(did: 'did:plc:user1')], + activeDid: 'did:plc:user1', + ), + setUp: () { + when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); + when(() => mockDatabase.getAllAccounts()).thenAnswer( + (_) async => [ + makeAccount(did: 'did:plc:user1'), + makeAccount(did: 'did:plc:user2', handle: 'user2.bsky.social'), + ], + ); + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => 'did:plc:user1'); + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); + }, + act: (cubit) => cubit.addAccountCompleted( + const AuthTokens(accessToken: 'token', did: 'did:plc:user2', handle: 'user2.bsky.social'), + ), + expect: () => [ + const AccountSwitcherState.loading(), + predicate( + (state) => + state.status == AccountSwitcherStatus.ready && + state.accounts.length == 2 && + state.activeDid == 'did:plc:user1', + ), + predicate((state) => state.activeDid == 'did:plc:user2'), + ], + ); + }); + }); +} diff --git a/test/features/compose/bloc/compose_bloc_test.dart b/test/features/compose/bloc/compose_bloc_test.dart index b3c64ac..bbc56f1 100644 --- a/test/features/compose/bloc/compose_bloc_test.dart +++ b/test/features/compose/bloc/compose_bloc_test.dart @@ -82,10 +82,8 @@ void main() { blocTest( 'isDraftDirty is true after media removed', build: () => composeBloc, - seed: () => const ComposeState.ready( - mediaAttachments: [MediaAttachment(localPath: '/1.jpg')], - isDraftDirty: false, - ), + seed: () => + const ComposeState.ready(mediaAttachments: [MediaAttachment(localPath: '/1.jpg')], isDraftDirty: false), act: (bloc) => bloc.add(const MediaRemoved(0)), expect: () => [isA().having((s) => s.isDraftDirty, 'isDraftDirty', true)], ); diff --git a/test/features/compose/presentation/compose_screen_test.dart b/test/features/compose/presentation/compose_screen_test.dart index c8ed0b0..21817d7 100644 --- a/test/features/compose/presentation/compose_screen_test.dart +++ b/test/features/compose/presentation/compose_screen_test.dart @@ -130,7 +130,9 @@ void main() { }); testWidgets('shows draft items when drafts are loaded', (tester) async { - seedState(const ComposeState.ready().copyWith(drafts: [_makeDraft(content: 'My saved draft')], isLoadingDrafts: false)); + seedState( + const ComposeState.ready().copyWith(drafts: [_makeDraft(content: 'My saved draft')], isLoadingDrafts: false), + ); await tester.pumpWidget(buildSubject()); await tester.pump(); diff --git a/test/features/connectivity/cubit/connectivity_cubit_test.dart b/test/features/connectivity/cubit/connectivity_cubit_test.dart new file mode 100644 index 0000000..3e173f3 --- /dev/null +++ b/test/features/connectivity/cubit/connectivity_cubit_test.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConnectivity extends Mock implements Connectivity {} + +void main() { + late MockConnectivity mockConnectivity; + late StreamController> connectivityStreamController; + + setUp(() { + mockConnectivity = MockConnectivity(); + connectivityStreamController = StreamController>.broadcast(); + + when(() => mockConnectivity.checkConnectivity()).thenAnswer((_) async => [ConnectivityResult.wifi]); + when(() => mockConnectivity.onConnectivityChanged).thenAnswer((_) => connectivityStreamController.stream); + }); + + tearDown(() { + connectivityStreamController.close(); + }); + + group('ConnectivityCubit', () { + test('initial state is online before checkConnectivity resolves', () { + final cubit = ConnectivityCubit(connectivity: mockConnectivity); + expect(cubit.state.isOnline, isTrue); + Future.delayed(Duration.zero).then((_) => cubit.close()); + }); + + blocTest( + 'emits online state when connectivity is wifi', + build: () { + when(() => mockConnectivity.checkConnectivity()).thenAnswer((_) async => [ConnectivityResult.wifi]); + return ConnectivityCubit(connectivity: mockConnectivity); + }, + expect: () => [predicate((state) => state.isOnline)], + ); + + blocTest( + 'emits offline state when connectivity changes to none', + build: () => ConnectivityCubit(connectivity: mockConnectivity), + act: (cubit) => connectivityStreamController.add([ConnectivityResult.none]), + expect: () => [ + predicate((state) => state.isOnline), + predicate((state) => !state.isOnline), + ], + ); + + blocTest( + 'emits online state when connectivity changes to mobile', + build: () { + when(() => mockConnectivity.checkConnectivity()).thenAnswer((_) async => [ConnectivityResult.none]); + return ConnectivityCubit(connectivity: mockConnectivity); + }, + act: (cubit) => connectivityStreamController.add([ConnectivityResult.mobile]), + expect: () => [ + predicate((state) => !state.isOnline), + predicate((state) => state.isOnline), + ], + ); + + test('ConnectivityState.online has isOnline = true', () { + const state = ConnectivityState.online(); + expect(state.isOnline, isTrue); + expect(state.status, ConnectivityStatus.online); + }); + + test('ConnectivityState.offline has isOnline = false', () { + const state = ConnectivityState.offline(); + expect(state.isOnline, isFalse); + expect(state.status, ConnectivityStatus.offline); + }); + }); +} diff --git a/test/features/feed/presentation/post_thread_screen_test.dart b/test/features/feed/presentation/post_thread_screen_test.dart index d0d8735..0bf2ad4 100644 --- a/test/features/feed/presentation/post_thread_screen_test.dart +++ b/test/features/feed/presentation/post_thread_screen_test.dart @@ -302,7 +302,7 @@ void main() { final parents = _extractParentChain(thread); expect(parents.length, 2); - expect(parents[0].post.cid, 'cid-gp'); // oldest first + expect(parents[0].post.cid, 'cid-gp'); expect(parents[1].post.cid, 'cid-p'); }); diff --git a/test/features/messages/bloc/convo_list_bloc_test.dart b/test/features/messages/bloc/convo_list_bloc_test.dart new file mode 100644 index 0000000..d3c73e7 --- /dev/null +++ b/test/features/messages/bloc/convo_list_bloc_test.dart @@ -0,0 +1,142 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConvoRepository extends Mock implements ConvoRepository {} + +void main() { + late MockConvoRepository mockConvoRepository; + + setUp(() { + mockConvoRepository = MockConvoRepository(); + }); + + ConvoView makeConvoView(String id) => ConvoView(id: id, rev: 'rev-1', members: [], muted: false, unreadCount: 0); + + group('ConvoListBloc', () { + blocTest( + 'emits loading and loaded when ConvosRequested succeeds', + build: () => ConvoListBloc(convoRepository: mockConvoRepository), + setUp: () { + when( + () => mockConvoRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => ConvoListResult(convos: [makeConvoView('c1')], cursor: 'cursor-1')); + }, + act: (bloc) => bloc.add(const ConvosRequested()), + expect: () => [ + const ConvoListState.loading(), + predicate( + (state) => + state.status == ConvoListStatus.loaded && + state.convos.length == 1 && + state.cursor == 'cursor-1' && + state.hasMore == true, + ), + ], + ); + + blocTest( + 'emits error when ConvosRequested fails', + build: () => ConvoListBloc(convoRepository: mockConvoRepository), + setUp: () { + when( + () => mockConvoRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(Exception('Network error')); + }, + act: (bloc) => bloc.add(const ConvosRequested()), + expect: () => [ + const ConvoListState.loading(), + predicate((state) => state.status == ConvoListStatus.error), + ], + ); + + blocTest( + 'does not refresh when state is not loaded', + build: () => ConvoListBloc(convoRepository: mockConvoRepository), + act: (bloc) => bloc.add(const ConvosRefreshed()), + expect: () => [], + verify: (_) { + verifyNever( + () => mockConvoRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ); + }, + ); + + blocTest( + 'refreshes convos on ConvosRefreshed when loaded', + build: () => ConvoListBloc(convoRepository: mockConvoRepository), + seed: () => ConvoListState.loaded(convos: [makeConvoView('c1')], cursor: 'old-cursor', hasMore: true), + setUp: () { + when( + () => mockConvoRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => ConvoListResult(convos: [makeConvoView('c2')], cursor: 'new-cursor')); + }, + act: (bloc) => bloc.add(const ConvosRefreshed()), + expect: () => [ + predicate((state) => state.isRefreshing), + predicate( + (state) => + state.status == ConvoListStatus.loaded && + state.convos.length == 1 && + state.convos.first.id == 'c2' && + state.cursor == 'new-cursor' && + !state.isRefreshing, + ), + ], + ); + + blocTest( + 'mutes convo and updates list on ConvoMuted', + build: () => ConvoListBloc(convoRepository: mockConvoRepository), + seed: () => ConvoListState.loaded(convos: [makeConvoView('c1')], cursor: null, hasMore: false), + setUp: () { + const mutedConvo = ConvoView(id: 'c1', rev: 'rev-2', members: [], muted: true, unreadCount: 0); + when(() => mockConvoRepository.muteConvo(any())).thenAnswer((_) async => mutedConvo); + }, + act: (bloc) => bloc.add(const ConvoMuted(convoId: 'c1')), + expect: () => [predicate((state) => state.convos.isNotEmpty && state.convos.first.muted)], + ); + + blocTest( + 'unmutes convo and updates list on ConvoUnmuted', + build: () => ConvoListBloc(convoRepository: mockConvoRepository), + seed: () => const ConvoListState.loaded( + convos: [ConvoView(id: 'c1', rev: 'rev-1', members: [], muted: true, unreadCount: 0)], + cursor: null, + hasMore: false, + ), + setUp: () { + final unmutedConvo = makeConvoView('c1'); + when(() => mockConvoRepository.unmuteConvo(any())).thenAnswer((_) async => unmutedConvo); + }, + act: (bloc) => bloc.add(const ConvoUnmuted(convoId: 'c1')), + expect: () => [predicate((state) => state.convos.isNotEmpty && !state.convos.first.muted)], + ); + + blocTest( + 'handles mute failure silently', + build: () => ConvoListBloc(convoRepository: mockConvoRepository), + seed: () => ConvoListState.loaded(convos: [makeConvoView('c1')], cursor: null, hasMore: false), + setUp: () { + when(() => mockConvoRepository.muteConvo(any())).thenThrow(Exception('Network error')); + }, + act: (bloc) => bloc.add(const ConvoMuted(convoId: 'c1')), + expect: () => [], + ); + }); +} diff --git a/test/features/messages/bloc/message_bloc_test.dart b/test/features/messages/bloc/message_bloc_test.dart new file mode 100644 index 0000000..0e116f3 --- /dev/null +++ b/test/features/messages/bloc/message_bloc_test.dart @@ -0,0 +1,196 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:bluesky/chat_bsky_convo_getmessages.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/messages/bloc/message_bloc.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConvoRepository extends Mock implements ConvoRepository {} + +void main() { + late MockConvoRepository mockConvoRepository; + + const testConvoId = 'convo-123'; + const testUserDid = 'did:plc:testuser'; + + setUp(() { + mockConvoRepository = MockConvoRepository(); + }); + + MessageView makeMessageView(String id, String text) => MessageView( + id: id, + rev: 'rev-1', + text: text, + sender: const MessageViewSender(did: testUserDid), + sentAt: DateTime.utc(2026, 3, 15), + ); + + UConvoGetMessagesMessages makeMessage(String id, String text) => + UConvoGetMessagesMessages.messageView(data: makeMessageView(id, text)); + + group('MessageBloc', () { + blocTest( + 'emits loading and loaded when MessagesRequested succeeds', + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), + setUp: () { + when( + () => mockConvoRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: [makeMessage('msg-1', 'Hello')], cursor: 'cursor-1')); + }, + act: (bloc) => bloc.add(const MessagesRequested(convoId: testConvoId)), + expect: () => [ + const MessageState.loading(), + predicate( + (state) => + state.status == MessageStatus.loaded && + state.messages.length == 1 && + state.cursor == 'cursor-1' && + state.hasMore == true && + state.convoId == testConvoId, + ), + ], + ); + + blocTest( + 'emits error when MessagesRequested fails', + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), + setUp: () { + when( + () => mockConvoRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(Exception('Network error')); + }, + act: (bloc) => bloc.add(const MessagesRequested(convoId: testConvoId)), + expect: () => [ + const MessageState.loading(), + predicate((state) => state.status == MessageStatus.error), + ], + ); + + blocTest( + 'loads more messages on MessagesPageLoaded', + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), + seed: () => MessageState.loaded( + messages: [makeMessage('msg-1', 'Hello')], + cursor: 'cursor-1', + hasMore: true, + convoId: testConvoId, + ), + setUp: () { + when( + () => mockConvoRepository.getMessages( + any(), + cursor: 'cursor-1', + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: [makeMessage('msg-2', 'World')], cursor: null)); + }, + act: (bloc) => bloc.add(const MessagesPageLoaded()), + expect: () => [ + predicate((state) => state.isLoadingMore), + predicate( + (state) => + state.status == MessageStatus.loaded && + state.messages.length == 2 && + !state.hasMore && + !state.isLoadingMore, + ), + ], + ); + + blocTest( + 'does not load more when already loading more', + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), + seed: () => MessageState.loaded( + messages: [makeMessage('msg-1', 'Hello')], + cursor: 'cursor-1', + hasMore: true, + convoId: testConvoId, + ).copyWith(isLoadingMore: true), + act: (bloc) => bloc.add(const MessagesPageLoaded()), + expect: () => [], + verify: (_) { + verifyNever( + () => mockConvoRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ); + }, + ); + + blocTest( + 'sends message and prepends to list on MessageSent', + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), + seed: () => MessageState.loaded(messages: [makeMessage('msg-1', 'Hello')], cursor: null, convoId: testConvoId), + setUp: () { + when( + () => mockConvoRepository.sendMessage(any(), any()), + ).thenAnswer((_) async => makeMessageView('msg-new', 'World')); + }, + act: (bloc) => bloc.add(const MessageSent(text: 'World')), + expect: () => [ + predicate((state) => state.isSending), + predicate((state) => state.messages.length == 2 && !state.isSending), + ], + ); + + blocTest( + 'handles send failure silently', + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), + seed: () => MessageState.loaded(messages: [makeMessage('msg-1', 'Hello')], cursor: null, convoId: testConvoId), + setUp: () { + when(() => mockConvoRepository.sendMessage(any(), any())).thenThrow(Exception('Send failed')); + }, + act: (bloc) => bloc.add(const MessageSent(text: 'World')), + expect: () => [ + predicate((state) => state.isSending), + predicate((state) => !state.isSending && state.messages.length == 1), + ], + ); + + blocTest( + 'calls updateRead on ConvoMarkedRead', + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), + seed: () => const MessageState.loaded(messages: [], cursor: null, convoId: testConvoId), + setUp: () { + when(() => mockConvoRepository.updateRead(any())).thenAnswer((_) async {}); + }, + act: (bloc) => bloc.add(const ConvoMarkedRead()), + expect: () => [], + verify: (_) { + verify(() => mockConvoRepository.updateRead(testConvoId)).called(1); + }, + ); + + blocTest( + 'handles updateRead failure silently', + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), + seed: () => const MessageState.loaded(messages: [], cursor: null, convoId: testConvoId), + setUp: () { + when(() => mockConvoRepository.updateRead(any())).thenThrow(Exception('Network error')); + }, + act: (bloc) => bloc.add(const ConvoMarkedRead()), + expect: () => [], + ); + + blocTest( + 'does not send when state is not loaded', + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), + act: (bloc) => bloc.add(const MessageSent(text: 'Hello')), + expect: () => [], + verify: (_) { + verifyNever(() => mockConvoRepository.sendMessage(any(), any())); + }, + ); + }); +} diff --git a/test/features/messages/data/convo_repository_test.dart b/test/features/messages/data/convo_repository_test.dart new file mode 100644 index 0000000..62c0ac6 --- /dev/null +++ b/test/features/messages/data/convo_repository_test.dart @@ -0,0 +1,123 @@ +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConvoRepository extends Mock implements ConvoRepository {} + +void main() { + group('ConvoListResult', () { + test('stores convos and cursor', () { + final convo = _makeConvoView('c1'); + final result = ConvoListResult(convos: [convo], cursor: 'cursor-1'); + + expect(result.convos.length, 1); + expect(result.convos.first.id, 'c1'); + expect(result.cursor, 'cursor-1'); + }); + + test('cursor is nullable', () { + final result = ConvoListResult(convos: []); + expect(result.cursor, isNull); + }); + }); + + group('MessageListResult', () { + test('stores messages and cursor', () { + final result = MessageListResult(messages: [], cursor: 'cursor-1'); + + expect(result.messages, isEmpty); + expect(result.cursor, 'cursor-1'); + }); + + test('cursor is nullable', () { + final result = MessageListResult(messages: []); + expect(result.cursor, isNull); + }); + }); + + group('ConvoRepository interface', () { + late MockConvoRepository mockRepo; + + setUp(() { + mockRepo = MockConvoRepository(); + }); + + test('listConvos returns ConvoListResult', () async { + final expected = ConvoListResult(convos: [_makeConvoView('c1')], cursor: 'cursor-1'); + when( + () => mockRepo.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => expected); + + final result = await mockRepo.listConvos(); + + expect(result.convos.length, 1); + expect(result.cursor, 'cursor-1'); + }); + + test('getMessages returns MessageListResult', () async { + final expected = MessageListResult(messages: [], cursor: null); + when( + () => mockRepo.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => expected); + + final result = await mockRepo.getMessages('convo-1'); + + expect(result.messages, isEmpty); + expect(result.cursor, isNull); + }); + + test('muteConvo returns ConvoView', () async { + final expected = _makeConvoView('c1'); + when(() => mockRepo.muteConvo(any())).thenAnswer((_) async => expected); + + final result = await mockRepo.muteConvo('c1'); + + expect(result.id, 'c1'); + }); + + test('unmuteConvo returns ConvoView', () async { + final expected = _makeConvoView('c1'); + when(() => mockRepo.unmuteConvo(any())).thenAnswer((_) async => expected); + + final result = await mockRepo.unmuteConvo('c1'); + + expect(result.id, 'c1'); + }); + + test('updateRead completes', () async { + when(() => mockRepo.updateRead(any())).thenAnswer((_) async {}); + + await mockRepo.updateRead('c1'); + + verify(() => mockRepo.updateRead('c1')).called(1); + }); + + test('sendMessage returns MessageView', () async { + final expected = _makeMessageView('msg-1', 'Hello'); + when(() => mockRepo.sendMessage(any(), any())).thenAnswer((_) async => expected); + + final result = await mockRepo.sendMessage('c1', 'Hello'); + + expect(result.id, 'msg-1'); + expect(result.text, 'Hello'); + }); + }); +} + +ConvoView _makeConvoView(String id) => ConvoView(id: id, rev: 'rev-1', members: [], muted: false, unreadCount: 0); + +MessageView _makeMessageView(String id, String text) => MessageView( + id: id, + rev: 'rev-1', + text: text, + sender: const MessageViewSender(did: 'did:plc:user'), + sentAt: DateTime.utc(2026, 3, 15), +); diff --git a/test/features/moderation/data/moderation_service_test.dart b/test/features/moderation/data/moderation_service_test.dart new file mode 100644 index 0000000..d9d9d93 --- /dev/null +++ b/test/features/moderation/data/moderation_service_test.dart @@ -0,0 +1,109 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:bluesky/bluesky.dart'; +import 'package:bluesky/moderation.dart' as bsky_moderation; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; + +/// Tests for ModerationService. +/// +/// Note: Since Bluesky, ActorService, and LabelerService are sealed/base +/// classes, we cannot mock them with mocktail. Instead, we test the +/// ModerationService's behavior through its public API, focusing on: +/// - Default behavior before initialization (null opts) +/// - State management (currentOpts, optsStream) +/// - Moderation methods returning correct types +void main() { + late ModerationService moderationService; + + setUp(() { + moderationService = ModerationService(bluesky: Bluesky.anonymous()); + }); + + tearDown(() { + moderationService.dispose(); + }); + + group('ModerationService', () { + group('initial state', () { + test('currentOpts is null before initialize', () { + expect(moderationService.currentOpts, isNull); + }); + + test('optsStream is a broadcast stream', () { + expect(moderationService.optsStream.isBroadcast, isTrue); + }); + }); + + group('moderatePost', () { + test('returns ModerationDecision when opts are null', () { + final result = moderationService.moderatePost(_makeSamplePostView()); + expect(result, isA()); + }); + }); + + group('moderateProfile', () { + test('returns ModerationDecision with ProfileView when opts are null', () { + final result = moderationService.moderateProfile( + const ProfileView(did: 'did:plc:user', handle: 'user.bsky.social'), + ); + expect(result, isA()); + }); + }); + + group('moderateProfileBasic', () { + test('returns ModerationDecision with ProfileViewBasic when opts are null', () { + final result = moderationService.moderateProfileBasic( + const ProfileViewBasic(did: 'did:plc:user', handle: 'user.bsky.social'), + ); + expect(result, isA()); + }); + }); + + group('moderateProfileDetailed', () { + test('returns ModerationDecision with ProfileViewDetailed when opts are null', () { + const profile = ProfileViewDetailed(did: 'did:plc:user', handle: 'user.bsky.social'); + final result = moderationService.moderateProfileDetailed(profile); + expect(result, isA()); + }); + }); + + group('dispose', () { + test('can be called without error', () { + expect(() => moderationService.dispose(), returnsNormally); + }); + + test('closes the opts stream', () async { + moderationService.dispose(); + final isDone = await moderationService.optsStream.isEmpty; + expect(isDone, isTrue); + }); + }); + + group('ModerationOpts', () { + test('can be constructed with prefs and empty labelDefs', () { + const prefs = bsky_moderation.ModerationPrefs( + adultContentEnabled: false, + labels: {}, + labelers: [], + mutedWords: [], + hiddenPosts: [], + ); + const opts = bsky_moderation.ModerationOpts(prefs: prefs); + expect(opts.prefs.adultContentEnabled, isFalse); + expect(opts.labelDefs, isEmpty); + }); + }); + }); +} + +PostView _makeSamplePostView() { + return PostView( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), + cid: 'cid-123', + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), + record: const {r'$type': 'app.bsky.feed.post', 'text': 'Hello world'}, + indexedAt: DateTime.utc(2026, 3, 15), + ); +} diff --git a/test/features/settings/presentation/about_screen_test.dart b/test/features/settings/presentation/about_screen_test.dart index 5fa50cd..884c86e 100644 --- a/test/features/settings/presentation/about_screen_test.dart +++ b/test/features/settings/presentation/about_screen_test.dart @@ -108,7 +108,6 @@ void main() { await tester.pumpWidget(buildSubject()); await tester.pump(); - // GitHub SVG, Tangled SVG, email icon — wrapped in _LinkIcon (InkWell) expect(find.byType(InkWell), findsNWidgets(3)); }); });