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

feat: post interactions

Changed files
+1100 -29
lib
test
src
features
feeds
infrastructure
presentation
infrastructure
+1 -1
lib/src/features/feeds/application/feed_content_notifier.g.dart
··· 65 65 } 66 66 } 67 67 68 - String _$feedContentNotifierHash() => r'a227f64fce3a48aa8ef51d0ab265822339feb477'; 68 + String _$feedContentNotifierHash() => r'7cacaa3328b52126a1d3b51bd439f3d1c7e896ee'; 69 69 70 70 /// Notifier for managing feed content (posts from the active feed). 71 71 ///
+3 -3
lib/src/features/feeds/application/feed_providers.g.dart
··· 78 78 AllFeedsNotifier create() => AllFeedsNotifier(); 79 79 } 80 80 81 - String _$allFeedsNotifierHash() => r'150e08e3ea64c0a368a0d0515eb73edadb1bfe4e'; 81 + String _$allFeedsNotifierHash() => r'9c7c7493ab2f646d37af9b8fedbaba49336be33e'; 82 82 83 83 /// Notifier for watching all saved feeds reactively. 84 84 ··· 128 128 PinnedFeedsNotifier create() => PinnedFeedsNotifier(); 129 129 } 130 130 131 - String _$pinnedFeedsNotifierHash() => r'b21dee72c84c29e80e0f073b019e768fd7952a67'; 131 + String _$pinnedFeedsNotifierHash() => r'ee2ab5b56dc7437709f959204e7b2d88a28c2ef6'; 132 132 133 133 /// Notifier for watching pinned feeds reactively. 134 134 ··· 385 385 } 386 386 } 387 387 388 - String _$activeFeedHash() => r'37de39d7b43cf52c4aa886da362bf217116c6874'; 388 + String _$activeFeedHash() => r'b68c4cb42ba06f9b57ad01bc4a7f891690948013'; 389 389 390 390 /// Notifier for tracking the currently active feed. 391 391 ///
+62
lib/src/features/feeds/application/post_interaction_providers.dart
··· 1 + import 'package:lazurite/src/app/providers.dart'; 2 + import 'package:lazurite/src/core/utils/logger_provider.dart'; 3 + import 'package:lazurite/src/features/auth/application/auth_providers.dart'; 4 + import 'package:lazurite/src/features/auth/domain/auth_state.dart'; 5 + import 'package:lazurite/src/features/feeds/infrastructure/post_interaction_repository.dart'; 6 + import 'package:lazurite/src/infrastructure/db/app_database.dart'; 7 + import 'package:lazurite/src/infrastructure/network/providers.dart'; 8 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 9 + 10 + part 'post_interaction_providers.g.dart'; 11 + 12 + /// Domain model for post interaction state (mirrors PostInteraction entity). 13 + class PostInteractionData { 14 + PostInteractionData({ 15 + required this.postUri, 16 + this.likeUri, 17 + this.repostUri, 18 + this.bookmarkUri, 19 + required this.bookmarked, 20 + required this.threadMuted, 21 + }); 22 + 23 + final String postUri; 24 + final String? likeUri; 25 + final String? repostUri; 26 + final String? bookmarkUri; 27 + final bool bookmarked; 28 + final bool threadMuted; 29 + 30 + static PostInteractionData fromEntity(PostInteraction entity) { 31 + return PostInteractionData( 32 + postUri: entity.postUri, 33 + likeUri: entity.likeUri, 34 + repostUri: entity.repostUri, 35 + bookmarkUri: entity.bookmarkUri, 36 + bookmarked: entity.bookmarked, 37 + threadMuted: entity.threadMuted, 38 + ); 39 + } 40 + } 41 + 42 + @riverpod 43 + PostInteractionRepository postInteractionRepository(Ref ref) { 44 + return PostInteractionRepository( 45 + ref.watch(xrpcClientProvider), 46 + ref.watch(appDatabaseProvider).postInteractionsDao, 47 + ref.watch(loggerProvider('PostInteractionRepository')), 48 + ); 49 + } 50 + 51 + @riverpod 52 + Stream<PostInteractionData?> postInteractionState(Ref ref, String postUri) { 53 + final authState = ref.watch(authProvider); 54 + final ownerDid = (authState is AuthStateAuthenticated) ? authState.session.did : null; 55 + if (ownerDid == null) return Stream.value(null); 56 + 57 + final dao = ref.watch(appDatabaseProvider).postInteractionsDao; 58 + return dao.watchInteraction(postUri, ownerDid).map((entity) { 59 + if (entity == null) return null; 60 + return PostInteractionData.fromEntity(entity); 61 + }); 62 + }
+130
lib/src/features/feeds/application/post_interaction_providers.g.dart
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'post_interaction_providers.dart'; 4 + 5 + // ************************************************************************** 6 + // RiverpodGenerator 7 + // ************************************************************************** 8 + 9 + // GENERATED CODE - DO NOT MODIFY BY HAND 10 + // ignore_for_file: type=lint, type=warning 11 + 12 + @ProviderFor(postInteractionRepository) 13 + final postInteractionRepositoryProvider = PostInteractionRepositoryProvider._(); 14 + 15 + final class PostInteractionRepositoryProvider 16 + extends 17 + $FunctionalProvider< 18 + PostInteractionRepository, 19 + PostInteractionRepository, 20 + PostInteractionRepository 21 + > 22 + with $Provider<PostInteractionRepository> { 23 + PostInteractionRepositoryProvider._() 24 + : super( 25 + from: null, 26 + argument: null, 27 + retry: null, 28 + name: r'postInteractionRepositoryProvider', 29 + isAutoDispose: true, 30 + dependencies: null, 31 + $allTransitiveDependencies: null, 32 + ); 33 + 34 + @override 35 + String debugGetCreateSourceHash() => _$postInteractionRepositoryHash(); 36 + 37 + @$internal 38 + @override 39 + $ProviderElement<PostInteractionRepository> $createElement($ProviderPointer pointer) => 40 + $ProviderElement(pointer); 41 + 42 + @override 43 + PostInteractionRepository create(Ref ref) { 44 + return postInteractionRepository(ref); 45 + } 46 + 47 + /// {@macro riverpod.override_with_value} 48 + Override overrideWithValue(PostInteractionRepository value) { 49 + return $ProviderOverride( 50 + origin: this, 51 + providerOverride: $SyncValueProvider<PostInteractionRepository>(value), 52 + ); 53 + } 54 + } 55 + 56 + String _$postInteractionRepositoryHash() => r'1b78f513934cb5a5d8634fa72f1f59ee8744232a'; 57 + 58 + @ProviderFor(postInteractionState) 59 + final postInteractionStateProvider = PostInteractionStateFamily._(); 60 + 61 + final class PostInteractionStateProvider 62 + extends 63 + $FunctionalProvider< 64 + AsyncValue<PostInteractionData?>, 65 + PostInteractionData?, 66 + Stream<PostInteractionData?> 67 + > 68 + with $FutureModifier<PostInteractionData?>, $StreamProvider<PostInteractionData?> { 69 + PostInteractionStateProvider._({ 70 + required PostInteractionStateFamily super.from, 71 + required String super.argument, 72 + }) : super( 73 + retry: null, 74 + name: r'postInteractionStateProvider', 75 + isAutoDispose: true, 76 + dependencies: null, 77 + $allTransitiveDependencies: null, 78 + ); 79 + 80 + @override 81 + String debugGetCreateSourceHash() => _$postInteractionStateHash(); 82 + 83 + @override 84 + String toString() { 85 + return r'postInteractionStateProvider' 86 + '' 87 + '($argument)'; 88 + } 89 + 90 + @$internal 91 + @override 92 + $StreamProviderElement<PostInteractionData?> $createElement($ProviderPointer pointer) => 93 + $StreamProviderElement(pointer); 94 + 95 + @override 96 + Stream<PostInteractionData?> create(Ref ref) { 97 + final argument = this.argument as String; 98 + return postInteractionState(ref, argument); 99 + } 100 + 101 + @override 102 + bool operator ==(Object other) { 103 + return other is PostInteractionStateProvider && other.argument == argument; 104 + } 105 + 106 + @override 107 + int get hashCode { 108 + return argument.hashCode; 109 + } 110 + } 111 + 112 + String _$postInteractionStateHash() => r'7bee7e79699aa2f5295dd1bcee5d207634b9a6eb'; 113 + 114 + final class PostInteractionStateFamily extends $Family 115 + with $FunctionalFamilyOverride<Stream<PostInteractionData?>, String> { 116 + PostInteractionStateFamily._() 117 + : super( 118 + retry: null, 119 + name: r'postInteractionStateProvider', 120 + dependencies: null, 121 + $allTransitiveDependencies: null, 122 + isAutoDispose: true, 123 + ); 124 + 125 + PostInteractionStateProvider call(String postUri) => 126 + PostInteractionStateProvider._(argument: postUri, from: this); 127 + 128 + @override 129 + String toString() => r'postInteractionStateProvider'; 130 + }
+271
lib/src/features/feeds/infrastructure/post_interaction_repository.dart
··· 1 + import 'package:drift/drift.dart'; 2 + import 'package:lazurite/src/core/utils/logger.dart'; 3 + import 'package:lazurite/src/infrastructure/db/app_database.dart'; 4 + import 'package:lazurite/src/infrastructure/db/daos/post_interactions_dao.dart'; 5 + import 'package:lazurite/src/infrastructure/network/xrpc_client.dart'; 6 + 7 + /// Repository for managing post interactions (like, repost, bookmark). 8 + /// 9 + /// Handles both remote API calls and local state persistence with optimistic updates. 10 + class PostInteractionRepository { 11 + PostInteractionRepository(this._api, this._dao, this._logger); 12 + 13 + final XrpcClient _api; 14 + final PostInteractionsDao _dao; 15 + final Logger _logger; 16 + 17 + /// Likes a post. 18 + Future<void> like(String postUri, String postCid, String ownerDid) async { 19 + _logger.info('Liking post', {'uri': postUri, 'owner': ownerDid}); 20 + 21 + final tempUri = 'temp-${DateTime.now().millisecondsSinceEpoch}'; 22 + await _dao.upsertInteraction( 23 + PostInteractionsCompanion.insert( 24 + postUri: postUri, 25 + ownerDid: ownerDid, 26 + likeUri: Value(tempUri), 27 + updatedAt: DateTime.now(), 28 + ), 29 + ); 30 + 31 + try { 32 + final response = await _api.call( 33 + 'com.atproto.repo.createRecord', 34 + body: { 35 + 'repo': ownerDid, 36 + 'collection': 'app.bsky.feed.like', 37 + 'record': { 38 + r'$type': 'app.bsky.feed.like', 39 + 'subject': {'uri': postUri, 'cid': postCid}, 40 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 41 + }, 42 + }, 43 + ); 44 + 45 + final realUri = response['uri'] as String; 46 + await _dao.upsertInteraction( 47 + PostInteractionsCompanion.insert( 48 + postUri: postUri, 49 + ownerDid: ownerDid, 50 + likeUri: Value(realUri), 51 + updatedAt: DateTime.now(), 52 + ), 53 + ); 54 + } catch (e, stack) { 55 + _logger.error('Failed to like post', e, stack); 56 + 57 + await _dao.upsertInteraction( 58 + PostInteractionsCompanion.insert( 59 + postUri: postUri, 60 + ownerDid: ownerDid, 61 + likeUri: const Value(null), 62 + updatedAt: DateTime.now(), 63 + ), 64 + ); 65 + rethrow; 66 + } 67 + } 68 + 69 + /// Unlikes a post. 70 + Future<void> unlike(String postUri, String likeUri, String ownerDid) async { 71 + _logger.info('Unliking post', {'uri': postUri, 'likeUri': likeUri}); 72 + 73 + await _dao.upsertInteraction( 74 + PostInteractionsCompanion.insert( 75 + postUri: postUri, 76 + ownerDid: ownerDid, 77 + likeUri: const Value(null), 78 + updatedAt: DateTime.now(), 79 + ), 80 + ); 81 + 82 + try { 83 + final parts = likeUri.split('/'); 84 + final rkey = parts.last; 85 + 86 + await _api.call( 87 + 'com.atproto.repo.deleteRecord', 88 + body: {'repo': ownerDid, 'collection': 'app.bsky.feed.like', 'rkey': rkey}, 89 + ); 90 + } catch (e, stack) { 91 + _logger.error('Failed to unlike post', e, stack); 92 + 93 + await _dao.upsertInteraction( 94 + PostInteractionsCompanion.insert( 95 + postUri: postUri, 96 + ownerDid: ownerDid, 97 + likeUri: Value(likeUri), 98 + updatedAt: DateTime.now(), 99 + ), 100 + ); 101 + rethrow; 102 + } 103 + } 104 + 105 + /// Reposts a post. 106 + Future<void> repost(String postUri, String postCid, String ownerDid) async { 107 + _logger.info('Reposting post', {'uri': postUri, 'owner': ownerDid}); 108 + 109 + final tempUri = 'temp-${DateTime.now().millisecondsSinceEpoch}'; 110 + await _dao.upsertInteraction( 111 + PostInteractionsCompanion.insert( 112 + postUri: postUri, 113 + ownerDid: ownerDid, 114 + repostUri: Value(tempUri), 115 + updatedAt: DateTime.now(), 116 + ), 117 + ); 118 + 119 + try { 120 + final response = await _api.call( 121 + 'com.atproto.repo.createRecord', 122 + body: { 123 + 'repo': ownerDid, 124 + 'collection': 'app.bsky.feed.repost', 125 + 'record': { 126 + r'$type': 'app.bsky.feed.repost', 127 + 'subject': {'uri': postUri, 'cid': postCid}, 128 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 129 + }, 130 + }, 131 + ); 132 + 133 + final realUri = response['uri'] as String; 134 + await _dao.upsertInteraction( 135 + PostInteractionsCompanion.insert( 136 + postUri: postUri, 137 + ownerDid: ownerDid, 138 + repostUri: Value(realUri), 139 + updatedAt: DateTime.now(), 140 + ), 141 + ); 142 + } catch (e, stack) { 143 + _logger.error('Failed to repost post', e, stack); 144 + 145 + await _dao.upsertInteraction( 146 + PostInteractionsCompanion.insert( 147 + postUri: postUri, 148 + ownerDid: ownerDid, 149 + repostUri: const Value(null), 150 + updatedAt: DateTime.now(), 151 + ), 152 + ); 153 + rethrow; 154 + } 155 + } 156 + 157 + /// Unreposts a post. 158 + Future<void> unrepost(String postUri, String repostUri, String ownerDid) async { 159 + _logger.info('Unreposting post', {'uri': postUri, 'repostUri': repostUri}); 160 + 161 + await _dao.upsertInteraction( 162 + PostInteractionsCompanion.insert( 163 + postUri: postUri, 164 + ownerDid: ownerDid, 165 + repostUri: const Value(null), 166 + updatedAt: DateTime.now(), 167 + ), 168 + ); 169 + 170 + try { 171 + final parts = repostUri.split('/'); 172 + final rkey = parts.last; 173 + 174 + await _api.call( 175 + 'com.atproto.repo.deleteRecord', 176 + body: {'repo': ownerDid, 'collection': 'app.bsky.feed.repost', 'rkey': rkey}, 177 + ); 178 + } catch (e, stack) { 179 + _logger.error('Failed to unrepost post', e, stack); 180 + 181 + await _dao.upsertInteraction( 182 + PostInteractionsCompanion.insert( 183 + postUri: postUri, 184 + ownerDid: ownerDid, 185 + repostUri: Value(repostUri), 186 + updatedAt: DateTime.now(), 187 + ), 188 + ); 189 + rethrow; 190 + } 191 + } 192 + 193 + /// Bookmarks a post. 194 + Future<void> bookmark(String postUri, String postCid, String ownerDid) async { 195 + _logger.info('Bookmarking post', {'uri': postUri}); 196 + 197 + await _dao.upsertInteraction( 198 + PostInteractionsCompanion.insert( 199 + postUri: postUri, 200 + ownerDid: ownerDid, 201 + bookmarked: const Value(true), 202 + bookmarkUri: const Value('temp-bookmark'), 203 + updatedAt: DateTime.now(), 204 + ), 205 + ); 206 + 207 + try { 208 + final response = await _api.call( 209 + 'app.bsky.bookmark.createBookmark', 210 + body: { 211 + 'subject': {'uri': postUri, 'cid': postCid}, 212 + }, 213 + ); 214 + 215 + final uri = response['uri'] as String; 216 + await _dao.upsertInteraction( 217 + PostInteractionsCompanion.insert( 218 + postUri: postUri, 219 + ownerDid: ownerDid, 220 + bookmarked: const Value(true), 221 + bookmarkUri: Value(uri), 222 + updatedAt: DateTime.now(), 223 + ), 224 + ); 225 + } catch (e, stack) { 226 + _logger.error('Failed to bookmark post', e, stack); 227 + 228 + await _dao.upsertInteraction( 229 + PostInteractionsCompanion.insert( 230 + postUri: postUri, 231 + ownerDid: ownerDid, 232 + bookmarked: const Value(false), 233 + bookmarkUri: const Value(null), 234 + updatedAt: DateTime.now(), 235 + ), 236 + ); 237 + rethrow; 238 + } 239 + } 240 + 241 + /// Unbookmarks a post. 242 + Future<void> unbookmark(String postUri, String bookmarkUri, String ownerDid) async { 243 + _logger.info('Unbookmarking post', {'uri': postUri, 'bookmarkUri': bookmarkUri}); 244 + 245 + await _dao.upsertInteraction( 246 + PostInteractionsCompanion.insert( 247 + postUri: postUri, 248 + ownerDid: ownerDid, 249 + bookmarked: const Value(false), 250 + bookmarkUri: const Value(null), 251 + updatedAt: DateTime.now(), 252 + ), 253 + ); 254 + 255 + try { 256 + await _api.call('app.bsky.bookmark.deleteBookmark', body: {'uri': postUri}); 257 + } catch (e, stack) { 258 + _logger.error('Failed to unbookmark post', e, stack); 259 + await _dao.upsertInteraction( 260 + PostInteractionsCompanion.insert( 261 + postUri: postUri, 262 + ownerDid: ownerDid, 263 + bookmarked: const Value(true), 264 + bookmarkUri: Value(bookmarkUri), 265 + updatedAt: DateTime.now(), 266 + ), 267 + ); 268 + rethrow; 269 + } 270 + } 271 + }
+52 -3
lib/src/features/feeds/presentation/screens/widgets/feed_post_card.dart
··· 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:go_router/go_router.dart'; 6 6 import 'package:lazurite/src/core/domain/content_label.dart'; 7 + import 'package:lazurite/src/features/auth/application/auth_providers.dart'; 8 + import 'package:lazurite/src/features/auth/domain/auth_state.dart'; 9 + import 'package:lazurite/src/features/feeds/application/post_interaction_providers.dart'; 7 10 import 'package:lazurite/src/features/feeds/presentation/widgets/post/content_warning.dart'; 8 11 import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_actions_row.dart'; 9 12 import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_body.dart'; ··· 84 87 replyCount: item.post.replyCount, 85 88 repostCount: item.post.repostCount, 86 89 likeCount: item.post.likeCount, 87 - viewerLikeUri: item.post.viewerLikeUri, 88 - viewerRepostUri: item.post.viewerRepostUri, 89 - viewerBookmarked: item.post.viewerBookmarked, 90 + viewerLikeUri: item.interaction?.likeUri ?? item.post.viewerLikeUri, 91 + viewerRepostUri: item.interaction?.repostUri ?? item.post.viewerRepostUri, 92 + viewerBookmarked: item.interaction?.bookmarked ?? item.post.viewerBookmarked, 93 + onReply: () { 94 + final encodedUri = Uri.encodeComponent(item.post.uri); 95 + GoRouter.of(context).push('/compose?replyTo=$encodedUri'); 96 + }, 97 + onRepost: () async { 98 + final repo = ref.read(postInteractionRepositoryProvider); 99 + final auth = ref.read(authProvider); 100 + final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null; 101 + if (ownerDid == null) return; 102 + 103 + final repostUri = item.interaction?.repostUri ?? item.post.viewerRepostUri; 104 + if (repostUri != null) { 105 + await repo.unrepost(item.post.uri, repostUri, ownerDid); 106 + } else { 107 + await repo.repost(item.post.uri, item.post.cid, ownerDid); 108 + } 109 + }, 110 + onLike: () async { 111 + final repo = ref.read(postInteractionRepositoryProvider); 112 + final auth = ref.read(authProvider); 113 + final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null; 114 + if (ownerDid == null) return; 115 + 116 + final likeUri = item.interaction?.likeUri ?? item.post.viewerLikeUri; 117 + if (likeUri != null) { 118 + await repo.unlike(item.post.uri, likeUri, ownerDid); 119 + } else { 120 + await repo.like(item.post.uri, item.post.cid, ownerDid); 121 + } 122 + }, 123 + onBookmark: () async { 124 + final repo = ref.read(postInteractionRepositoryProvider); 125 + final auth = ref.read(authProvider); 126 + final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null; 127 + if (ownerDid == null) return; 128 + 129 + final bookmarked = item.interaction?.bookmarked ?? item.post.viewerBookmarked; 130 + if (bookmarked) { 131 + final bookmarkUri = item.interaction?.bookmarkUri; 132 + if (bookmarkUri != null) { 133 + await repo.unbookmark(item.post.uri, bookmarkUri, ownerDid); 134 + } 135 + } else { 136 + await repo.bookmark(item.post.uri, item.post.cid, ownerDid); 137 + } 138 + }, 90 139 ), 91 140 ], 92 141 );
+100 -13
lib/src/features/feeds/presentation/widgets/post/post_actions_row.dart
··· 9 9 this.viewerLikeUri, 10 10 this.viewerRepostUri, 11 11 this.viewerBookmarked = false, 12 + this.onReply, 13 + this.onRepost, 14 + this.onLike, 15 + this.onBookmark, 16 + this.onMore, 12 17 super.key, 13 18 }); 14 19 ··· 25 30 /// Whether viewer has bookmarked this post. 26 31 final bool viewerBookmarked; 27 32 33 + final VoidCallback? onReply; 34 + final VoidCallback? onRepost; 35 + final VoidCallback? onLike; 36 + final VoidCallback? onBookmark; 37 + final VoidCallback? onMore; 38 + 28 39 @override 29 40 Widget build(BuildContext context) { 30 41 final isLiked = viewerLikeUri != null; ··· 33 44 return Row( 34 45 mainAxisAlignment: MainAxisAlignment.spaceBetween, 35 46 children: [ 36 - _ActionItem(icon: Icons.chat_bubble_outline, count: replyCount), 47 + _ActionItem( 48 + icon: Icons.chat_bubble_outline, 49 + count: replyCount, 50 + onTap: onReply, 51 + tooltip: 'Reply', 52 + ), 37 53 _ActionItem( 38 54 icon: Icons.repeat, 39 55 count: repostCount, 40 56 isActive: isReposted, 41 57 activeColor: Colors.green, 58 + onTap: onRepost, 59 + tooltip: isReposted ? 'Unrepost' : 'Repost', 42 60 ), 43 61 _ActionItem( 44 62 icon: isLiked ? Icons.favorite : Icons.favorite_border, 45 63 count: likeCount, 46 64 isActive: isLiked, 47 65 activeColor: Colors.red, 66 + onTap: onLike, 67 + tooltip: isLiked ? 'Unlike' : 'Like', 48 68 ), 49 69 _ActionItem( 50 70 icon: viewerBookmarked ? Icons.bookmark : Icons.bookmark_border, 51 71 count: 0, 52 72 isActive: viewerBookmarked, 53 73 activeColor: Colors.amber, 74 + onTap: onBookmark, 75 + tooltip: viewerBookmarked ? 'Remove Bookmark' : 'Bookmark', 54 76 ), 55 - const Icon(Icons.more_horiz, size: 18, color: AppColors.textSecondary), 77 + IconButton( 78 + onPressed: onMore, 79 + icon: const Icon(Icons.more_horiz, size: 18), 80 + color: AppColors.textSecondary, 81 + tooltip: 'More', 82 + padding: EdgeInsets.zero, 83 + constraints: const BoxConstraints(), 84 + ), 56 85 ], 57 86 ); 58 87 } 59 88 } 60 89 61 - class _ActionItem extends StatelessWidget { 90 + class _ActionItem extends StatefulWidget { 62 91 const _ActionItem({ 63 92 required this.icon, 64 - required this.count, 93 + this.count = 0, 65 94 this.isActive = false, 66 95 this.activeColor, 96 + this.onTap, 97 + this.tooltip, 67 98 }); 68 99 69 100 final IconData icon; 70 101 final int count; 71 102 final bool isActive; 72 103 final Color? activeColor; 104 + final VoidCallback? onTap; 105 + final String? tooltip; 106 + 107 + @override 108 + State<_ActionItem> createState() => _ActionItemState(); 109 + } 110 + 111 + class _ActionItemState extends State<_ActionItem> with SingleTickerProviderStateMixin { 112 + late AnimationController _controller; 113 + late Animation<double> _scaleAnimation; 114 + 115 + @override 116 + void initState() { 117 + super.initState(); 118 + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 100)); 119 + _scaleAnimation = Tween<double>( 120 + begin: 1.0, 121 + end: 1.2, 122 + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); 123 + } 124 + 125 + @override 126 + void dispose() { 127 + _controller.dispose(); 128 + super.dispose(); 129 + } 130 + 131 + void _handleTap() { 132 + if (widget.onTap == null) return; 133 + 134 + _controller.forward().then((_) => _controller.reverse()); 135 + widget.onTap!(); 136 + } 73 137 74 138 @override 75 139 Widget build(BuildContext context) { 76 - final color = isActive ? activeColor ?? AppColors.textSecondary : AppColors.textSecondary; 140 + final color = widget.isActive 141 + ? widget.activeColor ?? AppColors.textSecondary 142 + : AppColors.textSecondary; 77 143 78 - return Row( 79 - children: [ 80 - Icon(icon, size: 18, color: color), 81 - if (count > 0) ...[ 82 - const SizedBox(width: 4), 83 - Text(_formatCount(count), style: TextStyle(color: color, fontSize: 13)), 84 - ], 85 - ], 144 + return Tooltip( 145 + message: widget.tooltip ?? '', 146 + child: InkWell( 147 + onTap: _handleTap, 148 + borderRadius: BorderRadius.circular(20), 149 + child: Padding( 150 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 151 + child: Row( 152 + mainAxisSize: MainAxisSize.min, 153 + children: [ 154 + ScaleTransition( 155 + scale: _scaleAnimation, 156 + child: Icon(widget.icon, size: 18, color: color), 157 + ), 158 + if (widget.count > 0) ...[ 159 + const SizedBox(width: 4), 160 + Text( 161 + _formatCount(widget.count), 162 + style: TextStyle( 163 + color: color, 164 + fontSize: 13, 165 + fontWeight: widget.isActive ? FontWeight.bold : FontWeight.normal, 166 + ), 167 + ), 168 + ], 169 + ], 170 + ), 171 + ), 172 + ), 86 173 ); 87 174 } 88 175
+1 -1
lib/src/features/notifications/application/notifications_notifier.g.dart
··· 48 48 NotificationsNotifier create() => NotificationsNotifier(); 49 49 } 50 50 51 - String _$notificationsNotifierHash() => r'650ebb9034f46b19f7440fa0b1ceb5aff49d4431'; 51 + String _$notificationsNotifierHash() => r'67a09107637de31ef8a7e59d2f9c1d9d34487e74'; 52 52 53 53 /// Notifier for managing notification list state. 54 54 ///
+54 -3
lib/src/features/profile/presentation/widgets/pinned_post_card.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/src/features/auth/application/auth_providers.dart'; 5 + import 'package:lazurite/src/features/auth/domain/auth_state.dart'; 6 + import 'package:lazurite/src/features/feeds/application/post_interaction_providers.dart'; 3 7 import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_actions_row.dart'; 4 8 import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_body.dart'; 5 9 import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_embeds.dart'; ··· 16 20 Widget build(BuildContext context, WidgetRef ref) { 17 21 final theme = Theme.of(context); 18 22 final postAsync = ref.watch(pinnedPostProvider(postUri)); 23 + final interaction = ref.watch(postInteractionStateProvider(postUri)).value; 19 24 20 25 return postAsync.when( 21 26 data: (item) { ··· 63 68 replyCount: item.replyCount, 64 69 repostCount: item.repostCount, 65 70 likeCount: item.likeCount, 66 - viewerLikeUri: item.viewerLikeUri, 67 - viewerRepostUri: item.viewerRepostUri, 68 - viewerBookmarked: item.viewerBookmarked, 71 + viewerLikeUri: interaction?.likeUri ?? item.viewerLikeUri, 72 + viewerRepostUri: interaction?.repostUri ?? item.viewerRepostUri, 73 + viewerBookmarked: interaction?.bookmarked ?? item.viewerBookmarked, 74 + onReply: () { 75 + final encodedUri = Uri.encodeComponent(postUri); 76 + GoRouter.of(context).push('/compose?replyTo=$encodedUri'); 77 + }, 78 + onRepost: () async { 79 + final repo = ref.read(postInteractionRepositoryProvider); 80 + final auth = ref.read(authProvider); 81 + final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null; 82 + if (ownerDid == null) return; 83 + 84 + final repostUri = interaction?.repostUri ?? item.viewerRepostUri; 85 + if (repostUri != null) { 86 + await repo.unrepost(postUri, repostUri, ownerDid); 87 + } else { 88 + await repo.repost(postUri, item.cid, ownerDid); 89 + } 90 + }, 91 + onLike: () async { 92 + final repo = ref.read(postInteractionRepositoryProvider); 93 + final auth = ref.read(authProvider); 94 + final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null; 95 + if (ownerDid == null) return; 96 + 97 + final likeUri = interaction?.likeUri ?? item.viewerLikeUri; 98 + if (likeUri != null) { 99 + await repo.unlike(postUri, likeUri, ownerDid); 100 + } else { 101 + await repo.like(postUri, item.cid, ownerDid); 102 + } 103 + }, 104 + onBookmark: () async { 105 + final repo = ref.read(postInteractionRepositoryProvider); 106 + final auth = ref.read(authProvider); 107 + final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null; 108 + if (ownerDid == null) return; 109 + 110 + final bookmarked = interaction?.bookmarked ?? item.viewerBookmarked; 111 + if (bookmarked) { 112 + final bookmarkUri = interaction?.bookmarkUri; 113 + if (bookmarkUri != null) { 114 + await repo.unbookmark(postUri, bookmarkUri, ownerDid); 115 + } 116 + } else { 117 + await repo.bookmark(postUri, item.cid, ownerDid); 118 + } 119 + }, 69 120 ), 70 121 ], 71 122 ),
+71 -2
lib/src/infrastructure/db/app_database.g.dart
··· 7054 7054 type: DriftSqlType.string, 7055 7055 requiredDuringInsert: false, 7056 7056 ); 7057 + static const VerificationMeta _bookmarkUriMeta = const VerificationMeta('bookmarkUri'); 7058 + @override 7059 + late final GeneratedColumn<String> bookmarkUri = GeneratedColumn<String>( 7060 + 'bookmark_uri', 7061 + aliasedName, 7062 + true, 7063 + type: DriftSqlType.string, 7064 + requiredDuringInsert: false, 7065 + ); 7057 7066 static const VerificationMeta _bookmarkedMeta = const VerificationMeta('bookmarked'); 7058 7067 @override 7059 7068 late final GeneratedColumn<bool> bookmarked = GeneratedColumn<bool>( ··· 7091 7100 ownerDid, 7092 7101 likeUri, 7093 7102 repostUri, 7103 + bookmarkUri, 7094 7104 bookmarked, 7095 7105 threadMuted, 7096 7106 updatedAt, ··· 7129 7139 repostUri.isAcceptableOrUnknown(data['repost_uri']!, _repostUriMeta), 7130 7140 ); 7131 7141 } 7142 + if (data.containsKey('bookmark_uri')) { 7143 + context.handle( 7144 + _bookmarkUriMeta, 7145 + bookmarkUri.isAcceptableOrUnknown(data['bookmark_uri']!, _bookmarkUriMeta), 7146 + ); 7147 + } 7132 7148 if (data.containsKey('bookmarked')) { 7133 7149 context.handle( 7134 7150 _bookmarkedMeta, ··· 7174 7190 DriftSqlType.string, 7175 7191 data['${effectivePrefix}repost_uri'], 7176 7192 ), 7193 + bookmarkUri: attachedDatabase.typeMapping.read( 7194 + DriftSqlType.string, 7195 + data['${effectivePrefix}bookmark_uri'], 7196 + ), 7177 7197 bookmarked: attachedDatabase.typeMapping.read( 7178 7198 DriftSqlType.bool, 7179 7199 data['${effectivePrefix}bookmarked'], ··· 7208 7228 /// AT URI of the repost record (if reposted). 7209 7229 final String? repostUri; 7210 7230 7231 + /// AT URI of the bookmark record (if bookmarked). 7232 + final String? bookmarkUri; 7233 + 7211 7234 /// Whether the post is bookmarked. 7212 7235 final bool bookmarked; 7213 7236 ··· 7221 7244 required this.ownerDid, 7222 7245 this.likeUri, 7223 7246 this.repostUri, 7247 + this.bookmarkUri, 7224 7248 required this.bookmarked, 7225 7249 required this.threadMuted, 7226 7250 required this.updatedAt, ··· 7236 7260 if (!nullToAbsent || repostUri != null) { 7237 7261 map['repost_uri'] = Variable<String>(repostUri); 7238 7262 } 7263 + if (!nullToAbsent || bookmarkUri != null) { 7264 + map['bookmark_uri'] = Variable<String>(bookmarkUri); 7265 + } 7239 7266 map['bookmarked'] = Variable<bool>(bookmarked); 7240 7267 map['thread_muted'] = Variable<bool>(threadMuted); 7241 7268 map['updated_at'] = Variable<DateTime>(updatedAt); ··· 7248 7275 ownerDid: Value(ownerDid), 7249 7276 likeUri: likeUri == null && nullToAbsent ? const Value.absent() : Value(likeUri), 7250 7277 repostUri: repostUri == null && nullToAbsent ? const Value.absent() : Value(repostUri), 7278 + bookmarkUri: bookmarkUri == null && nullToAbsent ? const Value.absent() : Value(bookmarkUri), 7251 7279 bookmarked: Value(bookmarked), 7252 7280 threadMuted: Value(threadMuted), 7253 7281 updatedAt: Value(updatedAt), ··· 7261 7289 ownerDid: serializer.fromJson<String>(json['ownerDid']), 7262 7290 likeUri: serializer.fromJson<String?>(json['likeUri']), 7263 7291 repostUri: serializer.fromJson<String?>(json['repostUri']), 7292 + bookmarkUri: serializer.fromJson<String?>(json['bookmarkUri']), 7264 7293 bookmarked: serializer.fromJson<bool>(json['bookmarked']), 7265 7294 threadMuted: serializer.fromJson<bool>(json['threadMuted']), 7266 7295 updatedAt: serializer.fromJson<DateTime>(json['updatedAt']), ··· 7274 7303 'ownerDid': serializer.toJson<String>(ownerDid), 7275 7304 'likeUri': serializer.toJson<String?>(likeUri), 7276 7305 'repostUri': serializer.toJson<String?>(repostUri), 7306 + 'bookmarkUri': serializer.toJson<String?>(bookmarkUri), 7277 7307 'bookmarked': serializer.toJson<bool>(bookmarked), 7278 7308 'threadMuted': serializer.toJson<bool>(threadMuted), 7279 7309 'updatedAt': serializer.toJson<DateTime>(updatedAt), ··· 7285 7315 String? ownerDid, 7286 7316 Value<String?> likeUri = const Value.absent(), 7287 7317 Value<String?> repostUri = const Value.absent(), 7318 + Value<String?> bookmarkUri = const Value.absent(), 7288 7319 bool? bookmarked, 7289 7320 bool? threadMuted, 7290 7321 DateTime? updatedAt, ··· 7293 7324 ownerDid: ownerDid ?? this.ownerDid, 7294 7325 likeUri: likeUri.present ? likeUri.value : this.likeUri, 7295 7326 repostUri: repostUri.present ? repostUri.value : this.repostUri, 7327 + bookmarkUri: bookmarkUri.present ? bookmarkUri.value : this.bookmarkUri, 7296 7328 bookmarked: bookmarked ?? this.bookmarked, 7297 7329 threadMuted: threadMuted ?? this.threadMuted, 7298 7330 updatedAt: updatedAt ?? this.updatedAt, ··· 7303 7335 ownerDid: data.ownerDid.present ? data.ownerDid.value : this.ownerDid, 7304 7336 likeUri: data.likeUri.present ? data.likeUri.value : this.likeUri, 7305 7337 repostUri: data.repostUri.present ? data.repostUri.value : this.repostUri, 7338 + bookmarkUri: data.bookmarkUri.present ? data.bookmarkUri.value : this.bookmarkUri, 7306 7339 bookmarked: data.bookmarked.present ? data.bookmarked.value : this.bookmarked, 7307 7340 threadMuted: data.threadMuted.present ? data.threadMuted.value : this.threadMuted, 7308 7341 updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, ··· 7316 7349 ..write('ownerDid: $ownerDid, ') 7317 7350 ..write('likeUri: $likeUri, ') 7318 7351 ..write('repostUri: $repostUri, ') 7352 + ..write('bookmarkUri: $bookmarkUri, ') 7319 7353 ..write('bookmarked: $bookmarked, ') 7320 7354 ..write('threadMuted: $threadMuted, ') 7321 7355 ..write('updatedAt: $updatedAt') ··· 7324 7358 } 7325 7359 7326 7360 @override 7327 - int get hashCode => 7328 - Object.hash(postUri, ownerDid, likeUri, repostUri, bookmarked, threadMuted, updatedAt); 7361 + int get hashCode => Object.hash( 7362 + postUri, 7363 + ownerDid, 7364 + likeUri, 7365 + repostUri, 7366 + bookmarkUri, 7367 + bookmarked, 7368 + threadMuted, 7369 + updatedAt, 7370 + ); 7329 7371 @override 7330 7372 bool operator ==(Object other) => 7331 7373 identical(this, other) || ··· 7334 7376 other.ownerDid == this.ownerDid && 7335 7377 other.likeUri == this.likeUri && 7336 7378 other.repostUri == this.repostUri && 7379 + other.bookmarkUri == this.bookmarkUri && 7337 7380 other.bookmarked == this.bookmarked && 7338 7381 other.threadMuted == this.threadMuted && 7339 7382 other.updatedAt == this.updatedAt); ··· 7344 7387 final Value<String> ownerDid; 7345 7388 final Value<String?> likeUri; 7346 7389 final Value<String?> repostUri; 7390 + final Value<String?> bookmarkUri; 7347 7391 final Value<bool> bookmarked; 7348 7392 final Value<bool> threadMuted; 7349 7393 final Value<DateTime> updatedAt; ··· 7353 7397 this.ownerDid = const Value.absent(), 7354 7398 this.likeUri = const Value.absent(), 7355 7399 this.repostUri = const Value.absent(), 7400 + this.bookmarkUri = const Value.absent(), 7356 7401 this.bookmarked = const Value.absent(), 7357 7402 this.threadMuted = const Value.absent(), 7358 7403 this.updatedAt = const Value.absent(), ··· 7363 7408 required String ownerDid, 7364 7409 this.likeUri = const Value.absent(), 7365 7410 this.repostUri = const Value.absent(), 7411 + this.bookmarkUri = const Value.absent(), 7366 7412 this.bookmarked = const Value.absent(), 7367 7413 this.threadMuted = const Value.absent(), 7368 7414 required DateTime updatedAt, ··· 7375 7421 Expression<String>? ownerDid, 7376 7422 Expression<String>? likeUri, 7377 7423 Expression<String>? repostUri, 7424 + Expression<String>? bookmarkUri, 7378 7425 Expression<bool>? bookmarked, 7379 7426 Expression<bool>? threadMuted, 7380 7427 Expression<DateTime>? updatedAt, ··· 7385 7432 if (ownerDid != null) 'owner_did': ownerDid, 7386 7433 if (likeUri != null) 'like_uri': likeUri, 7387 7434 if (repostUri != null) 'repost_uri': repostUri, 7435 + if (bookmarkUri != null) 'bookmark_uri': bookmarkUri, 7388 7436 if (bookmarked != null) 'bookmarked': bookmarked, 7389 7437 if (threadMuted != null) 'thread_muted': threadMuted, 7390 7438 if (updatedAt != null) 'updated_at': updatedAt, ··· 7397 7445 Value<String>? ownerDid, 7398 7446 Value<String?>? likeUri, 7399 7447 Value<String?>? repostUri, 7448 + Value<String?>? bookmarkUri, 7400 7449 Value<bool>? bookmarked, 7401 7450 Value<bool>? threadMuted, 7402 7451 Value<DateTime>? updatedAt, ··· 7407 7456 ownerDid: ownerDid ?? this.ownerDid, 7408 7457 likeUri: likeUri ?? this.likeUri, 7409 7458 repostUri: repostUri ?? this.repostUri, 7459 + bookmarkUri: bookmarkUri ?? this.bookmarkUri, 7410 7460 bookmarked: bookmarked ?? this.bookmarked, 7411 7461 threadMuted: threadMuted ?? this.threadMuted, 7412 7462 updatedAt: updatedAt ?? this.updatedAt, ··· 7429 7479 if (repostUri.present) { 7430 7480 map['repost_uri'] = Variable<String>(repostUri.value); 7431 7481 } 7482 + if (bookmarkUri.present) { 7483 + map['bookmark_uri'] = Variable<String>(bookmarkUri.value); 7484 + } 7432 7485 if (bookmarked.present) { 7433 7486 map['bookmarked'] = Variable<bool>(bookmarked.value); 7434 7487 } ··· 7451 7504 ..write('ownerDid: $ownerDid, ') 7452 7505 ..write('likeUri: $likeUri, ') 7453 7506 ..write('repostUri: $repostUri, ') 7507 + ..write('bookmarkUri: $bookmarkUri, ') 7454 7508 ..write('bookmarked: $bookmarked, ') 7455 7509 ..write('threadMuted: $threadMuted, ') 7456 7510 ..write('updatedAt: $updatedAt, ') ··· 17580 17634 required String ownerDid, 17581 17635 Value<String?> likeUri, 17582 17636 Value<String?> repostUri, 17637 + Value<String?> bookmarkUri, 17583 17638 Value<bool> bookmarked, 17584 17639 Value<bool> threadMuted, 17585 17640 required DateTime updatedAt, ··· 17591 17646 Value<String> ownerDid, 17592 17647 Value<String?> likeUri, 17593 17648 Value<String?> repostUri, 17649 + Value<String?> bookmarkUri, 17594 17650 Value<bool> bookmarked, 17595 17651 Value<bool> threadMuted, 17596 17652 Value<DateTime> updatedAt, ··· 17635 17691 ColumnFilters<String> get repostUri => 17636 17692 $composableBuilder(column: $table.repostUri, builder: (column) => ColumnFilters(column)); 17637 17693 17694 + ColumnFilters<String> get bookmarkUri => 17695 + $composableBuilder(column: $table.bookmarkUri, builder: (column) => ColumnFilters(column)); 17696 + 17638 17697 ColumnFilters<bool> get bookmarked => 17639 17698 $composableBuilder(column: $table.bookmarked, builder: (column) => ColumnFilters(column)); 17640 17699 ··· 17681 17740 17682 17741 ColumnOrderings<String> get repostUri => 17683 17742 $composableBuilder(column: $table.repostUri, builder: (column) => ColumnOrderings(column)); 17743 + 17744 + ColumnOrderings<String> get bookmarkUri => 17745 + $composableBuilder(column: $table.bookmarkUri, builder: (column) => ColumnOrderings(column)); 17684 17746 17685 17747 ColumnOrderings<bool> get bookmarked => 17686 17748 $composableBuilder(column: $table.bookmarked, builder: (column) => ColumnOrderings(column)); ··· 17729 17791 GeneratedColumn<String> get repostUri => 17730 17792 $composableBuilder(column: $table.repostUri, builder: (column) => column); 17731 17793 17794 + GeneratedColumn<String> get bookmarkUri => 17795 + $composableBuilder(column: $table.bookmarkUri, builder: (column) => column); 17796 + 17732 17797 GeneratedColumn<bool> get bookmarked => 17733 17798 $composableBuilder(column: $table.bookmarked, builder: (column) => column); 17734 17799 ··· 17790 17855 Value<String> ownerDid = const Value.absent(), 17791 17856 Value<String?> likeUri = const Value.absent(), 17792 17857 Value<String?> repostUri = const Value.absent(), 17858 + Value<String?> bookmarkUri = const Value.absent(), 17793 17859 Value<bool> bookmarked = const Value.absent(), 17794 17860 Value<bool> threadMuted = const Value.absent(), 17795 17861 Value<DateTime> updatedAt = const Value.absent(), ··· 17799 17865 ownerDid: ownerDid, 17800 17866 likeUri: likeUri, 17801 17867 repostUri: repostUri, 17868 + bookmarkUri: bookmarkUri, 17802 17869 bookmarked: bookmarked, 17803 17870 threadMuted: threadMuted, 17804 17871 updatedAt: updatedAt, ··· 17810 17877 required String ownerDid, 17811 17878 Value<String?> likeUri = const Value.absent(), 17812 17879 Value<String?> repostUri = const Value.absent(), 17880 + Value<String?> bookmarkUri = const Value.absent(), 17813 17881 Value<bool> bookmarked = const Value.absent(), 17814 17882 Value<bool> threadMuted = const Value.absent(), 17815 17883 required DateTime updatedAt, ··· 17819 17887 ownerDid: ownerDid, 17820 17888 likeUri: likeUri, 17821 17889 repostUri: repostUri, 17890 + bookmarkUri: bookmarkUri, 17822 17891 bookmarked: bookmarked, 17823 17892 threadMuted: threadMuted, 17824 17893 updatedAt: updatedAt,
+17 -2
lib/src/infrastructure/db/daos/feed_content_dao.dart
··· 10 10 /// This replaces TimelineDao with clearer naming that aligns with BlueSky's 11 11 /// feed-based architecture. Uses the same underlying tables (Posts, Profiles, 12 12 /// FeedContentItems, FeedCursors). 13 - @DriftAccessor(tables: [Posts, Profiles, ProfileRelationships, FeedContentItems, FeedCursors]) 13 + @DriftAccessor( 14 + tables: [Posts, Profiles, ProfileRelationships, PostInteractions, FeedContentItems, FeedCursors], 15 + ) 14 16 class FeedContentDao extends DatabaseAccessor<AppDatabase> with _$FeedContentDaoMixin { 15 17 FeedContentDao(super.db); 16 18 ··· 64 66 innerJoin(posts, posts.uri.equalsExp(feedContentItems.postUri)), 65 67 innerJoin(profiles, profiles.did.equalsExp(posts.authorDid)), 66 68 leftOuterJoin(profileRelationships, profileRelationships.profileDid.equalsExp(profiles.did)), 69 + leftOuterJoin( 70 + postInteractions, 71 + postInteractions.postUri.equalsExp(posts.uri) & 72 + postInteractions.ownerDid.equalsExp(feedContentItems.ownerDid), 73 + ), 67 74 ]); 68 75 69 76 query.where( ··· 78 85 post: row.readTable(posts), 79 86 author: row.readTable(profiles), 80 87 relationship: row.readTableOrNull(profileRelationships), 88 + interaction: row.readTableOrNull(postInteractions), 81 89 reason: feedItem.reason, 82 90 ); 83 91 }).toList(); ··· 131 139 /// 132 140 /// Combines post content, author profile, and feed-specific data like repost reason. 133 141 class FeedPost { 134 - FeedPost({required this.post, required this.author, this.relationship, this.reason}); 142 + FeedPost({ 143 + required this.post, 144 + required this.author, 145 + this.relationship, 146 + this.interaction, 147 + this.reason, 148 + }); 135 149 136 150 final Post post; 137 151 final Profile author; 138 152 final ProfileRelationship? relationship; 153 + final PostInteraction? interaction; 139 154 140 155 /// Feed-specific reason (e.g., repost information as JSON). 141 156 final String? reason;
+1
lib/src/infrastructure/db/daos/feed_content_dao.g.dart
··· 7 7 $ProfilesTable get profiles => attachedDatabase.profiles; 8 8 $PostsTable get posts => attachedDatabase.posts; 9 9 $ProfileRelationshipsTable get profileRelationships => attachedDatabase.profileRelationships; 10 + $PostInteractionsTable get postInteractions => attachedDatabase.postInteractions; 10 11 $FeedContentItemsTable get feedContentItems => attachedDatabase.feedContentItems; 11 12 $FeedCursorsTable get feedCursors => attachedDatabase.feedCursors; 12 13 }
+3
lib/src/infrastructure/db/tables.dart
··· 44 44 /// AT URI of the repost record (if reposted). 45 45 TextColumn get repostUri => text().nullable()(); 46 46 47 + /// AT URI of the bookmark record (if bookmarked). 48 + TextColumn get bookmarkUri => text().nullable()(); 49 + 47 50 /// Whether the post is bookmarked. 48 51 BoolColumn get bookmarked => boolean().withDefault(const Constant(false))(); 49 52
+18
lib/src/infrastructure/network/endpoint_registry.dart
··· 167 167 hostKind: HostKind.pds, 168 168 requiresAuth: true, 169 169 ), 170 + 'app.bsky.bookmark.createBookmark': const EndpointMeta( 171 + nsid: 'app.bsky.bookmark.createBookmark', 172 + method: HttpMethod.post, 173 + hostKind: HostKind.pds, 174 + requiresAuth: true, 175 + ), 176 + 'app.bsky.bookmark.deleteBookmark': const EndpointMeta( 177 + nsid: 'app.bsky.bookmark.deleteBookmark', 178 + method: HttpMethod.post, 179 + hostKind: HostKind.pds, 180 + requiresAuth: true, 181 + ), 182 + 'app.bsky.bookmark.getBookmarks': const EndpointMeta( 183 + nsid: 'app.bsky.bookmark.getBookmarks', 184 + method: HttpMethod.get, 185 + hostKind: HostKind.pds, 186 + requiresAuth: true, 187 + ), 170 188 171 189 'com.atproto.server.createSession': const EndpointMeta( 172 190 nsid: 'com.atproto.server.createSession',
+1 -1
lib/src/infrastructure/network/providers.g.dart
··· 52 52 } 53 53 } 54 54 55 - String _$dioPublicHash() => r'32be6de7d8ba289bb1cf4af536dbb9263494bbae'; 55 + String _$dioPublicHash() => r'440eb166523bcd20276af10eafbe6735a28a655a'; 56 56 57 57 /// Provides the PDS Dio client for authenticated API access. 58 58 ///
+146
test/src/features/feeds/infrastructure/post_interaction_repository_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/src/core/utils/logger.dart'; 3 + import 'package:lazurite/src/features/feeds/infrastructure/post_interaction_repository.dart'; 4 + import 'package:lazurite/src/infrastructure/db/app_database.dart'; 5 + import 'package:lazurite/src/infrastructure/db/daos/post_interactions_dao.dart'; 6 + import 'package:lazurite/src/infrastructure/network/xrpc_client.dart'; 7 + import 'package:mocktail/mocktail.dart'; 8 + 9 + class MockXrpcClient extends Mock implements XrpcClient {} 10 + 11 + class MockPostInteractionsDao extends Mock implements PostInteractionsDao {} 12 + 13 + class MockLogger extends Mock implements Logger {} 14 + 15 + void main() { 16 + late MockXrpcClient api; 17 + late MockPostInteractionsDao dao; 18 + late MockLogger logger; 19 + late PostInteractionRepository repository; 20 + 21 + const ownerDid = 'did:plc:owner'; 22 + const postUri = 'at://did:plc:author/app.bsky.feed.post/123'; 23 + const postCid = 'bafyreih57axcs6fyz2p2y27i3m2j2'; 24 + 25 + setUpAll(() { 26 + registerFallbackValue( 27 + PostInteractionsCompanion.insert( 28 + postUri: 'dummy', 29 + ownerDid: 'dummy', 30 + updatedAt: DateTime.now(), 31 + ), 32 + ); 33 + }); 34 + 35 + setUp(() { 36 + api = MockXrpcClient(); 37 + dao = MockPostInteractionsDao(); 38 + logger = MockLogger(); 39 + repository = PostInteractionRepository(api, dao, logger); 40 + 41 + when(() => logger.info(any(), any())).thenReturn(null); 42 + when(() => logger.error(any(), any(), any())).thenReturn(null); 43 + }); 44 + 45 + group('PostInteractionRepository', () { 46 + test('like optimistic update and API call success', () async { 47 + when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {}); 48 + when( 49 + () => api.call('com.atproto.repo.createRecord', body: any(named: 'body')), 50 + ).thenAnswer((_) async => {'uri': 'at://did:plc:owner/app.bsky.feed.like/456'}); 51 + 52 + await repository.like(postUri, postCid, ownerDid); 53 + 54 + verify(() => dao.upsertInteraction(any())).called(2); 55 + verify(() => api.call('com.atproto.repo.createRecord', body: any(named: 'body'))).called(1); 56 + }); 57 + 58 + test('like rollback on API failure', () async { 59 + when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {}); 60 + when( 61 + () => api.call('com.atproto.repo.createRecord', body: any(named: 'body')), 62 + ).thenThrow(Exception('API Error')); 63 + 64 + try { 65 + await repository.like(postUri, postCid, ownerDid); 66 + fail('Should have thrown'); 67 + } catch (_) { 68 + /* Expected */ 69 + } 70 + 71 + verify(() => dao.upsertInteraction(any())).called(2); 72 + }); 73 + 74 + test('repost rollback on API failure', () async { 75 + when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {}); 76 + when( 77 + () => api.call('com.atproto.repo.createRecord', body: any(named: 'body')), 78 + ).thenThrow(Exception('API Error')); 79 + 80 + try { 81 + await repository.repost(postUri, postCid, ownerDid); 82 + fail('Should have thrown'); 83 + } catch (_) { 84 + /* Expected */ 85 + } 86 + 87 + verify(() => dao.upsertInteraction(any())).called(2); 88 + }); 89 + 90 + test('bookmark rollback on API failure', () async { 91 + when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {}); 92 + when( 93 + () => api.call('app.bsky.bookmark.createBookmark', body: any(named: 'body')), 94 + ).thenThrow(Exception('API Error')); 95 + 96 + try { 97 + await repository.bookmark(postUri, postCid, ownerDid); 98 + fail('Should have thrown'); 99 + } catch (_) { 100 + /* Expected */ 101 + } 102 + 103 + verify(() => dao.upsertInteraction(any())).called(2); 104 + }); 105 + 106 + test('repost optimistic update and API call success', () async { 107 + when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {}); 108 + when( 109 + () => api.call('com.atproto.repo.createRecord', body: any(named: 'body')), 110 + ).thenAnswer((_) async => {'uri': 'at://did:plc:owner/app.bsky.feed.repost/789'}); 111 + 112 + await repository.repost(postUri, postCid, ownerDid); 113 + 114 + verify(() => dao.upsertInteraction(any())).called(2); 115 + verify(() => api.call('com.atproto.repo.createRecord', body: any(named: 'body'))).called(1); 116 + }); 117 + 118 + test('bookmark optimistic update and API call success', () async { 119 + when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {}); 120 + when( 121 + () => api.call('app.bsky.bookmark.createBookmark', body: any(named: 'body')), 122 + ).thenAnswer((_) async => {'uri': 'at://did:plc:owner/app.bsky.bookmark/abc'}); 123 + 124 + await repository.bookmark(postUri, postCid, ownerDid); 125 + 126 + verify(() => dao.upsertInteraction(any())).called(2); 127 + verify( 128 + () => api.call('app.bsky.bookmark.createBookmark', body: any(named: 'body')), 129 + ).called(1); 130 + }); 131 + 132 + test('unbookmark optimistic update and API call success', () async { 133 + when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {}); 134 + when( 135 + () => api.call('app.bsky.bookmark.deleteBookmark', body: any(named: 'body')), 136 + ).thenAnswer((_) async => {}); 137 + 138 + await repository.unbookmark(postUri, 'at://did:plc:owner/app.bsky.bookmark/abc', ownerDid); 139 + 140 + verify(() => dao.upsertInteraction(any())).called(1); 141 + verify( 142 + () => api.call('app.bsky.bookmark.deleteBookmark', body: any(named: 'body')), 143 + ).called(1); 144 + }); 145 + }); 146 + }
+69
test/src/features/feeds/presentation/widgets/post/post_actions_row_test.dart
··· 33 33 expect(find.text('0'), findsNothing); 34 34 }); 35 35 36 + testWidgets('renders all action counts correctly', (tester) async { 37 + await tester.pumpWidget( 38 + const MaterialApp( 39 + home: Scaffold(body: PostActionsRow(replyCount: 10, repostCount: 20, likeCount: 30)), 40 + ), 41 + ); 42 + 43 + expect(find.text('10'), findsOneWidget); 44 + expect(find.text('20'), findsOneWidget); 45 + expect(find.text('30'), findsOneWidget); 46 + }); 47 + 48 + testWidgets('applies active color when interacted', (tester) async { 49 + await tester.pumpWidget( 50 + const MaterialApp( 51 + home: Scaffold( 52 + body: PostActionsRow( 53 + viewerLikeUri: 'some-uri', 54 + viewerRepostUri: 'some-uri', 55 + viewerBookmarked: true, 56 + ), 57 + ), 58 + ), 59 + ); 60 + 61 + final likeIcon = find.byIcon(Icons.favorite); 62 + final repostIcon = find.byIcon(Icons.repeat); 63 + final bookmarkIcon = find.byIcon(Icons.bookmark); 64 + 65 + expect(likeIcon, findsOneWidget); 66 + expect(repostIcon, findsOneWidget); 67 + expect(bookmarkIcon, findsOneWidget); 68 + 69 + final likeWidget = tester.widget<Icon>(likeIcon); 70 + expect(likeWidget.color, Colors.red); 71 + }); 72 + 73 + testWidgets('calls callbacks when items are tapped', (tester) async { 74 + bool likeCalled = false; 75 + bool repostCalled = false; 76 + bool bookmarkCalled = false; 77 + bool replyCalled = false; 78 + 79 + await tester.pumpWidget( 80 + MaterialApp( 81 + home: Scaffold( 82 + body: PostActionsRow( 83 + onLike: () => likeCalled = true, 84 + onRepost: () => repostCalled = true, 85 + onBookmark: () => bookmarkCalled = true, 86 + onReply: () => replyCalled = true, 87 + ), 88 + ), 89 + ), 90 + ); 91 + 92 + await tester.tap(find.byTooltip('Like')); 93 + expect(likeCalled, isTrue); 94 + 95 + await tester.tap(find.byTooltip('Repost')); 96 + expect(repostCalled, isTrue); 97 + 98 + await tester.tap(find.byTooltip('Bookmark')); 99 + expect(bookmarkCalled, isTrue); 100 + 101 + await tester.tap(find.byTooltip('Reply')); 102 + expect(replyCalled, isTrue); 103 + }); 104 + 36 105 group('viewer interaction states', () { 37 106 testWidgets('shows filled like icon when viewerLikeUri is present', (tester) async { 38 107 await tester.pumpWidget(
+100
test/src/infrastructure/db/daos/feed_content_dao_test.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:drift/drift.dart' hide isNull, isNotNull; 4 + import 'package:drift/native.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/src/infrastructure/db/app_database.dart'; 7 + import 'package:lazurite/src/infrastructure/db/daos/feed_content_dao.dart'; 8 + 9 + void main() { 10 + late AppDatabase db; 11 + late FeedContentDao dao; 12 + 13 + setUp(() { 14 + db = AppDatabase(NativeDatabase.memory()); 15 + dao = db.feedContentDao; 16 + }); 17 + 18 + tearDown(() async { 19 + await db.close(); 20 + }); 21 + 22 + const ownerDid = 'did:plc:owner'; 23 + const feedKey = 'home'; 24 + const postUri = 'at://did:plc:author/app.bsky.feed.post/123'; 25 + 26 + group('FeedContentDao with Interactions', () { 27 + test('watchFeedContent joins correctly and picks up local interaction', () async { 28 + await db 29 + .into(db.profiles) 30 + .insert( 31 + ProfilesCompanion.insert( 32 + did: 'did:plc:author', 33 + handle: 'author.bsky.social', 34 + indexedAt: Value(DateTime.now()), 35 + ), 36 + ); 37 + 38 + await db 39 + .into(db.posts) 40 + .insert( 41 + PostsCompanion.insert( 42 + uri: postUri, 43 + cid: 'cid123', 44 + authorDid: 'did:plc:author', 45 + record: jsonEncode({'text': 'Hello world'}), 46 + indexedAt: Value(DateTime.now()), 47 + ), 48 + ); 49 + 50 + await db 51 + .into(db.feedContentItems) 52 + .insert( 53 + FeedContentItemsCompanion.insert( 54 + feedKey: feedKey, 55 + postUri: postUri, 56 + ownerDid: ownerDid, 57 + sortKey: DateTime.now().toIso8601String(), 58 + ), 59 + ); 60 + 61 + final firstResults = await dao.watchFeedContent(feedKey, ownerDid).first; 62 + expect(firstResults.length, 1); 63 + expect(firstResults.first.interaction, isNull); 64 + 65 + await db.postInteractionsDao.upsertInteraction( 66 + PostInteractionsCompanion.insert( 67 + postUri: postUri, 68 + ownerDid: ownerDid, 69 + likeUri: const Value('at://did:plc:owner/app.bsky.feed.like/456'), 70 + updatedAt: DateTime.now(), 71 + ), 72 + ); 73 + 74 + final secondResults = await dao.watchFeedContent(feedKey, ownerDid).first; 75 + expect(secondResults.length, 1); 76 + expect(secondResults.first.interaction, isNotNull); 77 + expect( 78 + secondResults.first.interaction?.likeUri, 79 + 'at://did:plc:owner/app.bsky.feed.like/456', 80 + ); 81 + }); 82 + 83 + test('bookmarkUri persistence works', () async { 84 + await db.postInteractionsDao.upsertInteraction( 85 + PostInteractionsCompanion.insert( 86 + postUri: postUri, 87 + ownerDid: ownerDid, 88 + bookmarked: const Value(true), 89 + bookmarkUri: const Value('at://did:plc:owner/app.bsky.bookmark/abc'), 90 + updatedAt: DateTime.now(), 91 + ), 92 + ); 93 + 94 + final interaction = await db.postInteractionsDao.getInteraction(postUri, ownerDid); 95 + expect(interaction, isNotNull); 96 + expect(interaction?.bookmarked, isTrue); 97 + expect(interaction?.bookmarkUri, 'at://did:plc:owner/app.bsky.bookmark/abc'); 98 + }); 99 + }); 100 + }