mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: dm, accounts, offline, and moderation data layer

+2158 -70
+19 -19
docs/tasks/phase-3.md
··· 31 31 32 32 ## M10 — Post & Profile Actions 33 33 34 - - [ ] `PostActionRepository` — like, repost, delete via `com.atproto.repo.createRecord` / `deleteRecord` 35 - - [ ] `PostActionCubit` — optimistic state updates for like / repost toggle with rollback on failure 36 - - [ ] Like toggle: create `app.bsky.feed.like` record or delete by rkey; update `viewer.like` and `likeCount` 37 - - [ ] Repost toggle: create `app.bsky.feed.repost` record or delete by rkey; update `viewer.repost` and `repostCount` 38 - - [ ] Post action bar UI — like, repost, reply, share buttons with animated state transitions 39 - - [ ] `ProfileActionRepository` — follow, mute, block, report 40 - - [ ] `ProfileActionCubit` — optimistic follow/mute/block state with rollback 41 - - [ ] Follow toggle: create `app.bsky.graph.follow` record or delete by rkey; update `viewer.following` 42 - - [ ] Mute toggle via `app.bsky.graph.muteActor` / `unmuteActor`; update `viewer.muted` 43 - - [ ] Block toggle: create `app.bsky.graph.block` record or delete by rkey; update `viewer.blocking` 44 - - [ ] Profile action buttons: Follow / Following / Mute / Block in profile header and overflow menu 45 - - [ ] Report dialog: reason picker + optional description, submit via `com.atproto.moderation.createReport` 46 - - [ ] Report for both posts (RepoStrongRef subject) and accounts (RepoRef subject) 47 - - [ ] Confirmation dialog before mute / block actions 48 - - [ ] Thread muting via `app.bsky.feed.threadgate` awareness (show muted-thread indicator) 34 + - [x] `PostActionRepository` — like, repost, delete via `com.atproto.repo.createRecord` / `deleteRecord` 35 + - [x] `PostActionCubit` — optimistic state updates for like / repost toggle with rollback on failure 36 + - [x] Like toggle: create `app.bsky.feed.like` record or delete by rkey; update `viewer.like` and `likeCount` 37 + - [x] Repost toggle: create `app.bsky.feed.repost` record or delete by rkey; update `viewer.repost` and `repostCount` 38 + - [x] Post action bar UI — like, repost, reply, share buttons with animated state transitions 39 + - [x] `ProfileActionRepository` — follow, mute, block, report 40 + - [x] `ProfileActionCubit` — optimistic follow/mute/block state with rollback 41 + - [x] Follow toggle: create `app.bsky.graph.follow` record or delete by rkey; update `viewer.following` 42 + - [x] Mute toggle via `app.bsky.graph.muteActor` / `unmuteActor`; update `viewer.muted` 43 + - [x] Block toggle: create `app.bsky.graph.block` record or delete by rkey; update `viewer.blocking` 44 + - [x] Profile action buttons: Follow / Following / Mute / Block in profile header and overflow menu 45 + - [x] Report dialog: reason picker + optional description, submit via `com.atproto.moderation.createReport` 46 + - [x] Report for both posts (RepoStrongRef subject) and accounts (RepoRef subject) 47 + - [x] Confirmation dialog before mute / block actions 48 + - [x] Thread muting via `app.bsky.feed.threadgate` awareness (show muted-thread indicator) 49 49 50 50 ## M11 — Saved Posts 51 51 52 - - [ ] Drift migration: add `saved_posts` table (id, account_did, post_uri, post_json, saved_at) with unique constraint on (account_did, post_uri) 53 - - [ ] `SavedPostsCubit` — read/write saved posts, expose stream of saved URIs for icon state 54 - - [ ] Bookmark icon on post action bar — toggle saved state 55 - - [ ] Saved posts list screen accessible from profile or settings 52 + - [x] Drift migration: add `saved_posts` table (id, account_did, post_uri, post_json, saved_at) with unique constraint on (account_did, post_uri) 53 + - [x] `SavedPostsCubit` — read/write saved posts, expose stream of saved URIs for icon state 54 + - [x] Bookmark icon on post action bar — toggle saved state 55 + - [x] Saved posts list screen accessible from profile or settings
+14 -14
docs/tasks/phase-4.md
··· 3 3 ## M12 — Direct Messages 4 4 5 5 - [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination 6 - - [ ] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted` 7 - - [ ] Primary / Requests tab filtering on conversation list 6 + - [x] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted` 7 + - [x] Primary / Requests tab filtering on conversation list 8 8 - [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination 9 - - [ ] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead` 9 + - [x] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead` 10 10 - [ ] Chat bubble layout — current user right-aligned, others left-aligned 11 - - [ ] Send messages via `chat.bsky.convo.sendMessage` 12 - - [ ] New conversation via `chat.bsky.convo.getConvoForMembers` 11 + - [x] Send messages via `chat.bsky.convo.sendMessage` 12 + - [x] New conversation via `chat.bsky.convo.getConvoForMembers` 13 13 - [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread 14 - - [ ] Mute / unmute conversations 15 - - [ ] Mark conversation as read via `chat.bsky.convo.updateRead` 14 + - [x] Mute / unmute conversations 15 + - [x] Mark conversation as read via `chat.bsky.convo.updateRead` 16 16 17 17 ## M13 — Account Switching 18 18 19 - - [ ] `AccountSwitcherCubit` exposing account list and active DID 19 + - [x] `AccountSwitcherCubit` exposing account list and active DID 20 20 - [ ] Account switcher bottom sheet UI — list accounts with avatars and handles 21 - - [ ] Store `active_account_did` in Drift `settings` table 22 - - [ ] Drift migration: add `account_did` column to `cached_posts` if not present 21 + - [x] Store `active_account_did` in Drift `settings` table 22 + - [x] Drift migration: add `account_did` column to `cached_posts` if not present 23 23 - [ ] All user-scoped queries filter by active account DID 24 24 - [ ] Broadcast `AccountSwitched` event to all Blocs on switch 25 25 - [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row ··· 27 27 28 28 ## M14 — Offline Reading & Network Resilience 29 29 30 - - [ ] `ConnectivityCubit` via **connectivity_plus** — expose network state stream 30 + - [x] `ConnectivityCubit` via **connectivity_plus** — expose network state stream 31 31 - [ ] Cache last-fetched feed page as serialised JSON in Drift 32 32 - [ ] Display cached data immediately on launch, refresh in background 33 33 - [ ] "You're offline" banner when connectivity is lost ··· 43 43 44 44 ## M16 — Labelers & Content Moderation 45 45 46 - - [ ] Fetch user's labeler subscriptions from preferences via `app.bsky.actor.getPreferences` (`labelersPref`) 46 + - [x] Fetch user's labeler subscriptions from preferences via `app.bsky.actor.getPreferences` (`labelersPref`) 47 47 - [ ] Include subscribed labeler DIDs in `atproto-accept-labelers` header on all XRPC requests 48 - - [ ] `ModerationService` — wraps the `bluesky` package's `moderatePost`, `moderateProfile`, `moderateNotification` functions 48 + - [x] `ModerationService` — wraps the `bluesky` package's `moderatePost`, `moderateProfile`, `moderateNotification` functions 49 49 - [ ] Run moderation decisions on all displayed posts and profiles 50 50 - [ ] Apply `ModerationUI` results: filter, blur, alert, inform per display context (contentList, contentView, contentMedia, avatar, profileList, profileView) 51 51 - [ ] Blur overlay on posts/media with click-through "Show content" button ··· 58 58 - [ ] Adult content toggle (requires `adultContentEnabled` preference) 59 59 - [ ] Self-label support — render self-labels embedded in posts and profiles 60 60 - [ ] Labeler detail screen: show labeler creator, policies, and custom label definitions with localised names 61 - - [ ] Drift table: `labeler_cache` (labeler_did, policies_json, fetched_at) for offline label definition lookup 61 + - [x] Drift table: `labeler_cache` (labeler_did, policies_json, fetched_at) for offline label definition lookup
+37 -2
lib/core/database/app_database.dart
··· 6 6 7 7 part 'app_database.g.dart'; 8 8 9 - @DriftDatabase(tables: [Accounts, CachedProfiles, CachedPosts, Settings, SavedFeeds, SearchHistory, Drafts, SavedPosts]) 9 + @DriftDatabase( 10 + tables: [ 11 + Accounts, 12 + CachedProfiles, 13 + CachedPosts, 14 + Settings, 15 + SavedFeeds, 16 + SearchHistory, 17 + Drafts, 18 + SavedPosts, 19 + LabelerCache, 20 + ], 21 + ) 10 22 class AppDatabase extends _$AppDatabase { 11 23 AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); 12 24 13 25 @override 14 - int get schemaVersion => 7; 26 + int get schemaVersion => 9; 15 27 16 28 @override 17 29 MigrationStrategy get migration => MigrationStrategy( ··· 40 52 } 41 53 if (from < 7) { 42 54 await migrator.addColumn(savedPosts, savedPosts.saveType); 55 + } 56 + if (from < 8) { 57 + await migrator.addColumn(cachedPosts, cachedPosts.accountDid); 58 + } 59 + if (from < 9) { 60 + await migrator.createTable(labelerCache); 43 61 } 44 62 }, 45 63 ); ··· 285 303 (posts) => {for (final p in posts) p.postUri: p.saveType}, 286 304 ); 287 305 } 306 + 307 + Future<LabelerCacheEntry?> getLabelerCache(String labelerDid) => 308 + (select(labelerCache)..where((l) => l.labelerDid.equals(labelerDid))).getSingleOrNull(); 309 + 310 + Future<List<LabelerCacheEntry>> getAllLabelerCache() => select(labelerCache).get(); 311 + 312 + Future<int> upsertLabelerCache(String labelerDid, String policiesJson) => into(labelerCache).insert( 313 + LabelerCacheCompanion( 314 + labelerDid: Value(labelerDid), 315 + policiesJson: Value(policiesJson), 316 + fetchedAt: Value(DateTime.now()), 317 + ), 318 + mode: InsertMode.replace, 319 + ); 320 + 321 + Future<int> deleteLabelerCache(String labelerDid) => 322 + (delete(labelerCache)..where((l) => l.labelerDid.equals(labelerDid))).go(); 288 323 }
+418 -2
lib/core/database/app_database.g.dart
··· 894 894 type: DriftSqlType.string, 895 895 requiredDuringInsert: true, 896 896 ); 897 + static const VerificationMeta _accountDidMeta = const VerificationMeta('accountDid'); 898 + @override 899 + late final GeneratedColumn<String> accountDid = GeneratedColumn<String>( 900 + 'account_did', 901 + aliasedName, 902 + true, 903 + type: DriftSqlType.string, 904 + requiredDuringInsert: false, 905 + ); 897 906 static const VerificationMeta _payloadMeta = const VerificationMeta('payload'); 898 907 @override 899 908 late final GeneratedColumn<String> payload = GeneratedColumn<String>( ··· 923 932 defaultValue: currentDateAndTime, 924 933 ); 925 934 @override 926 - List<GeneratedColumn> get $columns => [uri, authorDid, payload, createdAt, fetchedAt]; 935 + List<GeneratedColumn> get $columns => [uri, authorDid, accountDid, payload, createdAt, fetchedAt]; 927 936 @override 928 937 String get aliasedName => _alias ?? actualTableName; 929 938 @override ··· 943 952 } else if (isInserting) { 944 953 context.missing(_authorDidMeta); 945 954 } 955 + if (data.containsKey('account_did')) { 956 + context.handle(_accountDidMeta, accountDid.isAcceptableOrUnknown(data['account_did']!, _accountDidMeta)); 957 + } 946 958 if (data.containsKey('payload')) { 947 959 context.handle(_payloadMeta, payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta)); 948 960 } else if (isInserting) { ··· 965 977 return CachedPost( 966 978 uri: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}uri'])!, 967 979 authorDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}author_did'])!, 980 + accountDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}account_did']), 968 981 payload: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}payload'])!, 969 982 createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at']), 970 983 fetchedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}fetched_at'])!, ··· 980 993 class CachedPost extends DataClass implements Insertable<CachedPost> { 981 994 final String uri; 982 995 final String authorDid; 996 + final String? accountDid; 983 997 final String payload; 984 998 final DateTime? createdAt; 985 999 final DateTime fetchedAt; 986 1000 const CachedPost({ 987 1001 required this.uri, 988 1002 required this.authorDid, 1003 + this.accountDid, 989 1004 required this.payload, 990 1005 this.createdAt, 991 1006 required this.fetchedAt, ··· 995 1010 final map = <String, Expression>{}; 996 1011 map['uri'] = Variable<String>(uri); 997 1012 map['author_did'] = Variable<String>(authorDid); 1013 + if (!nullToAbsent || accountDid != null) { 1014 + map['account_did'] = Variable<String>(accountDid); 1015 + } 998 1016 map['payload'] = Variable<String>(payload); 999 1017 if (!nullToAbsent || createdAt != null) { 1000 1018 map['created_at'] = Variable<DateTime>(createdAt); ··· 1007 1025 return CachedPostsCompanion( 1008 1026 uri: Value(uri), 1009 1027 authorDid: Value(authorDid), 1028 + accountDid: accountDid == null && nullToAbsent ? const Value.absent() : Value(accountDid), 1010 1029 payload: Value(payload), 1011 1030 createdAt: createdAt == null && nullToAbsent ? const Value.absent() : Value(createdAt), 1012 1031 fetchedAt: Value(fetchedAt), ··· 1018 1037 return CachedPost( 1019 1038 uri: serializer.fromJson<String>(json['uri']), 1020 1039 authorDid: serializer.fromJson<String>(json['authorDid']), 1040 + accountDid: serializer.fromJson<String?>(json['accountDid']), 1021 1041 payload: serializer.fromJson<String>(json['payload']), 1022 1042 createdAt: serializer.fromJson<DateTime?>(json['createdAt']), 1023 1043 fetchedAt: serializer.fromJson<DateTime>(json['fetchedAt']), ··· 1029 1049 return <String, dynamic>{ 1030 1050 'uri': serializer.toJson<String>(uri), 1031 1051 'authorDid': serializer.toJson<String>(authorDid), 1052 + 'accountDid': serializer.toJson<String?>(accountDid), 1032 1053 'payload': serializer.toJson<String>(payload), 1033 1054 'createdAt': serializer.toJson<DateTime?>(createdAt), 1034 1055 'fetchedAt': serializer.toJson<DateTime>(fetchedAt), ··· 1038 1059 CachedPost copyWith({ 1039 1060 String? uri, 1040 1061 String? authorDid, 1062 + Value<String?> accountDid = const Value.absent(), 1041 1063 String? payload, 1042 1064 Value<DateTime?> createdAt = const Value.absent(), 1043 1065 DateTime? fetchedAt, 1044 1066 }) => CachedPost( 1045 1067 uri: uri ?? this.uri, 1046 1068 authorDid: authorDid ?? this.authorDid, 1069 + accountDid: accountDid.present ? accountDid.value : this.accountDid, 1047 1070 payload: payload ?? this.payload, 1048 1071 createdAt: createdAt.present ? createdAt.value : this.createdAt, 1049 1072 fetchedAt: fetchedAt ?? this.fetchedAt, ··· 1052 1075 return CachedPost( 1053 1076 uri: data.uri.present ? data.uri.value : this.uri, 1054 1077 authorDid: data.authorDid.present ? data.authorDid.value : this.authorDid, 1078 + accountDid: data.accountDid.present ? data.accountDid.value : this.accountDid, 1055 1079 payload: data.payload.present ? data.payload.value : this.payload, 1056 1080 createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, 1057 1081 fetchedAt: data.fetchedAt.present ? data.fetchedAt.value : this.fetchedAt, ··· 1063 1087 return (StringBuffer('CachedPost(') 1064 1088 ..write('uri: $uri, ') 1065 1089 ..write('authorDid: $authorDid, ') 1090 + ..write('accountDid: $accountDid, ') 1066 1091 ..write('payload: $payload, ') 1067 1092 ..write('createdAt: $createdAt, ') 1068 1093 ..write('fetchedAt: $fetchedAt') ··· 1071 1096 } 1072 1097 1073 1098 @override 1074 - int get hashCode => Object.hash(uri, authorDid, payload, createdAt, fetchedAt); 1099 + int get hashCode => Object.hash(uri, authorDid, accountDid, payload, createdAt, fetchedAt); 1075 1100 @override 1076 1101 bool operator ==(Object other) => 1077 1102 identical(this, other) || 1078 1103 (other is CachedPost && 1079 1104 other.uri == this.uri && 1080 1105 other.authorDid == this.authorDid && 1106 + other.accountDid == this.accountDid && 1081 1107 other.payload == this.payload && 1082 1108 other.createdAt == this.createdAt && 1083 1109 other.fetchedAt == this.fetchedAt); ··· 1086 1112 class CachedPostsCompanion extends UpdateCompanion<CachedPost> { 1087 1113 final Value<String> uri; 1088 1114 final Value<String> authorDid; 1115 + final Value<String?> accountDid; 1089 1116 final Value<String> payload; 1090 1117 final Value<DateTime?> createdAt; 1091 1118 final Value<DateTime> fetchedAt; ··· 1093 1120 const CachedPostsCompanion({ 1094 1121 this.uri = const Value.absent(), 1095 1122 this.authorDid = const Value.absent(), 1123 + this.accountDid = const Value.absent(), 1096 1124 this.payload = const Value.absent(), 1097 1125 this.createdAt = const Value.absent(), 1098 1126 this.fetchedAt = const Value.absent(), ··· 1101 1129 CachedPostsCompanion.insert({ 1102 1130 required String uri, 1103 1131 required String authorDid, 1132 + this.accountDid = const Value.absent(), 1104 1133 required String payload, 1105 1134 this.createdAt = const Value.absent(), 1106 1135 this.fetchedAt = const Value.absent(), ··· 1111 1140 static Insertable<CachedPost> custom({ 1112 1141 Expression<String>? uri, 1113 1142 Expression<String>? authorDid, 1143 + Expression<String>? accountDid, 1114 1144 Expression<String>? payload, 1115 1145 Expression<DateTime>? createdAt, 1116 1146 Expression<DateTime>? fetchedAt, ··· 1119 1149 return RawValuesInsertable({ 1120 1150 if (uri != null) 'uri': uri, 1121 1151 if (authorDid != null) 'author_did': authorDid, 1152 + if (accountDid != null) 'account_did': accountDid, 1122 1153 if (payload != null) 'payload': payload, 1123 1154 if (createdAt != null) 'created_at': createdAt, 1124 1155 if (fetchedAt != null) 'fetched_at': fetchedAt, ··· 1129 1160 CachedPostsCompanion copyWith({ 1130 1161 Value<String>? uri, 1131 1162 Value<String>? authorDid, 1163 + Value<String?>? accountDid, 1132 1164 Value<String>? payload, 1133 1165 Value<DateTime?>? createdAt, 1134 1166 Value<DateTime>? fetchedAt, ··· 1137 1169 return CachedPostsCompanion( 1138 1170 uri: uri ?? this.uri, 1139 1171 authorDid: authorDid ?? this.authorDid, 1172 + accountDid: accountDid ?? this.accountDid, 1140 1173 payload: payload ?? this.payload, 1141 1174 createdAt: createdAt ?? this.createdAt, 1142 1175 fetchedAt: fetchedAt ?? this.fetchedAt, ··· 1152 1185 } 1153 1186 if (authorDid.present) { 1154 1187 map['author_did'] = Variable<String>(authorDid.value); 1188 + } 1189 + if (accountDid.present) { 1190 + map['account_did'] = Variable<String>(accountDid.value); 1155 1191 } 1156 1192 if (payload.present) { 1157 1193 map['payload'] = Variable<String>(payload.value); ··· 1173 1209 return (StringBuffer('CachedPostsCompanion(') 1174 1210 ..write('uri: $uri, ') 1175 1211 ..write('authorDid: $authorDid, ') 1212 + ..write('accountDid: $accountDid, ') 1176 1213 ..write('payload: $payload, ') 1177 1214 ..write('createdAt: $createdAt, ') 1178 1215 ..write('fetchedAt: $fetchedAt, ') ··· 3002 3039 } 3003 3040 } 3004 3041 3042 + class $LabelerCacheTable extends LabelerCache with TableInfo<$LabelerCacheTable, LabelerCacheEntry> { 3043 + @override 3044 + final GeneratedDatabase attachedDatabase; 3045 + final String? _alias; 3046 + $LabelerCacheTable(this.attachedDatabase, [this._alias]); 3047 + static const VerificationMeta _labelerDidMeta = const VerificationMeta('labelerDid'); 3048 + @override 3049 + late final GeneratedColumn<String> labelerDid = GeneratedColumn<String>( 3050 + 'labeler_did', 3051 + aliasedName, 3052 + false, 3053 + type: DriftSqlType.string, 3054 + requiredDuringInsert: true, 3055 + ); 3056 + static const VerificationMeta _policiesJsonMeta = const VerificationMeta('policiesJson'); 3057 + @override 3058 + late final GeneratedColumn<String> policiesJson = GeneratedColumn<String>( 3059 + 'policies_json', 3060 + aliasedName, 3061 + false, 3062 + type: DriftSqlType.string, 3063 + requiredDuringInsert: true, 3064 + ); 3065 + static const VerificationMeta _fetchedAtMeta = const VerificationMeta('fetchedAt'); 3066 + @override 3067 + late final GeneratedColumn<DateTime> fetchedAt = GeneratedColumn<DateTime>( 3068 + 'fetched_at', 3069 + aliasedName, 3070 + false, 3071 + type: DriftSqlType.dateTime, 3072 + requiredDuringInsert: false, 3073 + defaultValue: currentDateAndTime, 3074 + ); 3075 + @override 3076 + List<GeneratedColumn> get $columns => [labelerDid, policiesJson, fetchedAt]; 3077 + @override 3078 + String get aliasedName => _alias ?? actualTableName; 3079 + @override 3080 + String get actualTableName => $name; 3081 + static const String $name = 'labeler_cache'; 3082 + @override 3083 + VerificationContext validateIntegrity(Insertable<LabelerCacheEntry> instance, {bool isInserting = false}) { 3084 + final context = VerificationContext(); 3085 + final data = instance.toColumns(true); 3086 + if (data.containsKey('labeler_did')) { 3087 + context.handle(_labelerDidMeta, labelerDid.isAcceptableOrUnknown(data['labeler_did']!, _labelerDidMeta)); 3088 + } else if (isInserting) { 3089 + context.missing(_labelerDidMeta); 3090 + } 3091 + if (data.containsKey('policies_json')) { 3092 + context.handle(_policiesJsonMeta, policiesJson.isAcceptableOrUnknown(data['policies_json']!, _policiesJsonMeta)); 3093 + } else if (isInserting) { 3094 + context.missing(_policiesJsonMeta); 3095 + } 3096 + if (data.containsKey('fetched_at')) { 3097 + context.handle(_fetchedAtMeta, fetchedAt.isAcceptableOrUnknown(data['fetched_at']!, _fetchedAtMeta)); 3098 + } 3099 + return context; 3100 + } 3101 + 3102 + @override 3103 + Set<GeneratedColumn> get $primaryKey => {labelerDid}; 3104 + @override 3105 + LabelerCacheEntry map(Map<String, dynamic> data, {String? tablePrefix}) { 3106 + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; 3107 + return LabelerCacheEntry( 3108 + labelerDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}labeler_did'])!, 3109 + policiesJson: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}policies_json'])!, 3110 + fetchedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}fetched_at'])!, 3111 + ); 3112 + } 3113 + 3114 + @override 3115 + $LabelerCacheTable createAlias(String alias) { 3116 + return $LabelerCacheTable(attachedDatabase, alias); 3117 + } 3118 + } 3119 + 3120 + class LabelerCacheEntry extends DataClass implements Insertable<LabelerCacheEntry> { 3121 + final String labelerDid; 3122 + final String policiesJson; 3123 + final DateTime fetchedAt; 3124 + const LabelerCacheEntry({required this.labelerDid, required this.policiesJson, required this.fetchedAt}); 3125 + @override 3126 + Map<String, Expression> toColumns(bool nullToAbsent) { 3127 + final map = <String, Expression>{}; 3128 + map['labeler_did'] = Variable<String>(labelerDid); 3129 + map['policies_json'] = Variable<String>(policiesJson); 3130 + map['fetched_at'] = Variable<DateTime>(fetchedAt); 3131 + return map; 3132 + } 3133 + 3134 + LabelerCacheCompanion toCompanion(bool nullToAbsent) { 3135 + return LabelerCacheCompanion( 3136 + labelerDid: Value(labelerDid), 3137 + policiesJson: Value(policiesJson), 3138 + fetchedAt: Value(fetchedAt), 3139 + ); 3140 + } 3141 + 3142 + factory LabelerCacheEntry.fromJson(Map<String, dynamic> json, {ValueSerializer? serializer}) { 3143 + serializer ??= driftRuntimeOptions.defaultSerializer; 3144 + return LabelerCacheEntry( 3145 + labelerDid: serializer.fromJson<String>(json['labelerDid']), 3146 + policiesJson: serializer.fromJson<String>(json['policiesJson']), 3147 + fetchedAt: serializer.fromJson<DateTime>(json['fetchedAt']), 3148 + ); 3149 + } 3150 + @override 3151 + Map<String, dynamic> toJson({ValueSerializer? serializer}) { 3152 + serializer ??= driftRuntimeOptions.defaultSerializer; 3153 + return <String, dynamic>{ 3154 + 'labelerDid': serializer.toJson<String>(labelerDid), 3155 + 'policiesJson': serializer.toJson<String>(policiesJson), 3156 + 'fetchedAt': serializer.toJson<DateTime>(fetchedAt), 3157 + }; 3158 + } 3159 + 3160 + LabelerCacheEntry copyWith({String? labelerDid, String? policiesJson, DateTime? fetchedAt}) => LabelerCacheEntry( 3161 + labelerDid: labelerDid ?? this.labelerDid, 3162 + policiesJson: policiesJson ?? this.policiesJson, 3163 + fetchedAt: fetchedAt ?? this.fetchedAt, 3164 + ); 3165 + LabelerCacheEntry copyWithCompanion(LabelerCacheCompanion data) { 3166 + return LabelerCacheEntry( 3167 + labelerDid: data.labelerDid.present ? data.labelerDid.value : this.labelerDid, 3168 + policiesJson: data.policiesJson.present ? data.policiesJson.value : this.policiesJson, 3169 + fetchedAt: data.fetchedAt.present ? data.fetchedAt.value : this.fetchedAt, 3170 + ); 3171 + } 3172 + 3173 + @override 3174 + String toString() { 3175 + return (StringBuffer('LabelerCacheEntry(') 3176 + ..write('labelerDid: $labelerDid, ') 3177 + ..write('policiesJson: $policiesJson, ') 3178 + ..write('fetchedAt: $fetchedAt') 3179 + ..write(')')) 3180 + .toString(); 3181 + } 3182 + 3183 + @override 3184 + int get hashCode => Object.hash(labelerDid, policiesJson, fetchedAt); 3185 + @override 3186 + bool operator ==(Object other) => 3187 + identical(this, other) || 3188 + (other is LabelerCacheEntry && 3189 + other.labelerDid == this.labelerDid && 3190 + other.policiesJson == this.policiesJson && 3191 + other.fetchedAt == this.fetchedAt); 3192 + } 3193 + 3194 + class LabelerCacheCompanion extends UpdateCompanion<LabelerCacheEntry> { 3195 + final Value<String> labelerDid; 3196 + final Value<String> policiesJson; 3197 + final Value<DateTime> fetchedAt; 3198 + final Value<int> rowid; 3199 + const LabelerCacheCompanion({ 3200 + this.labelerDid = const Value.absent(), 3201 + this.policiesJson = const Value.absent(), 3202 + this.fetchedAt = const Value.absent(), 3203 + this.rowid = const Value.absent(), 3204 + }); 3205 + LabelerCacheCompanion.insert({ 3206 + required String labelerDid, 3207 + required String policiesJson, 3208 + this.fetchedAt = const Value.absent(), 3209 + this.rowid = const Value.absent(), 3210 + }) : labelerDid = Value(labelerDid), 3211 + policiesJson = Value(policiesJson); 3212 + static Insertable<LabelerCacheEntry> custom({ 3213 + Expression<String>? labelerDid, 3214 + Expression<String>? policiesJson, 3215 + Expression<DateTime>? fetchedAt, 3216 + Expression<int>? rowid, 3217 + }) { 3218 + return RawValuesInsertable({ 3219 + if (labelerDid != null) 'labeler_did': labelerDid, 3220 + if (policiesJson != null) 'policies_json': policiesJson, 3221 + if (fetchedAt != null) 'fetched_at': fetchedAt, 3222 + if (rowid != null) 'rowid': rowid, 3223 + }); 3224 + } 3225 + 3226 + LabelerCacheCompanion copyWith({ 3227 + Value<String>? labelerDid, 3228 + Value<String>? policiesJson, 3229 + Value<DateTime>? fetchedAt, 3230 + Value<int>? rowid, 3231 + }) { 3232 + return LabelerCacheCompanion( 3233 + labelerDid: labelerDid ?? this.labelerDid, 3234 + policiesJson: policiesJson ?? this.policiesJson, 3235 + fetchedAt: fetchedAt ?? this.fetchedAt, 3236 + rowid: rowid ?? this.rowid, 3237 + ); 3238 + } 3239 + 3240 + @override 3241 + Map<String, Expression> toColumns(bool nullToAbsent) { 3242 + final map = <String, Expression>{}; 3243 + if (labelerDid.present) { 3244 + map['labeler_did'] = Variable<String>(labelerDid.value); 3245 + } 3246 + if (policiesJson.present) { 3247 + map['policies_json'] = Variable<String>(policiesJson.value); 3248 + } 3249 + if (fetchedAt.present) { 3250 + map['fetched_at'] = Variable<DateTime>(fetchedAt.value); 3251 + } 3252 + if (rowid.present) { 3253 + map['rowid'] = Variable<int>(rowid.value); 3254 + } 3255 + return map; 3256 + } 3257 + 3258 + @override 3259 + String toString() { 3260 + return (StringBuffer('LabelerCacheCompanion(') 3261 + ..write('labelerDid: $labelerDid, ') 3262 + ..write('policiesJson: $policiesJson, ') 3263 + ..write('fetchedAt: $fetchedAt, ') 3264 + ..write('rowid: $rowid') 3265 + ..write(')')) 3266 + .toString(); 3267 + } 3268 + } 3269 + 3005 3270 abstract class _$AppDatabase extends GeneratedDatabase { 3006 3271 _$AppDatabase(QueryExecutor e) : super(e); 3007 3272 $AppDatabaseManager get managers => $AppDatabaseManager(this); ··· 3013 3278 late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this); 3014 3279 late final $DraftsTable drafts = $DraftsTable(this); 3015 3280 late final $SavedPostsTable savedPosts = $SavedPostsTable(this); 3281 + late final $LabelerCacheTable labelerCache = $LabelerCacheTable(this); 3016 3282 @override 3017 3283 Iterable<TableInfo<Table, Object?>> get allTables => allSchemaEntities.whereType<TableInfo<Table, Object?>>(); 3018 3284 @override ··· 3025 3291 searchHistory, 3026 3292 drafts, 3027 3293 savedPosts, 3294 + labelerCache, 3028 3295 ]; 3029 3296 } 3030 3297 ··· 3441 3708 CachedPostsCompanion Function({ 3442 3709 required String uri, 3443 3710 required String authorDid, 3711 + Value<String?> accountDid, 3444 3712 required String payload, 3445 3713 Value<DateTime?> createdAt, 3446 3714 Value<DateTime> fetchedAt, ··· 3450 3718 CachedPostsCompanion Function({ 3451 3719 Value<String> uri, 3452 3720 Value<String> authorDid, 3721 + Value<String?> accountDid, 3453 3722 Value<String> payload, 3454 3723 Value<DateTime?> createdAt, 3455 3724 Value<DateTime> fetchedAt, ··· 3469 3738 ColumnFilters<String> get authorDid => 3470 3739 $composableBuilder(column: $table.authorDid, builder: (column) => ColumnFilters(column)); 3471 3740 3741 + ColumnFilters<String> get accountDid => 3742 + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnFilters(column)); 3743 + 3472 3744 ColumnFilters<String> get payload => 3473 3745 $composableBuilder(column: $table.payload, builder: (column) => ColumnFilters(column)); 3474 3746 ··· 3493 3765 ColumnOrderings<String> get authorDid => 3494 3766 $composableBuilder(column: $table.authorDid, builder: (column) => ColumnOrderings(column)); 3495 3767 3768 + ColumnOrderings<String> get accountDid => 3769 + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnOrderings(column)); 3770 + 3496 3771 ColumnOrderings<String> get payload => 3497 3772 $composableBuilder(column: $table.payload, builder: (column) => ColumnOrderings(column)); 3498 3773 ··· 3514 3789 GeneratedColumn<String> get uri => $composableBuilder(column: $table.uri, builder: (column) => column); 3515 3790 3516 3791 GeneratedColumn<String> get authorDid => $composableBuilder(column: $table.authorDid, builder: (column) => column); 3792 + 3793 + GeneratedColumn<String> get accountDid => $composableBuilder(column: $table.accountDid, builder: (column) => column); 3517 3794 3518 3795 GeneratedColumn<String> get payload => $composableBuilder(column: $table.payload, builder: (column) => column); 3519 3796 ··· 3549 3826 ({ 3550 3827 Value<String> uri = const Value.absent(), 3551 3828 Value<String> authorDid = const Value.absent(), 3829 + Value<String?> accountDid = const Value.absent(), 3552 3830 Value<String> payload = const Value.absent(), 3553 3831 Value<DateTime?> createdAt = const Value.absent(), 3554 3832 Value<DateTime> fetchedAt = const Value.absent(), ··· 3556 3834 }) => CachedPostsCompanion( 3557 3835 uri: uri, 3558 3836 authorDid: authorDid, 3837 + accountDid: accountDid, 3559 3838 payload: payload, 3560 3839 createdAt: createdAt, 3561 3840 fetchedAt: fetchedAt, ··· 3565 3844 ({ 3566 3845 required String uri, 3567 3846 required String authorDid, 3847 + Value<String?> accountDid = const Value.absent(), 3568 3848 required String payload, 3569 3849 Value<DateTime?> createdAt = const Value.absent(), 3570 3850 Value<DateTime> fetchedAt = const Value.absent(), ··· 3572 3852 }) => CachedPostsCompanion.insert( 3573 3853 uri: uri, 3574 3854 authorDid: authorDid, 3855 + accountDid: accountDid, 3575 3856 payload: payload, 3576 3857 createdAt: createdAt, 3577 3858 fetchedAt: fetchedAt, ··· 4472 4753 SavedPostEntry, 4473 4754 PrefetchHooks Function() 4474 4755 >; 4756 + typedef $$LabelerCacheTableCreateCompanionBuilder = 4757 + LabelerCacheCompanion Function({ 4758 + required String labelerDid, 4759 + required String policiesJson, 4760 + Value<DateTime> fetchedAt, 4761 + Value<int> rowid, 4762 + }); 4763 + typedef $$LabelerCacheTableUpdateCompanionBuilder = 4764 + LabelerCacheCompanion Function({ 4765 + Value<String> labelerDid, 4766 + Value<String> policiesJson, 4767 + Value<DateTime> fetchedAt, 4768 + Value<int> rowid, 4769 + }); 4770 + 4771 + class $$LabelerCacheTableFilterComposer extends Composer<_$AppDatabase, $LabelerCacheTable> { 4772 + $$LabelerCacheTableFilterComposer({ 4773 + required super.$db, 4774 + required super.$table, 4775 + super.joinBuilder, 4776 + super.$addJoinBuilderToRootComposer, 4777 + super.$removeJoinBuilderFromRootComposer, 4778 + }); 4779 + ColumnFilters<String> get labelerDid => 4780 + $composableBuilder(column: $table.labelerDid, builder: (column) => ColumnFilters(column)); 4781 + 4782 + ColumnFilters<String> get policiesJson => 4783 + $composableBuilder(column: $table.policiesJson, builder: (column) => ColumnFilters(column)); 4784 + 4785 + ColumnFilters<DateTime> get fetchedAt => 4786 + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnFilters(column)); 4787 + } 4788 + 4789 + class $$LabelerCacheTableOrderingComposer extends Composer<_$AppDatabase, $LabelerCacheTable> { 4790 + $$LabelerCacheTableOrderingComposer({ 4791 + required super.$db, 4792 + required super.$table, 4793 + super.joinBuilder, 4794 + super.$addJoinBuilderToRootComposer, 4795 + super.$removeJoinBuilderFromRootComposer, 4796 + }); 4797 + ColumnOrderings<String> get labelerDid => 4798 + $composableBuilder(column: $table.labelerDid, builder: (column) => ColumnOrderings(column)); 4799 + 4800 + ColumnOrderings<String> get policiesJson => 4801 + $composableBuilder(column: $table.policiesJson, builder: (column) => ColumnOrderings(column)); 4802 + 4803 + ColumnOrderings<DateTime> get fetchedAt => 4804 + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnOrderings(column)); 4805 + } 4806 + 4807 + class $$LabelerCacheTableAnnotationComposer extends Composer<_$AppDatabase, $LabelerCacheTable> { 4808 + $$LabelerCacheTableAnnotationComposer({ 4809 + required super.$db, 4810 + required super.$table, 4811 + super.joinBuilder, 4812 + super.$addJoinBuilderToRootComposer, 4813 + super.$removeJoinBuilderFromRootComposer, 4814 + }); 4815 + GeneratedColumn<String> get labelerDid => $composableBuilder(column: $table.labelerDid, builder: (column) => column); 4816 + 4817 + GeneratedColumn<String> get policiesJson => 4818 + $composableBuilder(column: $table.policiesJson, builder: (column) => column); 4819 + 4820 + GeneratedColumn<DateTime> get fetchedAt => $composableBuilder(column: $table.fetchedAt, builder: (column) => column); 4821 + } 4822 + 4823 + class $$LabelerCacheTableTableManager 4824 + extends 4825 + RootTableManager< 4826 + _$AppDatabase, 4827 + $LabelerCacheTable, 4828 + LabelerCacheEntry, 4829 + $$LabelerCacheTableFilterComposer, 4830 + $$LabelerCacheTableOrderingComposer, 4831 + $$LabelerCacheTableAnnotationComposer, 4832 + $$LabelerCacheTableCreateCompanionBuilder, 4833 + $$LabelerCacheTableUpdateCompanionBuilder, 4834 + (LabelerCacheEntry, BaseReferences<_$AppDatabase, $LabelerCacheTable, LabelerCacheEntry>), 4835 + LabelerCacheEntry, 4836 + PrefetchHooks Function() 4837 + > { 4838 + $$LabelerCacheTableTableManager(_$AppDatabase db, $LabelerCacheTable table) 4839 + : super( 4840 + TableManagerState( 4841 + db: db, 4842 + table: table, 4843 + createFilteringComposer: () => $$LabelerCacheTableFilterComposer($db: db, $table: table), 4844 + createOrderingComposer: () => $$LabelerCacheTableOrderingComposer($db: db, $table: table), 4845 + createComputedFieldComposer: () => $$LabelerCacheTableAnnotationComposer($db: db, $table: table), 4846 + updateCompanionCallback: 4847 + ({ 4848 + Value<String> labelerDid = const Value.absent(), 4849 + Value<String> policiesJson = const Value.absent(), 4850 + Value<DateTime> fetchedAt = const Value.absent(), 4851 + Value<int> rowid = const Value.absent(), 4852 + }) => LabelerCacheCompanion( 4853 + labelerDid: labelerDid, 4854 + policiesJson: policiesJson, 4855 + fetchedAt: fetchedAt, 4856 + rowid: rowid, 4857 + ), 4858 + createCompanionCallback: 4859 + ({ 4860 + required String labelerDid, 4861 + required String policiesJson, 4862 + Value<DateTime> fetchedAt = const Value.absent(), 4863 + Value<int> rowid = const Value.absent(), 4864 + }) => LabelerCacheCompanion.insert( 4865 + labelerDid: labelerDid, 4866 + policiesJson: policiesJson, 4867 + fetchedAt: fetchedAt, 4868 + rowid: rowid, 4869 + ), 4870 + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), 4871 + prefetchHooksCallback: null, 4872 + ), 4873 + ); 4874 + } 4875 + 4876 + typedef $$LabelerCacheTableProcessedTableManager = 4877 + ProcessedTableManager< 4878 + _$AppDatabase, 4879 + $LabelerCacheTable, 4880 + LabelerCacheEntry, 4881 + $$LabelerCacheTableFilterComposer, 4882 + $$LabelerCacheTableOrderingComposer, 4883 + $$LabelerCacheTableAnnotationComposer, 4884 + $$LabelerCacheTableCreateCompanionBuilder, 4885 + $$LabelerCacheTableUpdateCompanionBuilder, 4886 + (LabelerCacheEntry, BaseReferences<_$AppDatabase, $LabelerCacheTable, LabelerCacheEntry>), 4887 + LabelerCacheEntry, 4888 + PrefetchHooks Function() 4889 + >; 4475 4890 4476 4891 class $AppDatabaseManager { 4477 4892 final _$AppDatabase _db; ··· 4484 4899 $$SearchHistoryTableTableManager get searchHistory => $$SearchHistoryTableTableManager(_db, _db.searchHistory); 4485 4900 $$DraftsTableTableManager get drafts => $$DraftsTableTableManager(_db, _db.drafts); 4486 4901 $$SavedPostsTableTableManager get savedPosts => $$SavedPostsTableTableManager(_db, _db.savedPosts); 4902 + $$LabelerCacheTableTableManager get labelerCache => $$LabelerCacheTableTableManager(_db, _db.labelerCache); 4487 4903 }
+11
lib/core/database/tables.dart
··· 34 34 class CachedPosts extends Table { 35 35 TextColumn get uri => text()(); 36 36 TextColumn get authorDid => text()(); 37 + TextColumn get accountDid => text().nullable()(); 37 38 TextColumn get payload => text()(); 38 39 DateTimeColumn get createdAt => dateTime().nullable()(); 39 40 DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); ··· 103 104 @override 104 105 List<String> get customConstraints => ['UNIQUE (account_did, post_uri)']; 105 106 } 107 + 108 + @DataClassName('LabelerCacheEntry') 109 + class LabelerCache extends Table { 110 + TextColumn get labelerDid => text()(); 111 + TextColumn get policiesJson => text()(); 112 + DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); 113 + 114 + @override 115 + Set<Column> get primaryKey => {labelerDid}; 116 + }
+63
lib/features/account/cubit/account_switcher_cubit.dart
··· 1 + import 'package:drift/drift.dart'; 2 + import 'package:equatable/equatable.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/core/database/app_database.dart'; 5 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 6 + 7 + part 'account_switcher_state.dart'; 8 + 9 + class AccountSwitcherCubit extends Cubit<AccountSwitcherState> { 10 + AccountSwitcherCubit({required AppDatabase database}) 11 + : _database = database, 12 + super(const AccountSwitcherState.initial()); 13 + 14 + final AppDatabase _database; 15 + 16 + static const String _keyActiveAccountDid = 'active_account_did'; 17 + 18 + Future<void> loadAccounts() async { 19 + emit(const AccountSwitcherState.loading()); 20 + 21 + try { 22 + final accounts = await _database.getAllAccounts(); 23 + final savedDid = await _database.getSetting(_keyActiveAccountDid); 24 + 25 + String? activeDid; 26 + if (savedDid != null && accounts.any((a) => a.did == savedDid)) { 27 + activeDid = savedDid; 28 + } else if (accounts.isNotEmpty) { 29 + activeDid = accounts.first.did; 30 + } 31 + 32 + emit(AccountSwitcherState.ready(accounts: accounts, activeDid: activeDid)); 33 + } catch (error) { 34 + emit(const AccountSwitcherState.ready(accounts: [])); 35 + } 36 + } 37 + 38 + Future<void> switchAccount(String did) async { 39 + if (state.status != AccountSwitcherStatus.ready) return; 40 + 41 + await _database.setSetting(_keyActiveAccountDid, did); 42 + emit(state.copyWith(activeDid: did)); 43 + } 44 + 45 + Future<void> addAccountCompleted(AuthTokens tokens) async { 46 + await _database.insertAccount( 47 + AccountsCompanion( 48 + did: Value(tokens.did), 49 + handle: Value(tokens.handle), 50 + displayName: tokens.displayName != null ? Value(tokens.displayName!) : const Value.absent(), 51 + service: tokens.service != null ? Value(tokens.service!) : const Value.absent(), 52 + accessToken: Value(tokens.accessToken), 53 + refreshToken: tokens.refreshToken != null ? Value(tokens.refreshToken!) : const Value.absent(), 54 + dpopPublicKey: tokens.dpopPublicKey != null ? Value(tokens.dpopPublicKey!) : const Value.absent(), 55 + dpopNonce: tokens.dpopNonce != null ? Value(tokens.dpopNonce!) : const Value.absent(), 56 + expiresAt: tokens.expiresAt != null ? Value(tokens.expiresAt!) : const Value.absent(), 57 + ), 58 + ); 59 + 60 + await loadAccounts(); 61 + await switchAccount(tokens.did); 62 + } 63 + }
+31
lib/features/account/cubit/account_switcher_state.dart
··· 1 + part of 'account_switcher_cubit.dart'; 2 + 3 + enum AccountSwitcherStatus { initial, loading, ready } 4 + 5 + class AccountSwitcherState extends Equatable { 6 + const AccountSwitcherState._({required this.status, this.accounts = const [], this.activeDid}); 7 + 8 + const AccountSwitcherState.initial() : this._(status: AccountSwitcherStatus.initial); 9 + 10 + const AccountSwitcherState.loading() : this._(status: AccountSwitcherStatus.loading); 11 + 12 + const AccountSwitcherState.ready({required List<Account> accounts, String? activeDid}) 13 + : this._(status: AccountSwitcherStatus.ready, accounts: accounts, activeDid: activeDid); 14 + 15 + final AccountSwitcherStatus status; 16 + final List<Account> accounts; 17 + final String? activeDid; 18 + 19 + Account? get activeAccount => accounts.where((a) => a.did == activeDid).firstOrNull; 20 + 21 + AccountSwitcherState copyWith({AccountSwitcherStatus? status, List<Account>? accounts, String? activeDid}) { 22 + return AccountSwitcherState._( 23 + status: status ?? this.status, 24 + accounts: accounts ?? this.accounts, 25 + activeDid: activeDid ?? this.activeDid, 26 + ); 27 + } 28 + 29 + @override 30 + List<Object?> get props => [status, accounts, activeDid]; 31 + }
+16 -2
lib/features/compose/bloc/compose_bloc.dart
··· 84 84 ..add(MediaAttachment(localPath: event.path, width: event.width, height: event.height)); 85 85 final isEmpty = state.text.trim().isEmpty && attachments.isEmpty; 86 86 87 - emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty, isDraftDirty: true)); 87 + emit( 88 + state.copyWith( 89 + mediaAttachments: attachments, 90 + isEmpty: isEmpty, 91 + canSubmit: !state.isOverLimit && !isEmpty, 92 + isDraftDirty: true, 93 + ), 94 + ); 88 95 } 89 96 90 97 Future<void> _onMediaRemoved(MediaRemoved event, Emitter<ComposeState> emit) async { ··· 93 100 final attachments = List<MediaAttachment>.from(state.mediaAttachments)..removeAt(event.index); 94 101 final isEmpty = state.text.trim().isEmpty && attachments.isEmpty && state.videoAttachment == null; 95 102 96 - emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty, isDraftDirty: true)); 103 + emit( 104 + state.copyWith( 105 + mediaAttachments: attachments, 106 + isEmpty: isEmpty, 107 + canSubmit: !state.isOverLimit && !isEmpty, 108 + isDraftDirty: true, 109 + ), 110 + ); 97 111 } 98 112 99 113 Future<void> _onAltTextUpdated(AltTextUpdated event, Emitter<ComposeState> emit) async {
+4 -1
lib/features/compose/presentation/compose_screen.dart
··· 324 324 Padding( 325 325 padding: const EdgeInsets.symmetric(vertical: 24), 326 326 child: Center( 327 - child: Text('No drafts saved', style: _theme.textTheme.bodyMedium?.copyWith(color: _theme.colorScheme.onSurfaceVariant)), 327 + child: Text( 328 + 'No drafts saved', 329 + style: _theme.textTheme.bodyMedium?.copyWith(color: _theme.colorScheme.onSurfaceVariant), 330 + ), 328 331 ), 329 332 ) 330 333 else
+38
lib/features/connectivity/cubit/connectivity_cubit.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:connectivity_plus/connectivity_plus.dart'; 4 + import 'package:equatable/equatable.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + 7 + part 'connectivity_state.dart'; 8 + 9 + class ConnectivityCubit extends Cubit<ConnectivityState> { 10 + ConnectivityCubit({Connectivity? connectivity}) 11 + : _connectivity = connectivity ?? Connectivity(), 12 + super(const ConnectivityState.online()) { 13 + _init(); 14 + } 15 + 16 + final Connectivity _connectivity; 17 + StreamSubscription<List<ConnectivityResult>>? _subscription; 18 + 19 + void _init() { 20 + _connectivity.checkConnectivity().then(_handleResults); 21 + _subscription = _connectivity.onConnectivityChanged.listen(_handleResults); 22 + } 23 + 24 + void _handleResults(List<ConnectivityResult> results) { 25 + final isOnline = results.any((r) => r != ConnectivityResult.none); 26 + if (isOnline) { 27 + emit(const ConnectivityState.online()); 28 + } else { 29 + emit(const ConnectivityState.offline()); 30 + } 31 + } 32 + 33 + @override 34 + Future<void> close() { 35 + _subscription?.cancel(); 36 + return super.close(); 37 + } 38 + }
+18
lib/features/connectivity/cubit/connectivity_state.dart
··· 1 + part of 'connectivity_cubit.dart'; 2 + 3 + enum ConnectivityStatus { online, offline } 4 + 5 + class ConnectivityState extends Equatable { 6 + const ConnectivityState({required this.isOnline}); 7 + 8 + const ConnectivityState.online() : this(isOnline: true); 9 + 10 + const ConnectivityState.offline() : this(isOnline: false); 11 + 12 + final bool isOnline; 13 + 14 + ConnectivityStatus get status => isOnline ? ConnectivityStatus.online : ConnectivityStatus.offline; 15 + 16 + @override 17 + List<Object?> get props => [isOnline]; 18 + }
+4 -5
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 58 58 Widget build(BuildContext context) { 59 59 return BlocListener<PostActionCubit, PostActionState>( 60 60 listenWhen: (previous, current) => 61 - (previous.error != current.error && current.error != null) || 62 - (!previous.isDeleted && current.isDeleted), 61 + (previous.error != current.error && current.error != null) || (!previous.isDeleted && current.isDeleted), 63 62 listener: (context, state) { 64 63 if (state.isDeleted) { 65 - ScaffoldMessenger.of(context).showSnackBar( 66 - const SnackBar(content: Text('Post deleted'), behavior: SnackBarBehavior.floating), 67 - ); 64 + ScaffoldMessenger.of( 65 + context, 66 + ).showSnackBar(const SnackBar(content: Text('Post deleted'), behavior: SnackBarBehavior.floating)); 68 67 onDeleted?.call(); 69 68 return; 70 69 }
+76
lib/features/messages/bloc/convo_list_bloc.dart
··· 1 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 + import 'package:equatable/equatable.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/core/logging/app_logger.dart'; 5 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 6 + 7 + part 'convo_list_event.dart'; 8 + part 'convo_list_state.dart'; 9 + 10 + class ConvoListBloc extends Bloc<ConvoListEvent, ConvoListState> { 11 + ConvoListBloc({required ConvoRepository convoRepository}) 12 + : _convoRepository = convoRepository, 13 + super(const ConvoListState.initial()) { 14 + on<ConvosRequested>(_onConvosRequested); 15 + on<ConvosRefreshed>(_onConvosRefreshed); 16 + on<ConvoMuted>(_onConvoMuted); 17 + on<ConvoUnmuted>(_onConvoUnmuted); 18 + } 19 + 20 + final ConvoRepository _convoRepository; 21 + 22 + Future<void> _onConvosRequested(ConvosRequested event, Emitter<ConvoListState> emit) async { 23 + emit(const ConvoListState.loading()); 24 + 25 + try { 26 + final result = await _convoRepository.listConvos(limit: event.limit); 27 + 28 + emit(ConvoListState.loaded(convos: result.convos, cursor: result.cursor, hasMore: result.cursor != null)); 29 + } catch (error) { 30 + emit(ConvoListState.error('Failed to load conversations: $error')); 31 + } 32 + } 33 + 34 + Future<void> _onConvosRefreshed(ConvosRefreshed event, Emitter<ConvoListState> emit) async { 35 + if (state.status != ConvoListStatus.loaded) { 36 + return; 37 + } 38 + 39 + emit(state.copyWith(isRefreshing: true)); 40 + 41 + try { 42 + final result = await _convoRepository.listConvos(limit: 20); 43 + 44 + emit( 45 + state.copyWith( 46 + convos: result.convos, 47 + cursor: result.cursor, 48 + hasMore: result.cursor != null, 49 + isRefreshing: false, 50 + ), 51 + ); 52 + } catch (error) { 53 + emit(state.copyWith(isRefreshing: false)); 54 + } 55 + } 56 + 57 + Future<void> _onConvoMuted(ConvoMuted event, Emitter<ConvoListState> emit) async { 58 + try { 59 + final updatedConvo = await _convoRepository.muteConvo(event.convoId); 60 + final updatedConvos = state.convos.map((c) => c.id == event.convoId ? updatedConvo : c).toList(); 61 + emit(state.copyWith(convos: updatedConvos)); 62 + } catch (error) { 63 + log.w('Failed to mute conversation ${event.convoId}: $error'); 64 + } 65 + } 66 + 67 + Future<void> _onConvoUnmuted(ConvoUnmuted event, Emitter<ConvoListState> emit) async { 68 + try { 69 + final updatedConvo = await _convoRepository.unmuteConvo(event.convoId); 70 + final updatedConvos = state.convos.map((c) => c.id == event.convoId ? updatedConvo : c).toList(); 71 + emit(state.copyWith(convos: updatedConvos)); 72 + } catch (error) { 73 + log.w('Failed to unmute conversation ${event.convoId}: $error'); 74 + } 75 + } 76 + }
+39
lib/features/messages/bloc/convo_list_event.dart
··· 1 + part of 'convo_list_bloc.dart'; 2 + 3 + sealed class ConvoListEvent extends Equatable { 4 + const ConvoListEvent(); 5 + 6 + @override 7 + List<Object?> get props => []; 8 + } 9 + 10 + class ConvosRequested extends ConvoListEvent { 11 + const ConvosRequested({this.limit = 20}); 12 + 13 + final int limit; 14 + 15 + @override 16 + List<Object?> get props => [limit]; 17 + } 18 + 19 + class ConvosRefreshed extends ConvoListEvent { 20 + const ConvosRefreshed(); 21 + } 22 + 23 + class ConvoMuted extends ConvoListEvent { 24 + const ConvoMuted({required this.convoId}); 25 + 26 + final String convoId; 27 + 28 + @override 29 + List<Object?> get props => [convoId]; 30 + } 31 + 32 + class ConvoUnmuted extends ConvoListEvent { 33 + const ConvoUnmuted({required this.convoId}); 34 + 35 + final String convoId; 36 + 37 + @override 38 + List<Object?> get props => [convoId]; 39 + }
+65
lib/features/messages/bloc/convo_list_state.dart
··· 1 + part of 'convo_list_bloc.dart'; 2 + 3 + enum ConvoListStatus { initial, loading, loaded, error } 4 + 5 + enum ConvoTab { primary, requests } 6 + 7 + class ConvoListState extends Equatable { 8 + const ConvoListState._({ 9 + required this.status, 10 + this.convos = const [], 11 + this.cursor, 12 + this.hasMore = false, 13 + this.isLoadingMore = false, 14 + this.isRefreshing = false, 15 + this.activeTab = ConvoTab.primary, 16 + this.errorMessage, 17 + }); 18 + 19 + const ConvoListState.initial() : this._(status: ConvoListStatus.initial); 20 + 21 + const ConvoListState.loading() : this._(status: ConvoListStatus.loading); 22 + 23 + const ConvoListState.loaded({ 24 + required List<ConvoView> convos, 25 + String? cursor, 26 + bool hasMore = false, 27 + ConvoTab activeTab = ConvoTab.primary, 28 + }) : this._(status: ConvoListStatus.loaded, convos: convos, cursor: cursor, hasMore: hasMore, activeTab: activeTab); 29 + 30 + const ConvoListState.error(String message) : this._(status: ConvoListStatus.error, errorMessage: message); 31 + 32 + final ConvoListStatus status; 33 + final List<ConvoView> convos; 34 + final String? cursor; 35 + final bool hasMore; 36 + final bool isLoadingMore; 37 + final bool isRefreshing; 38 + final ConvoTab activeTab; 39 + final String? errorMessage; 40 + 41 + ConvoListState copyWith({ 42 + ConvoListStatus? status, 43 + List<ConvoView>? convos, 44 + String? cursor, 45 + bool? hasMore, 46 + bool? isLoadingMore, 47 + bool? isRefreshing, 48 + ConvoTab? activeTab, 49 + String? errorMessage, 50 + }) { 51 + return ConvoListState._( 52 + status: status ?? this.status, 53 + convos: convos ?? this.convos, 54 + cursor: cursor ?? this.cursor, 55 + hasMore: hasMore ?? this.hasMore, 56 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 57 + isRefreshing: isRefreshing ?? this.isRefreshing, 58 + activeTab: activeTab ?? this.activeTab, 59 + errorMessage: errorMessage ?? this.errorMessage, 60 + ); 61 + } 62 + 63 + @override 64 + List<Object?> get props => [status, convos, cursor, hasMore, isLoadingMore, isRefreshing, activeTab, errorMessage]; 65 + }
+119
lib/features/messages/bloc/message_bloc.dart
··· 1 + import 'package:bluesky/chat_bsky_convo_getmessages.dart'; 2 + import 'package:equatable/equatable.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/core/logging/app_logger.dart'; 5 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 6 + 7 + part 'message_event.dart'; 8 + part 'message_state.dart'; 9 + 10 + class MessageBloc extends Bloc<MessageEvent, MessageState> { 11 + MessageBloc({required ConvoRepository convoRepository, required String currentUserDid}) 12 + : _convoRepository = convoRepository, 13 + _currentUserDid = currentUserDid, 14 + super(const MessageState.initial()) { 15 + on<MessagesRequested>(_onMessagesRequested); 16 + on<MessagesPageLoaded>(_onMessagesPageLoaded); 17 + on<MessageSent>(_onMessageSent); 18 + on<MessageDeleted>(_onMessageDeleted); 19 + on<ConvoMarkedRead>(_onConvoMarkedRead); 20 + } 21 + 22 + final ConvoRepository _convoRepository; 23 + // FIXME: use this 24 + final String _currentUserDid; 25 + 26 + Future<void> _onMessagesRequested(MessagesRequested event, Emitter<MessageState> emit) async { 27 + emit(const MessageState.loading()); 28 + 29 + try { 30 + final result = await _convoRepository.getMessages(event.convoId, limit: event.limit); 31 + 32 + emit( 33 + MessageState.loaded( 34 + messages: result.messages, 35 + cursor: result.cursor, 36 + hasMore: result.cursor != null, 37 + convoId: event.convoId, 38 + ), 39 + ); 40 + } catch (error) { 41 + emit(MessageState.error('Failed to load messages: $error')); 42 + } 43 + } 44 + 45 + Future<void> _onMessagesPageLoaded(MessagesPageLoaded event, Emitter<MessageState> emit) async { 46 + if (state.status != MessageStatus.loaded || state.cursor == null || state.isLoadingMore) { 47 + return; 48 + } 49 + 50 + emit(state.copyWith(isLoadingMore: true)); 51 + 52 + try { 53 + final result = await _convoRepository.getMessages(state.convoId!, cursor: state.cursor, limit: event.limit); 54 + 55 + emit( 56 + state.copyWith( 57 + messages: [...state.messages, ...result.messages], 58 + cursor: result.cursor, 59 + hasMore: result.cursor != null, 60 + isLoadingMore: false, 61 + ), 62 + ); 63 + } catch (error) { 64 + emit(state.copyWith(isLoadingMore: false, hasMore: false)); 65 + } 66 + } 67 + 68 + Future<void> _onMessageSent(MessageSent event, Emitter<MessageState> emit) async { 69 + if (state.status != MessageStatus.loaded || state.convoId == null) { 70 + return; 71 + } 72 + 73 + emit(state.copyWith(isSending: true)); 74 + 75 + try { 76 + final messageView = await _convoRepository.sendMessage(state.convoId!, event.text); 77 + final newMessage = UConvoGetMessagesMessages.messageView(data: messageView); 78 + 79 + emit(state.copyWith(messages: [newMessage, ...state.messages], isSending: false)); 80 + } catch (error) { 81 + emit(state.copyWith(isSending: false)); 82 + log.w('Failed to send message: $error'); 83 + } 84 + } 85 + 86 + Future<void> _onMessageDeleted(MessageDeleted event, Emitter<MessageState> emit) async { 87 + if (state.status != MessageStatus.loaded || state.convoId == null) { 88 + return; 89 + } 90 + 91 + try { 92 + final deletedView = await _convoRepository.deleteMessageForSelf(state.convoId!, event.messageId); 93 + final deletedMessage = UConvoGetMessagesMessages.deletedMessageView(data: deletedView); 94 + 95 + final updatedMessages = state.messages.map((m) { 96 + final isTarget = m.when( 97 + messageView: (data) => data.id == event.messageId, 98 + deletedMessageView: (_) => false, 99 + unknown: (_) => false, 100 + ); 101 + return isTarget ? deletedMessage : m; 102 + }).toList(); 103 + 104 + emit(state.copyWith(messages: updatedMessages)); 105 + } catch (error) { 106 + log.w('Failed to delete message ${event.messageId}: $error'); 107 + } 108 + } 109 + 110 + Future<void> _onConvoMarkedRead(ConvoMarkedRead event, Emitter<MessageState> emit) async { 111 + if (state.convoId == null) return; 112 + 113 + try { 114 + await _convoRepository.updateRead(state.convoId!); 115 + } catch (_) { 116 + log.w('Failed to mark conversation as read'); 117 + } 118 + } 119 + }
+49
lib/features/messages/bloc/message_event.dart
··· 1 + part of 'message_bloc.dart'; 2 + 3 + sealed class MessageEvent extends Equatable { 4 + const MessageEvent(); 5 + 6 + @override 7 + List<Object?> get props => []; 8 + } 9 + 10 + class MessagesRequested extends MessageEvent { 11 + const MessagesRequested({required this.convoId, this.limit = 50}); 12 + 13 + final String convoId; 14 + final int limit; 15 + 16 + @override 17 + List<Object?> get props => [convoId, limit]; 18 + } 19 + 20 + class MessagesPageLoaded extends MessageEvent { 21 + const MessagesPageLoaded({this.limit = 50}); 22 + 23 + final int limit; 24 + 25 + @override 26 + List<Object?> get props => [limit]; 27 + } 28 + 29 + class MessageSent extends MessageEvent { 30 + const MessageSent({required this.text}); 31 + 32 + final String text; 33 + 34 + @override 35 + List<Object?> get props => [text]; 36 + } 37 + 38 + class MessageDeleted extends MessageEvent { 39 + const MessageDeleted({required this.messageId}); 40 + 41 + final String messageId; 42 + 43 + @override 44 + List<Object?> get props => [messageId]; 45 + } 46 + 47 + class ConvoMarkedRead extends MessageEvent { 48 + const ConvoMarkedRead(); 49 + }
+63
lib/features/messages/bloc/message_state.dart
··· 1 + part of 'message_bloc.dart'; 2 + 3 + enum MessageStatus { initial, loading, loaded, sending, error } 4 + 5 + class MessageState extends Equatable { 6 + const MessageState._({ 7 + required this.status, 8 + this.messages = const [], 9 + this.cursor, 10 + this.hasMore = false, 11 + this.convoId, 12 + this.isSending = false, 13 + this.isLoadingMore = false, 14 + this.errorMessage, 15 + }); 16 + 17 + const MessageState.initial() : this._(status: MessageStatus.initial); 18 + 19 + const MessageState.loading() : this._(status: MessageStatus.loading); 20 + 21 + const MessageState.loaded({ 22 + required List<UConvoGetMessagesMessages> messages, 23 + String? cursor, 24 + bool hasMore = false, 25 + required String convoId, 26 + }) : this._(status: MessageStatus.loaded, messages: messages, cursor: cursor, hasMore: hasMore, convoId: convoId); 27 + 28 + const MessageState.error(String message) : this._(status: MessageStatus.error, errorMessage: message); 29 + 30 + final MessageStatus status; 31 + final List<UConvoGetMessagesMessages> messages; 32 + final String? cursor; 33 + final bool hasMore; 34 + final String? convoId; 35 + final bool isSending; 36 + final bool isLoadingMore; 37 + final String? errorMessage; 38 + 39 + MessageState copyWith({ 40 + MessageStatus? status, 41 + List<UConvoGetMessagesMessages>? messages, 42 + String? cursor, 43 + bool? hasMore, 44 + String? convoId, 45 + bool? isSending, 46 + bool? isLoadingMore, 47 + String? errorMessage, 48 + }) { 49 + return MessageState._( 50 + status: status ?? this.status, 51 + messages: messages ?? this.messages, 52 + cursor: cursor ?? this.cursor, 53 + hasMore: hasMore ?? this.hasMore, 54 + convoId: convoId ?? this.convoId, 55 + isSending: isSending ?? this.isSending, 56 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 57 + errorMessage: errorMessage ?? this.errorMessage, 58 + ); 59 + } 60 + 61 + @override 62 + List<Object?> get props => [status, messages, cursor, hasMore, convoId, isSending, isLoadingMore, errorMessage]; 63 + }
+65
lib/features/messages/data/convo_repository.dart
··· 1 + import 'package:bluesky/bluesky_chat.dart'; 2 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 3 + import 'package:bluesky/chat_bsky_convo_getmessages.dart'; 4 + 5 + class ConvoRepository { 6 + ConvoRepository({required BlueskyChat chat}) : _chat = chat; 7 + 8 + final BlueskyChat _chat; 9 + 10 + Future<ConvoListResult> listConvos({String? cursor, int limit = 20}) async { 11 + final response = await _chat.convo.listConvos(cursor: cursor, limit: limit); 12 + return ConvoListResult(convos: response.data.convos, cursor: response.data.cursor); 13 + } 14 + 15 + Future<ConvoView> getConvoForMembers(List<String> dids) async { 16 + final response = await _chat.convo.getConvoForMembers(members: dids); 17 + return response.data.convo; 18 + } 19 + 20 + Future<MessageListResult> getMessages(String convoId, {String? cursor, int limit = 50}) async { 21 + final response = await _chat.convo.getMessages(convoId: convoId, cursor: cursor, limit: limit); 22 + return MessageListResult(messages: response.data.messages, cursor: response.data.cursor); 23 + } 24 + 25 + Future<MessageView> sendMessage(String convoId, String text) async { 26 + final response = await _chat.convo.sendMessage( 27 + convoId: convoId, 28 + message: MessageInput(text: text), 29 + ); 30 + return response.data; 31 + } 32 + 33 + Future<DeletedMessageView> deleteMessageForSelf(String convoId, String messageId) async { 34 + final response = await _chat.convo.deleteMessageForSelf(convoId: convoId, messageId: messageId); 35 + return response.data; 36 + } 37 + 38 + Future<ConvoView> muteConvo(String convoId) async { 39 + final response = await _chat.convo.muteConvo(convoId: convoId); 40 + return response.data.convo; 41 + } 42 + 43 + Future<ConvoView> unmuteConvo(String convoId) async { 44 + final response = await _chat.convo.unmuteConvo(convoId: convoId); 45 + return response.data.convo; 46 + } 47 + 48 + Future<void> updateRead(String convoId) async { 49 + await _chat.convo.updateRead(convoId: convoId); 50 + } 51 + } 52 + 53 + class ConvoListResult { 54 + ConvoListResult({required this.convos, this.cursor}); 55 + 56 + final List<ConvoView> convos; 57 + final String? cursor; 58 + } 59 + 60 + class MessageListResult { 61 + MessageListResult({required this.messages, this.cursor}); 62 + 63 + final List<UConvoGetMessagesMessages> messages; 64 + final String? cursor; 65 + }
+91
lib/features/moderation/data/moderation_service.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:bluesky/bluesky.dart'; 4 + import 'package:bluesky/moderation.dart' as bsky_moderation; 5 + import 'package:bluesky/app_bsky_actor_defs.dart'; 6 + import 'package:bluesky/app_bsky_feed_defs.dart'; 7 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as notifications; 8 + import 'package:lazurite/core/logging/app_logger.dart'; 9 + 10 + class ModerationService { 11 + ModerationService({required Bluesky bluesky}) : _bluesky = bluesky; 12 + 13 + final Bluesky _bluesky; 14 + bsky_moderation.ModerationOpts? _opts; 15 + final _optsController = StreamController<bsky_moderation.ModerationOpts>.broadcast(); 16 + 17 + Stream<bsky_moderation.ModerationOpts> get optsStream => _optsController.stream; 18 + bsky_moderation.ModerationOpts? get currentOpts => _opts; 19 + 20 + Future<void> initialize() async { 21 + await _rebuildOpts(); 22 + } 23 + 24 + Future<void> updatePreferences() async { 25 + await _rebuildOpts(); 26 + } 27 + 28 + Future<void> _rebuildOpts() async { 29 + try { 30 + final prefsResponse = await _bluesky.actor.getPreferences(); 31 + final prefs = prefsResponse.data.getModerationPrefs(); 32 + 33 + final labelDefs = await _bluesky.labeler.getLabelDefinitions(prefs); 34 + 35 + final opts = bsky_moderation.ModerationOpts(prefs: prefs, labelDefs: labelDefs); 36 + 37 + _opts = opts; 38 + _optsController.add(opts); 39 + } catch (error) { 40 + log.w('Failed to build moderation opts: $error'); 41 + } 42 + } 43 + 44 + bsky_moderation.ModerationDecision moderatePost(PostView post) { 45 + if (_opts == null) { 46 + return bsky_moderation.ModerationDecision.merge([]); 47 + } 48 + return bsky_moderation.moderatePost(bsky_moderation.ModerationSubjectPost.postView(data: post), _opts!); 49 + } 50 + 51 + bsky_moderation.ModerationDecision moderateProfile(ProfileView profile) { 52 + if (_opts == null) { 53 + return bsky_moderation.ModerationDecision.merge([]); 54 + } 55 + return bsky_moderation.moderateProfile(bsky_moderation.ModerationSubjectProfile.profileView(data: profile), _opts!); 56 + } 57 + 58 + bsky_moderation.ModerationDecision moderateProfileBasic(ProfileViewBasic profile) { 59 + if (_opts == null) { 60 + return bsky_moderation.ModerationDecision.merge([]); 61 + } 62 + return bsky_moderation.moderateProfile( 63 + bsky_moderation.ModerationSubjectProfile.profileViewBasic(data: profile), 64 + _opts!, 65 + ); 66 + } 67 + 68 + bsky_moderation.ModerationDecision moderateProfileDetailed(ProfileViewDetailed profile) { 69 + if (_opts == null) { 70 + return bsky_moderation.ModerationDecision.merge([]); 71 + } 72 + return bsky_moderation.moderateProfile( 73 + bsky_moderation.ModerationSubjectProfile.profileViewDetailed(data: profile), 74 + _opts!, 75 + ); 76 + } 77 + 78 + bsky_moderation.ModerationDecision moderateNotification(notifications.Notification notification) { 79 + if (_opts == null) { 80 + return bsky_moderation.ModerationDecision.merge([]); 81 + } 82 + return bsky_moderation.moderateNotification( 83 + bsky_moderation.ModerationSubjectNotification.notification(data: notification), 84 + _opts!, 85 + ); 86 + } 87 + 88 + void dispose() { 89 + _optsController.close(); 90 + } 91 + }
+32
pubspec.lock
··· 241 241 url: "https://pub.dev" 242 242 source: hosted 243 243 version: "1.19.1" 244 + connectivity_plus: 245 + dependency: "direct main" 246 + description: 247 + name: connectivity_plus 248 + sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c" 249 + url: "https://pub.dev" 250 + source: hosted 251 + version: "7.0.0" 252 + connectivity_plus_platform_interface: 253 + dependency: transitive 254 + description: 255 + name: connectivity_plus_platform_interface 256 + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" 257 + url: "https://pub.dev" 258 + source: hosted 259 + version: "2.0.1" 244 260 convert: 245 261 dependency: transitive 246 262 description: ··· 297 313 url: "https://pub.dev" 298 314 source: hosted 299 315 version: "3.1.3" 316 + dbus: 317 + dependency: transitive 318 + description: 319 + name: dbus 320 + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 321 + url: "https://pub.dev" 322 + source: hosted 323 + version: "0.7.12" 300 324 diff_match_patch: 301 325 dependency: transitive 302 326 description: ··· 752 776 url: "https://pub.dev" 753 777 source: hosted 754 778 version: "1.0.0" 779 + nm: 780 + dependency: transitive 781 + description: 782 + name: nm 783 + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" 784 + url: "https://pub.dev" 785 + source: hosted 786 + version: "0.5.0" 755 787 node_preamble: 756 788 dependency: transitive 757 789 description:
+1
pubspec.yaml
··· 39 39 workmanager: ^0.5.2 40 40 plugin_platform_interface: ^2.1.8 41 41 url_launcher_platform_interface: ^2.3.2 42 + connectivity_plus: ^7.0.0 42 43 43 44 dev_dependencies: 44 45 flutter_test:
+12 -16
scripts/constants.ts
··· 1 - import type { ThemeColors } from "./types"; 1 + import type { ThemeColors, ThemeKey } from "./types"; 2 2 3 3 /** Splash screen sizes for Android */ 4 4 export const ANDROID_SIZES = [ ··· 9 9 { name: "xxxhdpi", size: 1280 }, 10 10 ]; 11 11 12 + const IPHONE_X = { name: "Default-Portrait-812h@3x", size: 1125, scale: 3, height: 2436 }; 13 + const IPHONE_XR = { name: "Default-Portrait-896h@3x", size: 828, scale: 3, height: 1792 }; 14 + /** Also 13, 14 */ 15 + const IPHONE_12 = { name: "Default-Portrait-926h@3x", size: 1170, scale: 3, height: 2532 }; 16 + const IPHONE_14_PRO = { name: "Default-Portrait-932h@3x", size: 1179, scale: 3, height: 2556 }; 17 + 12 18 /** Splash screen sizes for iOS */ 13 - export const IOS_SIZES = [ 19 + export const IOS_SIZES: { name: string; size: number; scale: number; height?: number }[] = [ 20 + IPHONE_X, 21 + IPHONE_XR, 22 + IPHONE_12, 23 + IPHONE_14_PRO, 14 24 { name: "Default", size: 320, scale: 1 }, 15 25 { name: "Default@2x", size: 640, scale: 2 }, 16 26 { name: "Default-568h@2x", size: 640, scale: 2, height: 1136 }, 17 27 { name: "Default-667h@2x", size: 750, scale: 2, height: 1334 }, 18 28 { name: "Default-Portrait-736h@3x", size: 1242, scale: 3, height: 2208 }, 19 29 { name: "Default-Landscape-736h@3x", size: 2208, scale: 3, height: 1242 }, 20 - { name: "Default-Portrait-812h@3x", size: 1125, scale: 3, height: 2436 }, // iPhone X 21 30 { name: "Default-Landscape-812h@3x", size: 2436, scale: 3, height: 1125 }, 22 - { name: "Default-Portrait-896h@3x", size: 828, scale: 3, height: 1792 }, // iPhone XR 23 31 { name: "Default-Landscape-896h@3x", size: 1792, scale: 3, height: 828 }, 24 - { name: "Default-Portrait-926h@3x", size: 1170, scale: 3, height: 2532 }, // iPhone 12/13/14 25 32 { name: "Default-Landscape-926h@3x", size: 2532, scale: 3, height: 1170 }, 26 - { name: "Default-Portrait-932h@3x", size: 1179, scale: 3, height: 2556 }, // iPhone 14 Pro 27 33 { name: "Default-Landscape-932h@3x", size: 2556, scale: 3, height: 1179 }, 28 34 ]; 29 - 30 - type ThemeKey = 31 - | "oxocarbon" 32 - | "oxocarbon-light" 33 - | "catppuccin" 34 - | "catppuccin-light" 35 - | "nord" 36 - | "nord-light" 37 - | "rosePine" 38 - | "rosePine-light"; 39 35 40 36 export const THEMES: Record<ThemeKey, ThemeColors> = { 41 37 oxocarbon: { name: "Oxocarbon", variant: "dark", background: "#161616", text: "#f2f4f8", primary: "#78a9ff" },
+1 -2
scripts/index.ts
··· 26 26 27 27 const minDim = Math.min(width, height); 28 28 const logoSize = minDim * 0.22; 29 - const gap = minDim * 0.04; // Gap between logo and text (like 16px in about screen) 29 + const gap = minDim * 0.04; 30 30 const fontSize = minDim * 0.07; 31 31 32 - // Calculate total height of logo + gap + text to center the group 33 32 const totalGroupHeight = logoSize + gap + fontSize; 34 33 const groupCenterY = height / 2; 35 34 const logoY = groupCenterY - totalGroupHeight / 2;
+10
scripts/types.ts
··· 1 1 export type ThemeVariant = "light" | "dark"; 2 2 3 3 export type ThemeColors = { name: string; variant: ThemeVariant; background: string; text: string; primary: string }; 4 + 5 + export type ThemeKey = 6 + | "oxocarbon" 7 + | "oxocarbon-light" 8 + | "catppuccin" 9 + | "catppuccin-light" 10 + | "nord" 11 + | "nord-light" 12 + | "rosePine" 13 + | "rosePine-light";
+208
test/features/account/cubit/account_switcher_cubit_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/core/database/app_database.dart'; 4 + import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 5 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 6 + import 'package:mocktail/mocktail.dart'; 7 + 8 + class MockAppDatabase extends Mock implements AppDatabase {} 9 + 10 + class AccountsCompanionFake extends Fake implements AccountsCompanion {} 11 + 12 + void main() { 13 + late MockAppDatabase mockDatabase; 14 + 15 + setUpAll(() { 16 + registerFallbackValue(AccountsCompanionFake()); 17 + }); 18 + 19 + setUp(() { 20 + mockDatabase = MockAppDatabase(); 21 + }); 22 + 23 + Account makeAccount({required String did, String handle = 'user.bsky.social'}) { 24 + return Account( 25 + did: did, 26 + handle: handle, 27 + displayName: null, 28 + service: null, 29 + accessToken: 'token', 30 + refreshToken: null, 31 + dpopPublicKey: null, 32 + dpopPrivateKey: null, 33 + dpopNonce: null, 34 + expiresAt: null, 35 + createdAt: DateTime.utc(2026, 1, 1), 36 + updatedAt: DateTime.utc(2026, 1, 1), 37 + ); 38 + } 39 + 40 + group('AccountSwitcherCubit', () { 41 + group('loadAccounts', () { 42 + blocTest<AccountSwitcherCubit, AccountSwitcherState>( 43 + 'emits loading then ready with accounts when accounts exist', 44 + build: () => AccountSwitcherCubit(database: mockDatabase), 45 + setUp: () { 46 + final accounts = [makeAccount(did: 'did:plc:user1'), makeAccount(did: 'did:plc:user2')]; 47 + when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); 48 + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => 'did:plc:user1'); 49 + }, 50 + act: (cubit) => cubit.loadAccounts(), 51 + expect: () => [ 52 + const AccountSwitcherState.loading(), 53 + predicate<AccountSwitcherState>( 54 + (state) => 55 + state.status == AccountSwitcherStatus.ready && 56 + state.accounts.length == 2 && 57 + state.activeDid == 'did:plc:user1', 58 + ), 59 + ], 60 + ); 61 + 62 + blocTest<AccountSwitcherCubit, AccountSwitcherState>( 63 + 'defaults to first account when no saved active did', 64 + build: () => AccountSwitcherCubit(database: mockDatabase), 65 + setUp: () { 66 + final accounts = [makeAccount(did: 'did:plc:user1'), makeAccount(did: 'did:plc:user2')]; 67 + when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); 68 + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => null); 69 + }, 70 + act: (cubit) => cubit.loadAccounts(), 71 + expect: () => [ 72 + const AccountSwitcherState.loading(), 73 + predicate<AccountSwitcherState>( 74 + (state) => state.status == AccountSwitcherStatus.ready && state.activeDid == 'did:plc:user1', 75 + ), 76 + ], 77 + ); 78 + 79 + blocTest<AccountSwitcherCubit, AccountSwitcherState>( 80 + 'defaults to first account when saved did not in accounts', 81 + build: () => AccountSwitcherCubit(database: mockDatabase), 82 + setUp: () { 83 + final accounts = [makeAccount(did: 'did:plc:user1')]; 84 + when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); 85 + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => 'did:plc:unknown'); 86 + }, 87 + act: (cubit) => cubit.loadAccounts(), 88 + expect: () => [ 89 + const AccountSwitcherState.loading(), 90 + predicate<AccountSwitcherState>( 91 + (state) => state.status == AccountSwitcherStatus.ready && state.activeDid == 'did:plc:user1', 92 + ), 93 + ], 94 + ); 95 + 96 + blocTest<AccountSwitcherCubit, AccountSwitcherState>( 97 + 'emits ready with empty accounts on failure', 98 + build: () => AccountSwitcherCubit(database: mockDatabase), 99 + setUp: () { 100 + when(() => mockDatabase.getAllAccounts()).thenThrow(Exception('DB error')); 101 + }, 102 + act: (cubit) => cubit.loadAccounts(), 103 + expect: () => [ 104 + const AccountSwitcherState.loading(), 105 + predicate<AccountSwitcherState>( 106 + (state) => state.status == AccountSwitcherStatus.ready && state.accounts.isEmpty, 107 + ), 108 + ], 109 + ); 110 + }); 111 + 112 + group('switchAccount', () { 113 + blocTest<AccountSwitcherCubit, AccountSwitcherState>( 114 + 'updates activeDid when switching accounts', 115 + build: () => AccountSwitcherCubit(database: mockDatabase), 116 + seed: () => AccountSwitcherState.ready( 117 + accounts: [ 118 + makeAccount(did: 'did:plc:user1'), 119 + makeAccount(did: 'did:plc:user2'), 120 + ], 121 + activeDid: 'did:plc:user1', 122 + ), 123 + setUp: () { 124 + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 125 + }, 126 + act: (cubit) => cubit.switchAccount('did:plc:user2'), 127 + expect: () => [predicate<AccountSwitcherState>((state) => state.activeDid == 'did:plc:user2')], 128 + verify: (_) { 129 + verify(() => mockDatabase.setSetting('active_account_did', 'did:plc:user2')).called(1); 130 + }, 131 + ); 132 + 133 + blocTest<AccountSwitcherCubit, AccountSwitcherState>( 134 + 'does nothing when state is not ready', 135 + build: () => AccountSwitcherCubit(database: mockDatabase), 136 + act: (cubit) => cubit.switchAccount('did:plc:user1'), 137 + expect: () => [], 138 + verify: (_) { 139 + verifyNever(() => mockDatabase.setSetting(any(), any())); 140 + }, 141 + ); 142 + }); 143 + 144 + group('addAccountCompleted', () { 145 + blocTest<AccountSwitcherCubit, AccountSwitcherState>( 146 + 'inserts account, reloads, and activeDid is set to the new account', 147 + build: () => AccountSwitcherCubit(database: mockDatabase), 148 + setUp: () { 149 + when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); 150 + when( 151 + () => mockDatabase.getAllAccounts(), 152 + ).thenAnswer((_) async => [makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social')]); 153 + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => null); 154 + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 155 + }, 156 + act: (cubit) => cubit.addAccountCompleted( 157 + const AuthTokens(accessToken: 'token', did: 'did:plc:newuser', handle: 'new.bsky.social'), 158 + ), 159 + expect: () => [ 160 + const AccountSwitcherState.loading(), 161 + predicate<AccountSwitcherState>( 162 + (state) => 163 + state.status == AccountSwitcherStatus.ready && 164 + state.accounts.length == 1 && 165 + state.activeDid == 'did:plc:newuser', 166 + ), 167 + ], 168 + verify: (_) { 169 + verify(() => mockDatabase.insertAccount(any())).called(1); 170 + verify(() => mockDatabase.setSetting('active_account_did', 'did:plc:newuser')).called(1); 171 + }, 172 + ); 173 + 174 + blocTest<AccountSwitcherCubit, AccountSwitcherState>( 175 + 'switches to newly added account even when another was active', 176 + build: () => AccountSwitcherCubit(database: mockDatabase), 177 + seed: () => AccountSwitcherState.ready( 178 + accounts: [makeAccount(did: 'did:plc:user1')], 179 + activeDid: 'did:plc:user1', 180 + ), 181 + setUp: () { 182 + when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); 183 + when(() => mockDatabase.getAllAccounts()).thenAnswer( 184 + (_) async => [ 185 + makeAccount(did: 'did:plc:user1'), 186 + makeAccount(did: 'did:plc:user2', handle: 'user2.bsky.social'), 187 + ], 188 + ); 189 + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => 'did:plc:user1'); 190 + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 191 + }, 192 + act: (cubit) => cubit.addAccountCompleted( 193 + const AuthTokens(accessToken: 'token', did: 'did:plc:user2', handle: 'user2.bsky.social'), 194 + ), 195 + expect: () => [ 196 + const AccountSwitcherState.loading(), 197 + predicate<AccountSwitcherState>( 198 + (state) => 199 + state.status == AccountSwitcherStatus.ready && 200 + state.accounts.length == 2 && 201 + state.activeDid == 'did:plc:user1', 202 + ), 203 + predicate<AccountSwitcherState>((state) => state.activeDid == 'did:plc:user2'), 204 + ], 205 + ); 206 + }); 207 + }); 208 + }
+2 -4
test/features/compose/bloc/compose_bloc_test.dart
··· 82 82 blocTest<ComposeBloc, ComposeState>( 83 83 'isDraftDirty is true after media removed', 84 84 build: () => composeBloc, 85 - seed: () => const ComposeState.ready( 86 - mediaAttachments: [MediaAttachment(localPath: '/1.jpg')], 87 - isDraftDirty: false, 88 - ), 85 + seed: () => 86 + const ComposeState.ready(mediaAttachments: [MediaAttachment(localPath: '/1.jpg')], isDraftDirty: false), 89 87 act: (bloc) => bloc.add(const MediaRemoved(0)), 90 88 expect: () => [isA<ComposeState>().having((s) => s.isDraftDirty, 'isDraftDirty', true)], 91 89 );
+3 -1
test/features/compose/presentation/compose_screen_test.dart
··· 130 130 }); 131 131 132 132 testWidgets('shows draft items when drafts are loaded', (tester) async { 133 - seedState(const ComposeState.ready().copyWith(drafts: [_makeDraft(content: 'My saved draft')], isLoadingDrafts: false)); 133 + seedState( 134 + const ComposeState.ready().copyWith(drafts: [_makeDraft(content: 'My saved draft')], isLoadingDrafts: false), 135 + ); 134 136 135 137 await tester.pumpWidget(buildSubject()); 136 138 await tester.pump();
+78
test/features/connectivity/cubit/connectivity_cubit_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:bloc_test/bloc_test.dart'; 4 + import 'package:connectivity_plus/connectivity_plus.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 7 + import 'package:mocktail/mocktail.dart'; 8 + 9 + class MockConnectivity extends Mock implements Connectivity {} 10 + 11 + void main() { 12 + late MockConnectivity mockConnectivity; 13 + late StreamController<List<ConnectivityResult>> connectivityStreamController; 14 + 15 + setUp(() { 16 + mockConnectivity = MockConnectivity(); 17 + connectivityStreamController = StreamController<List<ConnectivityResult>>.broadcast(); 18 + 19 + when(() => mockConnectivity.checkConnectivity()).thenAnswer((_) async => [ConnectivityResult.wifi]); 20 + when(() => mockConnectivity.onConnectivityChanged).thenAnswer((_) => connectivityStreamController.stream); 21 + }); 22 + 23 + tearDown(() { 24 + connectivityStreamController.close(); 25 + }); 26 + 27 + group('ConnectivityCubit', () { 28 + test('initial state is online before checkConnectivity resolves', () { 29 + final cubit = ConnectivityCubit(connectivity: mockConnectivity); 30 + expect(cubit.state.isOnline, isTrue); 31 + Future<void>.delayed(Duration.zero).then((_) => cubit.close()); 32 + }); 33 + 34 + blocTest<ConnectivityCubit, ConnectivityState>( 35 + 'emits online state when connectivity is wifi', 36 + build: () { 37 + when(() => mockConnectivity.checkConnectivity()).thenAnswer((_) async => [ConnectivityResult.wifi]); 38 + return ConnectivityCubit(connectivity: mockConnectivity); 39 + }, 40 + expect: () => [predicate<ConnectivityState>((state) => state.isOnline)], 41 + ); 42 + 43 + blocTest<ConnectivityCubit, ConnectivityState>( 44 + 'emits offline state when connectivity changes to none', 45 + build: () => ConnectivityCubit(connectivity: mockConnectivity), 46 + act: (cubit) => connectivityStreamController.add([ConnectivityResult.none]), 47 + expect: () => [ 48 + predicate<ConnectivityState>((state) => state.isOnline), 49 + predicate<ConnectivityState>((state) => !state.isOnline), 50 + ], 51 + ); 52 + 53 + blocTest<ConnectivityCubit, ConnectivityState>( 54 + 'emits online state when connectivity changes to mobile', 55 + build: () { 56 + when(() => mockConnectivity.checkConnectivity()).thenAnswer((_) async => [ConnectivityResult.none]); 57 + return ConnectivityCubit(connectivity: mockConnectivity); 58 + }, 59 + act: (cubit) => connectivityStreamController.add([ConnectivityResult.mobile]), 60 + expect: () => [ 61 + predicate<ConnectivityState>((state) => !state.isOnline), 62 + predicate<ConnectivityState>((state) => state.isOnline), 63 + ], 64 + ); 65 + 66 + test('ConnectivityState.online has isOnline = true', () { 67 + const state = ConnectivityState.online(); 68 + expect(state.isOnline, isTrue); 69 + expect(state.status, ConnectivityStatus.online); 70 + }); 71 + 72 + test('ConnectivityState.offline has isOnline = false', () { 73 + const state = ConnectivityState.offline(); 74 + expect(state.isOnline, isFalse); 75 + expect(state.status, ConnectivityStatus.offline); 76 + }); 77 + }); 78 + }
+1 -1
test/features/feed/presentation/post_thread_screen_test.dart
··· 302 302 final parents = _extractParentChain(thread); 303 303 304 304 expect(parents.length, 2); 305 - expect(parents[0].post.cid, 'cid-gp'); // oldest first 305 + expect(parents[0].post.cid, 'cid-gp'); 306 306 expect(parents[1].post.cid, 'cid-p'); 307 307 }); 308 308
+142
test/features/messages/bloc/convo_list_bloc_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 5 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 6 + import 'package:mocktail/mocktail.dart'; 7 + 8 + class MockConvoRepository extends Mock implements ConvoRepository {} 9 + 10 + void main() { 11 + late MockConvoRepository mockConvoRepository; 12 + 13 + setUp(() { 14 + mockConvoRepository = MockConvoRepository(); 15 + }); 16 + 17 + ConvoView makeConvoView(String id) => ConvoView(id: id, rev: 'rev-1', members: [], muted: false, unreadCount: 0); 18 + 19 + group('ConvoListBloc', () { 20 + blocTest<ConvoListBloc, ConvoListState>( 21 + 'emits loading and loaded when ConvosRequested succeeds', 22 + build: () => ConvoListBloc(convoRepository: mockConvoRepository), 23 + setUp: () { 24 + when( 25 + () => mockConvoRepository.listConvos( 26 + cursor: any(named: 'cursor'), 27 + limit: any(named: 'limit'), 28 + ), 29 + ).thenAnswer((_) async => ConvoListResult(convos: [makeConvoView('c1')], cursor: 'cursor-1')); 30 + }, 31 + act: (bloc) => bloc.add(const ConvosRequested()), 32 + expect: () => [ 33 + const ConvoListState.loading(), 34 + predicate<ConvoListState>( 35 + (state) => 36 + state.status == ConvoListStatus.loaded && 37 + state.convos.length == 1 && 38 + state.cursor == 'cursor-1' && 39 + state.hasMore == true, 40 + ), 41 + ], 42 + ); 43 + 44 + blocTest<ConvoListBloc, ConvoListState>( 45 + 'emits error when ConvosRequested fails', 46 + build: () => ConvoListBloc(convoRepository: mockConvoRepository), 47 + setUp: () { 48 + when( 49 + () => mockConvoRepository.listConvos( 50 + cursor: any(named: 'cursor'), 51 + limit: any(named: 'limit'), 52 + ), 53 + ).thenThrow(Exception('Network error')); 54 + }, 55 + act: (bloc) => bloc.add(const ConvosRequested()), 56 + expect: () => [ 57 + const ConvoListState.loading(), 58 + predicate<ConvoListState>((state) => state.status == ConvoListStatus.error), 59 + ], 60 + ); 61 + 62 + blocTest<ConvoListBloc, ConvoListState>( 63 + 'does not refresh when state is not loaded', 64 + build: () => ConvoListBloc(convoRepository: mockConvoRepository), 65 + act: (bloc) => bloc.add(const ConvosRefreshed()), 66 + expect: () => [], 67 + verify: (_) { 68 + verifyNever( 69 + () => mockConvoRepository.listConvos( 70 + cursor: any(named: 'cursor'), 71 + limit: any(named: 'limit'), 72 + ), 73 + ); 74 + }, 75 + ); 76 + 77 + blocTest<ConvoListBloc, ConvoListState>( 78 + 'refreshes convos on ConvosRefreshed when loaded', 79 + build: () => ConvoListBloc(convoRepository: mockConvoRepository), 80 + seed: () => ConvoListState.loaded(convos: [makeConvoView('c1')], cursor: 'old-cursor', hasMore: true), 81 + setUp: () { 82 + when( 83 + () => mockConvoRepository.listConvos( 84 + cursor: any(named: 'cursor'), 85 + limit: any(named: 'limit'), 86 + ), 87 + ).thenAnswer((_) async => ConvoListResult(convos: [makeConvoView('c2')], cursor: 'new-cursor')); 88 + }, 89 + act: (bloc) => bloc.add(const ConvosRefreshed()), 90 + expect: () => [ 91 + predicate<ConvoListState>((state) => state.isRefreshing), 92 + predicate<ConvoListState>( 93 + (state) => 94 + state.status == ConvoListStatus.loaded && 95 + state.convos.length == 1 && 96 + state.convos.first.id == 'c2' && 97 + state.cursor == 'new-cursor' && 98 + !state.isRefreshing, 99 + ), 100 + ], 101 + ); 102 + 103 + blocTest<ConvoListBloc, ConvoListState>( 104 + 'mutes convo and updates list on ConvoMuted', 105 + build: () => ConvoListBloc(convoRepository: mockConvoRepository), 106 + seed: () => ConvoListState.loaded(convos: [makeConvoView('c1')], cursor: null, hasMore: false), 107 + setUp: () { 108 + const mutedConvo = ConvoView(id: 'c1', rev: 'rev-2', members: [], muted: true, unreadCount: 0); 109 + when(() => mockConvoRepository.muteConvo(any())).thenAnswer((_) async => mutedConvo); 110 + }, 111 + act: (bloc) => bloc.add(const ConvoMuted(convoId: 'c1')), 112 + expect: () => [predicate<ConvoListState>((state) => state.convos.isNotEmpty && state.convos.first.muted)], 113 + ); 114 + 115 + blocTest<ConvoListBloc, ConvoListState>( 116 + 'unmutes convo and updates list on ConvoUnmuted', 117 + build: () => ConvoListBloc(convoRepository: mockConvoRepository), 118 + seed: () => const ConvoListState.loaded( 119 + convos: [ConvoView(id: 'c1', rev: 'rev-1', members: [], muted: true, unreadCount: 0)], 120 + cursor: null, 121 + hasMore: false, 122 + ), 123 + setUp: () { 124 + final unmutedConvo = makeConvoView('c1'); 125 + when(() => mockConvoRepository.unmuteConvo(any())).thenAnswer((_) async => unmutedConvo); 126 + }, 127 + act: (bloc) => bloc.add(const ConvoUnmuted(convoId: 'c1')), 128 + expect: () => [predicate<ConvoListState>((state) => state.convos.isNotEmpty && !state.convos.first.muted)], 129 + ); 130 + 131 + blocTest<ConvoListBloc, ConvoListState>( 132 + 'handles mute failure silently', 133 + build: () => ConvoListBloc(convoRepository: mockConvoRepository), 134 + seed: () => ConvoListState.loaded(convos: [makeConvoView('c1')], cursor: null, hasMore: false), 135 + setUp: () { 136 + when(() => mockConvoRepository.muteConvo(any())).thenThrow(Exception('Network error')); 137 + }, 138 + act: (bloc) => bloc.add(const ConvoMuted(convoId: 'c1')), 139 + expect: () => [], 140 + ); 141 + }); 142 + }
+196
test/features/messages/bloc/message_bloc_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 3 + import 'package:bluesky/chat_bsky_convo_getmessages.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/messages/bloc/message_bloc.dart'; 6 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 7 + import 'package:mocktail/mocktail.dart'; 8 + 9 + class MockConvoRepository extends Mock implements ConvoRepository {} 10 + 11 + void main() { 12 + late MockConvoRepository mockConvoRepository; 13 + 14 + const testConvoId = 'convo-123'; 15 + const testUserDid = 'did:plc:testuser'; 16 + 17 + setUp(() { 18 + mockConvoRepository = MockConvoRepository(); 19 + }); 20 + 21 + MessageView makeMessageView(String id, String text) => MessageView( 22 + id: id, 23 + rev: 'rev-1', 24 + text: text, 25 + sender: const MessageViewSender(did: testUserDid), 26 + sentAt: DateTime.utc(2026, 3, 15), 27 + ); 28 + 29 + UConvoGetMessagesMessages makeMessage(String id, String text) => 30 + UConvoGetMessagesMessages.messageView(data: makeMessageView(id, text)); 31 + 32 + group('MessageBloc', () { 33 + blocTest<MessageBloc, MessageState>( 34 + 'emits loading and loaded when MessagesRequested succeeds', 35 + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), 36 + setUp: () { 37 + when( 38 + () => mockConvoRepository.getMessages( 39 + any(), 40 + cursor: any(named: 'cursor'), 41 + limit: any(named: 'limit'), 42 + ), 43 + ).thenAnswer((_) async => MessageListResult(messages: [makeMessage('msg-1', 'Hello')], cursor: 'cursor-1')); 44 + }, 45 + act: (bloc) => bloc.add(const MessagesRequested(convoId: testConvoId)), 46 + expect: () => [ 47 + const MessageState.loading(), 48 + predicate<MessageState>( 49 + (state) => 50 + state.status == MessageStatus.loaded && 51 + state.messages.length == 1 && 52 + state.cursor == 'cursor-1' && 53 + state.hasMore == true && 54 + state.convoId == testConvoId, 55 + ), 56 + ], 57 + ); 58 + 59 + blocTest<MessageBloc, MessageState>( 60 + 'emits error when MessagesRequested fails', 61 + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), 62 + setUp: () { 63 + when( 64 + () => mockConvoRepository.getMessages( 65 + any(), 66 + cursor: any(named: 'cursor'), 67 + limit: any(named: 'limit'), 68 + ), 69 + ).thenThrow(Exception('Network error')); 70 + }, 71 + act: (bloc) => bloc.add(const MessagesRequested(convoId: testConvoId)), 72 + expect: () => [ 73 + const MessageState.loading(), 74 + predicate<MessageState>((state) => state.status == MessageStatus.error), 75 + ], 76 + ); 77 + 78 + blocTest<MessageBloc, MessageState>( 79 + 'loads more messages on MessagesPageLoaded', 80 + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), 81 + seed: () => MessageState.loaded( 82 + messages: [makeMessage('msg-1', 'Hello')], 83 + cursor: 'cursor-1', 84 + hasMore: true, 85 + convoId: testConvoId, 86 + ), 87 + setUp: () { 88 + when( 89 + () => mockConvoRepository.getMessages( 90 + any(), 91 + cursor: 'cursor-1', 92 + limit: any(named: 'limit'), 93 + ), 94 + ).thenAnswer((_) async => MessageListResult(messages: [makeMessage('msg-2', 'World')], cursor: null)); 95 + }, 96 + act: (bloc) => bloc.add(const MessagesPageLoaded()), 97 + expect: () => [ 98 + predicate<MessageState>((state) => state.isLoadingMore), 99 + predicate<MessageState>( 100 + (state) => 101 + state.status == MessageStatus.loaded && 102 + state.messages.length == 2 && 103 + !state.hasMore && 104 + !state.isLoadingMore, 105 + ), 106 + ], 107 + ); 108 + 109 + blocTest<MessageBloc, MessageState>( 110 + 'does not load more when already loading more', 111 + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), 112 + seed: () => MessageState.loaded( 113 + messages: [makeMessage('msg-1', 'Hello')], 114 + cursor: 'cursor-1', 115 + hasMore: true, 116 + convoId: testConvoId, 117 + ).copyWith(isLoadingMore: true), 118 + act: (bloc) => bloc.add(const MessagesPageLoaded()), 119 + expect: () => [], 120 + verify: (_) { 121 + verifyNever( 122 + () => mockConvoRepository.getMessages( 123 + any(), 124 + cursor: any(named: 'cursor'), 125 + limit: any(named: 'limit'), 126 + ), 127 + ); 128 + }, 129 + ); 130 + 131 + blocTest<MessageBloc, MessageState>( 132 + 'sends message and prepends to list on MessageSent', 133 + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), 134 + seed: () => MessageState.loaded(messages: [makeMessage('msg-1', 'Hello')], cursor: null, convoId: testConvoId), 135 + setUp: () { 136 + when( 137 + () => mockConvoRepository.sendMessage(any(), any()), 138 + ).thenAnswer((_) async => makeMessageView('msg-new', 'World')); 139 + }, 140 + act: (bloc) => bloc.add(const MessageSent(text: 'World')), 141 + expect: () => [ 142 + predicate<MessageState>((state) => state.isSending), 143 + predicate<MessageState>((state) => state.messages.length == 2 && !state.isSending), 144 + ], 145 + ); 146 + 147 + blocTest<MessageBloc, MessageState>( 148 + 'handles send failure silently', 149 + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), 150 + seed: () => MessageState.loaded(messages: [makeMessage('msg-1', 'Hello')], cursor: null, convoId: testConvoId), 151 + setUp: () { 152 + when(() => mockConvoRepository.sendMessage(any(), any())).thenThrow(Exception('Send failed')); 153 + }, 154 + act: (bloc) => bloc.add(const MessageSent(text: 'World')), 155 + expect: () => [ 156 + predicate<MessageState>((state) => state.isSending), 157 + predicate<MessageState>((state) => !state.isSending && state.messages.length == 1), 158 + ], 159 + ); 160 + 161 + blocTest<MessageBloc, MessageState>( 162 + 'calls updateRead on ConvoMarkedRead', 163 + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), 164 + seed: () => const MessageState.loaded(messages: [], cursor: null, convoId: testConvoId), 165 + setUp: () { 166 + when(() => mockConvoRepository.updateRead(any())).thenAnswer((_) async {}); 167 + }, 168 + act: (bloc) => bloc.add(const ConvoMarkedRead()), 169 + expect: () => [], 170 + verify: (_) { 171 + verify(() => mockConvoRepository.updateRead(testConvoId)).called(1); 172 + }, 173 + ); 174 + 175 + blocTest<MessageBloc, MessageState>( 176 + 'handles updateRead failure silently', 177 + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), 178 + seed: () => const MessageState.loaded(messages: [], cursor: null, convoId: testConvoId), 179 + setUp: () { 180 + when(() => mockConvoRepository.updateRead(any())).thenThrow(Exception('Network error')); 181 + }, 182 + act: (bloc) => bloc.add(const ConvoMarkedRead()), 183 + expect: () => [], 184 + ); 185 + 186 + blocTest<MessageBloc, MessageState>( 187 + 'does not send when state is not loaded', 188 + build: () => MessageBloc(convoRepository: mockConvoRepository, currentUserDid: testUserDid), 189 + act: (bloc) => bloc.add(const MessageSent(text: 'Hello')), 190 + expect: () => [], 191 + verify: (_) { 192 + verifyNever(() => mockConvoRepository.sendMessage(any(), any())); 193 + }, 194 + ); 195 + }); 196 + }
+123
test/features/messages/data/convo_repository_test.dart
··· 1 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 4 + import 'package:mocktail/mocktail.dart'; 5 + 6 + class MockConvoRepository extends Mock implements ConvoRepository {} 7 + 8 + void main() { 9 + group('ConvoListResult', () { 10 + test('stores convos and cursor', () { 11 + final convo = _makeConvoView('c1'); 12 + final result = ConvoListResult(convos: [convo], cursor: 'cursor-1'); 13 + 14 + expect(result.convos.length, 1); 15 + expect(result.convos.first.id, 'c1'); 16 + expect(result.cursor, 'cursor-1'); 17 + }); 18 + 19 + test('cursor is nullable', () { 20 + final result = ConvoListResult(convos: []); 21 + expect(result.cursor, isNull); 22 + }); 23 + }); 24 + 25 + group('MessageListResult', () { 26 + test('stores messages and cursor', () { 27 + final result = MessageListResult(messages: [], cursor: 'cursor-1'); 28 + 29 + expect(result.messages, isEmpty); 30 + expect(result.cursor, 'cursor-1'); 31 + }); 32 + 33 + test('cursor is nullable', () { 34 + final result = MessageListResult(messages: []); 35 + expect(result.cursor, isNull); 36 + }); 37 + }); 38 + 39 + group('ConvoRepository interface', () { 40 + late MockConvoRepository mockRepo; 41 + 42 + setUp(() { 43 + mockRepo = MockConvoRepository(); 44 + }); 45 + 46 + test('listConvos returns ConvoListResult', () async { 47 + final expected = ConvoListResult(convos: [_makeConvoView('c1')], cursor: 'cursor-1'); 48 + when( 49 + () => mockRepo.listConvos( 50 + cursor: any(named: 'cursor'), 51 + limit: any(named: 'limit'), 52 + ), 53 + ).thenAnswer((_) async => expected); 54 + 55 + final result = await mockRepo.listConvos(); 56 + 57 + expect(result.convos.length, 1); 58 + expect(result.cursor, 'cursor-1'); 59 + }); 60 + 61 + test('getMessages returns MessageListResult', () async { 62 + final expected = MessageListResult(messages: [], cursor: null); 63 + when( 64 + () => mockRepo.getMessages( 65 + any(), 66 + cursor: any(named: 'cursor'), 67 + limit: any(named: 'limit'), 68 + ), 69 + ).thenAnswer((_) async => expected); 70 + 71 + final result = await mockRepo.getMessages('convo-1'); 72 + 73 + expect(result.messages, isEmpty); 74 + expect(result.cursor, isNull); 75 + }); 76 + 77 + test('muteConvo returns ConvoView', () async { 78 + final expected = _makeConvoView('c1'); 79 + when(() => mockRepo.muteConvo(any())).thenAnswer((_) async => expected); 80 + 81 + final result = await mockRepo.muteConvo('c1'); 82 + 83 + expect(result.id, 'c1'); 84 + }); 85 + 86 + test('unmuteConvo returns ConvoView', () async { 87 + final expected = _makeConvoView('c1'); 88 + when(() => mockRepo.unmuteConvo(any())).thenAnswer((_) async => expected); 89 + 90 + final result = await mockRepo.unmuteConvo('c1'); 91 + 92 + expect(result.id, 'c1'); 93 + }); 94 + 95 + test('updateRead completes', () async { 96 + when(() => mockRepo.updateRead(any())).thenAnswer((_) async {}); 97 + 98 + await mockRepo.updateRead('c1'); 99 + 100 + verify(() => mockRepo.updateRead('c1')).called(1); 101 + }); 102 + 103 + test('sendMessage returns MessageView', () async { 104 + final expected = _makeMessageView('msg-1', 'Hello'); 105 + when(() => mockRepo.sendMessage(any(), any())).thenAnswer((_) async => expected); 106 + 107 + final result = await mockRepo.sendMessage('c1', 'Hello'); 108 + 109 + expect(result.id, 'msg-1'); 110 + expect(result.text, 'Hello'); 111 + }); 112 + }); 113 + } 114 + 115 + ConvoView _makeConvoView(String id) => ConvoView(id: id, rev: 'rev-1', members: [], muted: false, unreadCount: 0); 116 + 117 + MessageView _makeMessageView(String id, String text) => MessageView( 118 + id: id, 119 + rev: 'rev-1', 120 + text: text, 121 + sender: const MessageViewSender(did: 'did:plc:user'), 122 + sentAt: DateTime.utc(2026, 3, 15), 123 + );
+109
test/features/moderation/data/moderation_service_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:bluesky/bluesky.dart'; 5 + import 'package:bluesky/moderation.dart' as bsky_moderation; 6 + import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 8 + 9 + /// Tests for ModerationService. 10 + /// 11 + /// Note: Since Bluesky, ActorService, and LabelerService are sealed/base 12 + /// classes, we cannot mock them with mocktail. Instead, we test the 13 + /// ModerationService's behavior through its public API, focusing on: 14 + /// - Default behavior before initialization (null opts) 15 + /// - State management (currentOpts, optsStream) 16 + /// - Moderation methods returning correct types 17 + void main() { 18 + late ModerationService moderationService; 19 + 20 + setUp(() { 21 + moderationService = ModerationService(bluesky: Bluesky.anonymous()); 22 + }); 23 + 24 + tearDown(() { 25 + moderationService.dispose(); 26 + }); 27 + 28 + group('ModerationService', () { 29 + group('initial state', () { 30 + test('currentOpts is null before initialize', () { 31 + expect(moderationService.currentOpts, isNull); 32 + }); 33 + 34 + test('optsStream is a broadcast stream', () { 35 + expect(moderationService.optsStream.isBroadcast, isTrue); 36 + }); 37 + }); 38 + 39 + group('moderatePost', () { 40 + test('returns ModerationDecision when opts are null', () { 41 + final result = moderationService.moderatePost(_makeSamplePostView()); 42 + expect(result, isA<bsky_moderation.ModerationDecision>()); 43 + }); 44 + }); 45 + 46 + group('moderateProfile', () { 47 + test('returns ModerationDecision with ProfileView when opts are null', () { 48 + final result = moderationService.moderateProfile( 49 + const ProfileView(did: 'did:plc:user', handle: 'user.bsky.social'), 50 + ); 51 + expect(result, isA<bsky_moderation.ModerationDecision>()); 52 + }); 53 + }); 54 + 55 + group('moderateProfileBasic', () { 56 + test('returns ModerationDecision with ProfileViewBasic when opts are null', () { 57 + final result = moderationService.moderateProfileBasic( 58 + const ProfileViewBasic(did: 'did:plc:user', handle: 'user.bsky.social'), 59 + ); 60 + expect(result, isA<bsky_moderation.ModerationDecision>()); 61 + }); 62 + }); 63 + 64 + group('moderateProfileDetailed', () { 65 + test('returns ModerationDecision with ProfileViewDetailed when opts are null', () { 66 + const profile = ProfileViewDetailed(did: 'did:plc:user', handle: 'user.bsky.social'); 67 + final result = moderationService.moderateProfileDetailed(profile); 68 + expect(result, isA<bsky_moderation.ModerationDecision>()); 69 + }); 70 + }); 71 + 72 + group('dispose', () { 73 + test('can be called without error', () { 74 + expect(() => moderationService.dispose(), returnsNormally); 75 + }); 76 + 77 + test('closes the opts stream', () async { 78 + moderationService.dispose(); 79 + final isDone = await moderationService.optsStream.isEmpty; 80 + expect(isDone, isTrue); 81 + }); 82 + }); 83 + 84 + group('ModerationOpts', () { 85 + test('can be constructed with prefs and empty labelDefs', () { 86 + const prefs = bsky_moderation.ModerationPrefs( 87 + adultContentEnabled: false, 88 + labels: {}, 89 + labelers: [], 90 + mutedWords: [], 91 + hiddenPosts: [], 92 + ); 93 + const opts = bsky_moderation.ModerationOpts(prefs: prefs); 94 + expect(opts.prefs.adultContentEnabled, isFalse); 95 + expect(opts.labelDefs, isEmpty); 96 + }); 97 + }); 98 + }); 99 + } 100 + 101 + PostView _makeSamplePostView() { 102 + return PostView( 103 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), 104 + cid: 'cid-123', 105 + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 106 + record: const {r'$type': 'app.bsky.feed.post', 'text': 'Hello world'}, 107 + indexedAt: DateTime.utc(2026, 3, 15), 108 + ); 109 + }
-1
test/features/settings/presentation/about_screen_test.dart
··· 108 108 await tester.pumpWidget(buildSubject()); 109 109 await tester.pump(); 110 110 111 - // GitHub SVG, Tangled SVG, email icon — wrapped in _LinkIcon (InkWell) 112 111 expect(find.byType(InkWell), findsNWidgets(3)); 113 112 }); 114 113 });