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: BlackSky topics/BlueSky trending

+1905 -90
+1 -15
docs/tasks/phase-7.md
··· 1 1 --- 2 2 title: Phase 7 Task Breakdown 3 - updated: 2026-04-09 3 + updated: 2026-04-29 4 4 --- 5 5 6 6 # Phase 7 Milestones ··· 86 86 - [x] "Index status" tile - shows indexed post count, "Re-index" button 87 87 - [x] "Max results" slider - 10 to 50, default 20 88 88 - [x] Backfill progress indicator - "Indexing: 142/300 posts..." shown during backfill 89 - 90 - ### Tests 91 - 92 - - [x] Unit tests: `WordPieceTokenizer` - tokenization, padding, truncation, edge cases (empty string, very long text) 93 - - [x] Unit tests: `EmbeddingService` - initialization, embed returns correct dimensions, L2 normalization, dispose cleanup 94 - - [x] Unit tests: `PostTextExtractor` - text concatenation from various post shapes (text-only, images with alt, link cards, combinations) 95 - - [x] Unit tests: `EmbeddingRepository` - upsert, delete, query by account, count 96 - - [x] Unit tests: `LikedPostsRepository` - sync pagination, dedup on known URI, 1000-cap eviction 97 - - [x] Unit tests: `SemanticIndexer` - index/remove/backfill, progress stream, integration with save/like hooks 98 - - [x] Unit tests: `SemanticSearchRepository` - search returns scored results, scope filtering, account isolation 99 - - [x] Unit tests: `SemanticSearchCubit` - debounce, state transitions, scope changes 100 - - [x] Unit tests: `SemanticIndexCubit` - backfill progress, reindex trigger 101 - - [x] Widget tests: search tab renders, query produces results, scope chips filter, relevance badges display, empty/no-results/unavailable states 102 - - [x] Widget tests: settings section renders, toggle enables/disables, progress indicator during backfill, re-index button triggers reindex
+12 -12
docs/tasks/routing.md
··· 20 20 21 21 ## M3 - Trending Surface 22 22 23 - - [ ] Add Home app bar `Trending` action button 24 - - [ ] Add `/trending` route and `TrendingScreen` 25 - - [ ] Implement `getTrendingTopics(limit=10)` fetch path 26 - - [ ] Implement required `getTrends(limit=10)` enrichment path for richer metadata 27 - - [ ] Hide `Suggested` section when provider returns empty list 28 - - [ ] Add loading/empty/error states for trending screen 29 - - [ ] Add analytics/logging for provider and fallback used on trending requests 23 + - [x] Add Home app bar `Trending` action button 24 + - [x] Add `/trending` route and `TrendingScreen` 25 + - [x] Implement `getTrendingTopics(limit=10)` fetch path 26 + - [x] Implement required `getTrends(limit=10)` enrichment path for richer metadata 27 + - [x] Hide `Suggested` section when provider returns empty list 28 + - [x] Add loading/empty/error states for trending screen 29 + - [x] Add analytics/logging for provider and fallback used on trending requests 30 30 31 31 ## M4 - Trend Link Routing 32 32 33 - - [ ] Add provider-aware trend link resolver (`resolveWebLink`) 34 - - [ ] Support `/profile/<actor>/feed/<rkey>` links 35 - - [ ] Support `/topic/<id>` links 36 - - [ ] Degrade unknown link formats to safe external open 37 - - [ ] Add unit tests for link parsing and fallback resolution 33 + - [x] Add provider-aware trend link resolver (`resolveWebLink`) 34 + - [x] Support `/profile/<actor>/feed/<rkey>` links 35 + - [x] Support `/topic/<id>` links 36 + - [x] Degrade unknown link formats to safe external open 37 + - [x] Add unit tests for link parsing and fallback resolution 38 38 39 39 ## M5 - Fallback Engine 40 40
+38
lib/core/network/app_view_router.dart
··· 27 27 28 28 return provider.webBaseUrl.resolve(trimmed); 29 29 } 30 + 31 + TrendLinkResolution resolveTrendLink(String relativeOrAbsolute) { 32 + final externalUri = resolveWebLink(relativeOrAbsolute); 33 + final inAppRoute = _resolveInAppTrendRoute(externalUri); 34 + return TrendLinkResolution(inAppRoute: inAppRoute, externalUri: externalUri); 35 + } 36 + 37 + String? _resolveInAppTrendRoute(Uri externalUri) { 38 + if (externalUri.host.toLowerCase() != provider.webBaseUrl.host.toLowerCase()) { 39 + return null; 40 + } 41 + 42 + final segments = externalUri.pathSegments.where((segment) => segment.isNotEmpty).toList(growable: false); 43 + if (segments.length >= 4 && segments[0] == 'profile' && segments[2] == 'feed') { 44 + final actor = segments[1].trim(); 45 + final rkey = segments[3].trim(); 46 + if (actor.isNotEmpty && rkey.isNotEmpty) { 47 + return '/feed?actor=${Uri.encodeQueryComponent(actor)}&rkey=${Uri.encodeQueryComponent(rkey)}'; 48 + } 49 + } 50 + 51 + if (segments.length >= 2 && segments[0] == 'topic') { 52 + final topic = segments[1].trim(); 53 + if (topic.isNotEmpty) { 54 + return '/topic?topic=${Uri.encodeQueryComponent(topic)}'; 55 + } 56 + return null; 57 + } 58 + 59 + return null; 60 + } 61 + } 62 + 63 + class TrendLinkResolution { 64 + const TrendLinkResolution({required this.inAppRoute, required this.externalUri}); 65 + 66 + final String? inAppRoute; 67 + final Uri externalUri; 30 68 }
+45 -55
lib/core/router/app_router.dart
··· 7 7 import 'package:go_router/go_router.dart'; 8 8 import 'package:lazurite/core/database/app_database.dart'; 9 9 import 'package:lazurite/core/logging/app_logger.dart'; 10 - import 'package:lazurite/core/network/constellation_client.dart'; 11 10 import 'package:lazurite/core/network/app_view_provider.dart'; 11 + import 'package:lazurite/core/network/constellation_client.dart'; 12 12 import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 13 13 import 'package:lazurite/core/router/app_shell.dart'; 14 14 import 'package:lazurite/core/router/fade_through_page.dart'; ··· 19 19 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 20 20 import 'package:lazurite/features/compose/presentation/compose_screen.dart'; 21 21 import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; 22 + import 'package:lazurite/features/feed/presentation/feed_detail_screen.dart'; 22 23 import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; 23 24 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 24 25 import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; ··· 27 28 import 'package:lazurite/features/feed/presentation/media/video_player_screen.dart'; 28 29 import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; 29 30 import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; 31 + import 'package:lazurite/features/feed/presentation/trending_screen.dart'; 30 32 import 'package:lazurite/features/lists/bloc/list_bloc.dart'; 31 33 import 'package:lazurite/features/lists/data/list_repository.dart'; 32 34 import 'package:lazurite/features/lists/presentation/list_detail_screen.dart'; ··· 50 52 import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; 51 53 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 52 54 import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; 55 + import 'package:lazurite/features/search/cubit/topic_cubit.dart'; 53 56 import 'package:lazurite/features/search/data/hashtag_utils.dart'; 54 57 import 'package:lazurite/features/search/data/search_repository.dart'; 55 58 import 'package:lazurite/features/search/presentation/hashtag_screen.dart'; 56 59 import 'package:lazurite/features/search/presentation/search_screen.dart'; 60 + import 'package:lazurite/features/search/presentation/topic_screen.dart'; 57 61 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 58 62 import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; 59 63 import 'package:lazurite/features/settings/data/video_repository.dart'; ··· 68 72 import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; 69 73 import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; 70 74 71 - ComposeRouteArgs parseComposeRouteExtra(Object? extra) { 72 - if (extra is ComposeRouteArgs) { 73 - return extra; 74 - } 75 - 76 - if (extra is Map) { 77 - String? readString(String key) { 78 - final value = extra[key]; 79 - return value is String ? value : null; 80 - } 81 - 82 - int? readInt(String key) { 83 - final value = extra[key]; 84 - if (value is int) { 85 - return value; 86 - } 87 - if (value is String) { 88 - return int.tryParse(value); 89 - } 90 - return null; 91 - } 92 - 93 - Map<String, dynamic>? readMap(String key) { 94 - final value = extra[key]; 95 - if (value is Map<String, dynamic>) { 96 - return value; 97 - } 98 - if (value is Map) { 99 - return Map<String, dynamic>.from(value); 100 - } 101 - return null; 102 - } 103 - 104 - return ComposeRouteArgs( 105 - replyParentUri: readString('replyParentUri'), 106 - replyParentCid: readString('replyParentCid'), 107 - replyRootUri: readString('replyRootUri'), 108 - replyRootCid: readString('replyRootCid'), 109 - replyAuthorHandle: readString('replyAuthorHandle'), 110 - quoteUri: readString('quoteUri'), 111 - quoteCid: readString('quoteCid'), 112 - quoteAuthorHandle: readString('quoteAuthorHandle'), 113 - draftId: readInt('draftId'), 114 - initialText: readString('initialText'), 115 - editPostUri: readString('editPostUri'), 116 - editPostCid: readString('editPostCid'), 117 - editRecord: readMap('editRecord'), 118 - ); 119 - } 120 - 121 - return const ComposeRouteArgs(); 122 - } 123 - 124 75 class AppRouter { 125 76 AppRouter({required this.authBloc, this.navigatorObserver}); 126 77 final AuthBloc authBloc; ··· 165 116 path: '/compose', 166 117 parentNavigatorKey: _rootNavigatorKey, 167 118 pageBuilder: (context, state) { 168 - final args = parseComposeRouteExtra(state.extra); 119 + final args = ComposeRouteArgs.parseExtra(state.extra); 169 120 return _page( 170 121 context, 171 122 state, ··· 214 165 key: ValueKey('hashtag-$normalizedTag'), 215 166 create: (_) => HashtagCubit(searchRepository: context.read<SearchRepository>(), tag: normalizedTag), 216 167 child: HashtagScreen(tag: normalizedTag), 168 + ), 169 + ); 170 + }, 171 + ), 172 + GoRoute( 173 + path: '/topic', 174 + parentNavigatorKey: _rootNavigatorKey, 175 + pageBuilder: (context, state) { 176 + final rawTopic = state.uri.queryParameters['topic'] ?? ''; 177 + final topic = Uri.decodeComponent(rawTopic).trim(); 178 + return _page( 179 + context, 180 + state, 181 + BlocProvider( 182 + key: ValueKey('topic-$topic'), 183 + create: (_) => TopicCubit(searchRepository: context.read<SearchRepository>(), topic: topic), 184 + child: TopicScreen(topic: topic), 217 185 ), 218 186 ); 219 187 }, ··· 359 327 GoRoute( 360 328 path: 'feeds', 361 329 pageBuilder: (context, state) => _page(context, state, const FeedManagementScreen()), 330 + ), 331 + GoRoute( 332 + path: 'feed', 333 + pageBuilder: (context, state) { 334 + final encodedUri = state.uri.queryParameters['uri']; 335 + final encodedActor = state.uri.queryParameters['actor']; 336 + final encodedRkey = state.uri.queryParameters['rkey']; 337 + 338 + AtUri? feedUri; 339 + if (encodedUri != null && encodedUri.trim().isNotEmpty) { 340 + final rawUri = Uri.decodeComponent(encodedUri); 341 + feedUri = AtUri.parse(rawUri); 342 + } 343 + 344 + final actor = encodedActor == null ? null : Uri.decodeComponent(encodedActor); 345 + final rkey = encodedRkey == null ? null : Uri.decodeComponent(encodedRkey); 346 + return _page(context, state, FeedDetailScreen(feedUri: feedUri, actor: actor, rkey: rkey)); 347 + }, 348 + ), 349 + GoRoute( 350 + path: 'trending', 351 + pageBuilder: (context, state) => _page(context, state, const TrendingScreen()), 362 352 ), 363 353 GoRoute( 364 354 path: 'settings',
+52
lib/features/compose/presentation/compose_route_args.dart
··· 1 1 class ComposeRouteArgs { 2 + factory ComposeRouteArgs.parseExtra(Object? extra) { 3 + if (extra is ComposeRouteArgs) { 4 + return extra; 5 + } 6 + 7 + if (extra is Map) { 8 + String? readString(String key) { 9 + final value = extra[key]; 10 + return value is String ? value : null; 11 + } 12 + 13 + int? readInt(String key) { 14 + final value = extra[key]; 15 + if (value is int) { 16 + return value; 17 + } 18 + if (value is String) { 19 + return int.tryParse(value); 20 + } 21 + return null; 22 + } 23 + 24 + Map<String, dynamic>? readMap(String key) { 25 + final value = extra[key]; 26 + if (value is Map<String, dynamic>) { 27 + return value; 28 + } 29 + if (value is Map) { 30 + return Map<String, dynamic>.from(value); 31 + } 32 + return null; 33 + } 34 + 35 + return ComposeRouteArgs( 36 + replyParentUri: readString('replyParentUri'), 37 + replyParentCid: readString('replyParentCid'), 38 + replyRootUri: readString('replyRootUri'), 39 + replyRootCid: readString('replyRootCid'), 40 + replyAuthorHandle: readString('replyAuthorHandle'), 41 + quoteUri: readString('quoteUri'), 42 + quoteCid: readString('quoteCid'), 43 + quoteAuthorHandle: readString('quoteAuthorHandle'), 44 + draftId: readInt('draftId'), 45 + initialText: readString('initialText'), 46 + editPostUri: readString('editPostUri'), 47 + editPostCid: readString('editPostCid'), 48 + editRecord: readMap('editRecord'), 49 + ); 50 + } 51 + 52 + return const ComposeRouteArgs(); 53 + } 2 54 const ComposeRouteArgs({ 3 55 this.replyParentUri, 4 56 this.replyParentCid,
+105
lib/features/feed/data/feed_repository.dart
··· 4 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 5 5 import 'package:bluesky/app_bsky_feed_defs.dart'; 6 6 import 'package:bluesky/app_bsky_feed_getauthorfeed.dart'; 7 + import 'package:bluesky/app_bsky_unspecced_defs.dart'; 7 8 import 'package:bluesky/bluesky.dart'; 8 9 import 'package:lazurite/core/database/app_database.dart'; 10 + import 'package:lazurite/core/logging/app_logger.dart'; 9 11 import 'package:lazurite/core/network/app_view_request_context.dart'; 12 + import 'package:lazurite/features/feed/data/trending_join.dart'; 10 13 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 11 14 12 15 class FeedRepository { ··· 33 36 final AppViewRequestContext _appViewContext; 34 37 35 38 static const String timelineCacheKey = 'timeline'; 39 + static const int _minTrendingLimit = 1; 40 + static const int _maxTrendingLimit = 25; 36 41 37 42 static String cacheKeyForSavedFeed(SavedFeed feed) { 38 43 final feedType = feed.type; ··· 153 158 return response.data.feeds; 154 159 } 155 160 161 + Future<AtUri> resolveFeedGeneratorUri({required String actor, required String rkey}) async { 162 + final normalizedActor = actor.trim(); 163 + final normalizedRkey = rkey.trim(); 164 + if (normalizedActor.isEmpty || normalizedRkey.isEmpty) { 165 + throw ArgumentError('actor and rkey are required to resolve a feed generator URI.'); 166 + } 167 + 168 + if (normalizedActor.startsWith('did:')) { 169 + return AtUri.parse('at://$normalizedActor/app.bsky.feed.generator/$normalizedRkey'); 170 + } 171 + 172 + final response = await _bluesky.actor.getProfile( 173 + actor: normalizedActor, 174 + $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 175 + ); 176 + final did = response.data.did.trim(); 177 + if (did.isEmpty) { 178 + throw StateError('Resolved profile did was empty for actor=$normalizedActor'); 179 + } 180 + return AtUri.parse('at://$did/app.bsky.feed.generator/$normalizedRkey'); 181 + } 182 + 183 + Future<TrendingScreenData> getTrendingScreenData({int limit = 10}) async { 184 + final topicsResult = await getTrendingTopics(limit: limit); 185 + 186 + List<TrendView> trends = const []; 187 + var metadataUnavailable = false; 188 + try { 189 + trends = await getTrends(limit: limit); 190 + } catch (error, stackTrace) { 191 + metadataUnavailable = true; 192 + final provider = _appViewContext.resolveProviderKey(); 193 + log.w( 194 + 'trending.getTrends degraded provider=$provider fallback=none reason=$error', 195 + error: error, 196 + stackTrace: stackTrace, 197 + ); 198 + } 199 + 200 + return TrendingScreenData( 201 + topics: enrichTrendingTopics(topics: topicsResult.topics, trends: trends), 202 + suggested: enrichTrendingTopics(topics: topicsResult.suggested, trends: trends), 203 + metadataUnavailable: metadataUnavailable, 204 + ); 205 + } 206 + 207 + Future<TrendingTopicsResult> getTrendingTopics({int limit = 10}) async { 208 + final clampedLimit = _clampTrendingLimit(limit); 209 + final provider = _appViewContext.resolveProviderKey(); 210 + log.i('trending.getTrendingTopics provider=$provider fallback=none limit=$clampedLimit'); 211 + 212 + final response = await _bluesky.unspecced.getTrendingTopics( 213 + limit: clampedLimit, 214 + $service: _appViewContext.publicServiceHost(), 215 + $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 216 + ); 217 + 218 + return TrendingTopicsResult(topics: response.data.topics, suggested: response.data.suggested); 219 + } 220 + 221 + Future<List<TrendView>> getTrends({int limit = 10}) async { 222 + final clampedLimit = _clampTrendingLimit(limit); 223 + final provider = _appViewContext.resolveProviderKey(); 224 + log.i('trending.getTrends provider=$provider fallback=none limit=$clampedLimit'); 225 + 226 + final response = await _bluesky.unspecced.getTrends( 227 + limit: clampedLimit, 228 + $service: _appViewContext.publicServiceHost(), 229 + $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 230 + ); 231 + return response.data.trends; 232 + } 233 + 234 + int _clampTrendingLimit(int limit) { 235 + if (limit < _minTrendingLimit) { 236 + return _minTrendingLimit; 237 + } 238 + if (limit > _maxTrendingLimit) { 239 + return _maxTrendingLimit; 240 + } 241 + return limit; 242 + } 243 + 156 244 Future<GeneratorView> getFeedGenerator(AtUri feedUri) async { 157 245 final response = await _bluesky.feed.getFeedGenerator( 158 246 feed: feedUri, ··· 208 296 class PreferencesResult { 209 297 PreferencesResult({required this.preferences}); 210 298 final List<UPreferences> preferences; 299 + } 300 + 301 + class TrendingTopicsResult { 302 + TrendingTopicsResult({required this.topics, required this.suggested}); 303 + 304 + final List<TrendingTopic> topics; 305 + final List<TrendingTopic> suggested; 306 + } 307 + 308 + class TrendingScreenData { 309 + TrendingScreenData({required this.topics, required this.suggested, required this.metadataUnavailable}); 310 + 311 + final List<EnrichedTrendingTopic> topics; 312 + final List<EnrichedTrendingTopic> suggested; 313 + final bool metadataUnavailable; 314 + 315 + bool get isEmpty => topics.isEmpty && suggested.isEmpty; 211 316 } 212 317 213 318 enum FeedFilter {
+100
lib/features/feed/data/trending_join.dart
··· 1 + import 'package:bluesky/app_bsky_unspecced_defs.dart'; 2 + 3 + class EnrichedTrendingTopic { 4 + const EnrichedTrendingTopic({required this.topic, this.trend}); 5 + 6 + final TrendingTopic topic; 7 + final TrendView? trend; 8 + } 9 + 10 + List<EnrichedTrendingTopic> enrichTrendingTopics({ 11 + required List<TrendingTopic> topics, 12 + required List<TrendView> trends, 13 + }) { 14 + if (topics.isEmpty) { 15 + return const []; 16 + } 17 + 18 + return topics.map((topic) => EnrichedTrendingTopic(topic: topic, trend: _bestTrendFor(topic, trends))).toList(); 19 + } 20 + 21 + TrendView? _bestTrendFor(TrendingTopic topic, List<TrendView> trends) { 22 + final topicLinkKey = trendLinkJoinKey(topic.link); 23 + final topicFallbackKey = normalizeTrendTopic(topic.topic); 24 + 25 + final parsedMatches = <TrendView>[]; 26 + final fallbackMatches = <TrendView>[]; 27 + 28 + for (final trend in trends) { 29 + final trendLinkKey = trendLinkJoinKey(trend.link); 30 + if (topicLinkKey != null && trendLinkKey == topicLinkKey) { 31 + parsedMatches.add(trend); 32 + continue; 33 + } 34 + 35 + if (normalizeTrendTopic(trend.topic) == topicFallbackKey) { 36 + fallbackMatches.add(trend); 37 + } 38 + } 39 + 40 + if (parsedMatches.isNotEmpty) { 41 + return _pickDeterministicTrend(parsedMatches); 42 + } 43 + 44 + if (fallbackMatches.isNotEmpty) { 45 + return _pickDeterministicTrend(fallbackMatches); 46 + } 47 + 48 + return null; 49 + } 50 + 51 + TrendView _pickDeterministicTrend(List<TrendView> candidates) { 52 + final sorted = List<TrendView>.from(candidates) 53 + ..sort((left, right) { 54 + final startedAtComparison = right.startedAt.compareTo(left.startedAt); 55 + if (startedAtComparison != 0) { 56 + return startedAtComparison; 57 + } 58 + return left.link.compareTo(right.link); 59 + }); 60 + 61 + return sorted.first; 62 + } 63 + 64 + String normalizeTrendTopic(String value) { 65 + final trimmed = value.trim().toLowerCase(); 66 + final withoutHash = trimmed.startsWith('#') ? trimmed.substring(1) : trimmed; 67 + return withoutHash.replaceAll(RegExp(r'\s+'), ' '); 68 + } 69 + 70 + String? trendLinkJoinKey(String rawLink) { 71 + final trimmed = rawLink.trim(); 72 + if (trimmed.isEmpty) { 73 + return null; 74 + } 75 + 76 + final parsed = Uri.tryParse(trimmed); 77 + if (parsed == null) { 78 + return null; 79 + } 80 + 81 + final segments = parsed.pathSegments.where((segment) => segment.isNotEmpty).toList(growable: false); 82 + if (segments.length >= 2 && segments[0] == 'topic') { 83 + final topicId = segments[1].trim(); 84 + if (topicId.isEmpty) { 85 + return null; 86 + } 87 + return 'topic:${topicId.toLowerCase()}'; 88 + } 89 + 90 + if (segments.length >= 4 && segments[0] == 'profile' && segments[2] == 'feed') { 91 + final actor = segments[1].trim(); 92 + final rkey = segments[3].trim(); 93 + if (actor.isEmpty || rkey.isEmpty) { 94 + return null; 95 + } 96 + return 'feed:${actor.toLowerCase()}:${rkey.toLowerCase()}'; 97 + } 98 + 99 + return null; 100 + }
+245
lib/features/feed/presentation/feed_detail_screen.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_feed_defs.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 6 + import 'package:lazurite/features/feed/data/feed_repository.dart'; 7 + import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; 8 + import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 9 + import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 10 + import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 11 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 12 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 13 + 14 + class FeedDetailScreen extends StatefulWidget { 15 + const FeedDetailScreen({super.key, this.feedUri, this.actor, this.rkey}); 16 + 17 + final AtUri? feedUri; 18 + final String? actor; 19 + final String? rkey; 20 + 21 + @override 22 + State<FeedDetailScreen> createState() => _FeedDetailScreenState(); 23 + } 24 + 25 + class _FeedDetailScreenState extends State<FeedDetailScreen> { 26 + final ScrollController _scrollController = ScrollController(); 27 + final Set<String> _seenPostUris = <String>{}; 28 + 29 + GeneratorView? _generator; 30 + final List<FeedViewPost> _posts = []; 31 + String? _cursor; 32 + bool _isLoading = true; 33 + bool _isLoadingMore = false; 34 + String? _errorMessage; 35 + AtUri? _resolvedFeedUri; 36 + 37 + @override 38 + void initState() { 39 + super.initState(); 40 + _scrollController.addListener(_onScroll); 41 + _loadInitial(); 42 + } 43 + 44 + @override 45 + void dispose() { 46 + _scrollController.removeListener(_onScroll); 47 + _scrollController.dispose(); 48 + super.dispose(); 49 + } 50 + 51 + void _onScroll() { 52 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 53 + _loadMore(); 54 + } 55 + } 56 + 57 + Future<void> _loadInitial() async { 58 + _setStateIfMounted(() { 59 + _isLoading = true; 60 + _errorMessage = null; 61 + }); 62 + 63 + final repo = context.read<FeedRepository>(); 64 + AtUri? targetUri; 65 + try { 66 + targetUri = await _resolveTargetUri(repo); 67 + } catch (error) { 68 + _setStateIfMounted(() { 69 + _isLoading = false; 70 + _errorMessage = 'Failed to resolve feed: $error'; 71 + }); 72 + return; 73 + } 74 + 75 + if (targetUri == null) { 76 + _setStateIfMounted(() { 77 + _isLoading = false; 78 + _errorMessage = 'Missing feed identifier.'; 79 + }); 80 + return; 81 + } 82 + 83 + GeneratorView? generator; 84 + try { 85 + generator = await repo.getFeedGenerator(targetUri); 86 + } catch (_) {} 87 + 88 + try { 89 + final result = await repo.getFeed(feedUri: targetUri); 90 + _setStateIfMounted(() { 91 + _resolvedFeedUri = targetUri; 92 + _generator = generator; 93 + _posts 94 + ..clear() 95 + ..addAll(result.posts); 96 + _cursor = result.cursor; 97 + _isLoading = false; 98 + _errorMessage = null; 99 + }); 100 + } catch (error) { 101 + _setStateIfMounted(() { 102 + _resolvedFeedUri = targetUri; 103 + _generator = generator; 104 + _isLoading = false; 105 + _errorMessage = 'Failed to load feed: $error'; 106 + }); 107 + } 108 + } 109 + 110 + Future<void> _loadMore() async { 111 + if (_isLoadingMore || _cursor == null || _isLoading) { 112 + return; 113 + } 114 + 115 + _setStateIfMounted(() => _isLoadingMore = true); 116 + try { 117 + final feedUri = _resolvedFeedUri; 118 + if (feedUri == null) { 119 + _setStateIfMounted(() => _isLoadingMore = false); 120 + return; 121 + } 122 + final result = await context.read<FeedRepository>().getFeed(feedUri: feedUri, cursor: _cursor); 123 + _setStateIfMounted(() { 124 + _posts.addAll(result.posts); 125 + _cursor = result.cursor; 126 + _isLoadingMore = false; 127 + }); 128 + } catch (_) { 129 + _setStateIfMounted(() => _isLoadingMore = false); 130 + } 131 + } 132 + 133 + void _setStateIfMounted(VoidCallback callback) { 134 + if (!mounted) { 135 + return; 136 + } 137 + setState(callback); 138 + } 139 + 140 + String _title() { 141 + final displayName = _generator?.displayName.trim(); 142 + if (displayName != null && displayName.isNotEmpty) { 143 + return displayName; 144 + } 145 + 146 + return 'Feed'; 147 + } 148 + 149 + Future<AtUri?> _resolveTargetUri(FeedRepository repo) async { 150 + final direct = widget.feedUri; 151 + if (direct != null) { 152 + return direct; 153 + } 154 + 155 + final actor = widget.actor?.trim(); 156 + final rkey = widget.rkey?.trim(); 157 + if (actor == null || actor.isEmpty || rkey == null || rkey.isEmpty) { 158 + return null; 159 + } 160 + 161 + return repo.resolveFeedGeneratorUri(actor: actor, rkey: rkey); 162 + } 163 + 164 + String? _subtitle() { 165 + final creator = _generator?.creator; 166 + if (creator == null) { 167 + return null; 168 + } 169 + return creator.displayName?.trim().isNotEmpty == true ? 'by ${creator.displayName}' : 'by ${creator.handle}'; 170 + } 171 + 172 + @override 173 + Widget build(BuildContext context) { 174 + return Scaffold( 175 + appBar: AppBar( 176 + title: Text(_title()), 177 + bottom: _subtitle() == null 178 + ? null 179 + : PreferredSize( 180 + preferredSize: const Size.fromHeight(24), 181 + child: Padding( 182 + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 10), 183 + child: Align( 184 + alignment: Alignment.centerLeft, 185 + child: Text( 186 + _subtitle()!, 187 + style: Theme.of( 188 + context, 189 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 190 + ), 191 + ), 192 + ), 193 + ), 194 + ), 195 + body: _buildBody(), 196 + ); 197 + } 198 + 199 + Widget _buildBody() { 200 + if (_isLoading) { 201 + return const LoadingState(message: 'Loading feed'); 202 + } 203 + 204 + if (_errorMessage != null && _posts.isEmpty) { 205 + return ErrorState( 206 + title: 'Failed to load feed', 207 + message: _errorMessage!, 208 + onRetry: _loadInitial, 209 + icon: Icons.sync_problem_outlined, 210 + ); 211 + } 212 + 213 + if (_posts.isEmpty) { 214 + return const EmptyState(message: 'No posts yet', icon: Icons.article_outlined); 215 + } 216 + 217 + final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 218 + Widget buildCard(int index, PostCardVariant variant) { 219 + final post = _posts[index]; 220 + final postUri = post.post.uri.toString(); 221 + return StaggeredEntrance( 222 + itemKey: postUri, 223 + index: index, 224 + seenKeys: _seenPostUris, 225 + child: PostCardWithActions( 226 + feedViewPost: post, 227 + accountDid: accountDid, 228 + variant: variant, 229 + onDeleted: () { 230 + _setStateIfMounted(() => _posts.removeWhere((p) => p.post.uri.toString() == postUri)); 231 + }, 232 + ), 233 + ); 234 + } 235 + 236 + return FeedLayoutView( 237 + itemCount: _posts.length, 238 + scrollController: _scrollController, 239 + isLoadingMore: _isLoadingMore, 240 + onRefresh: _loadInitial, 241 + gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.grid), 242 + linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.linear), 243 + ); 244 + } 245 + }
+12 -1
lib/features/feed/presentation/home_feed_screen.dart
··· 92 92 return Scaffold( 93 93 appBar: LazuriteAppBar( 94 94 sectionLabel: 'Home', 95 - actions: [IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds'))], 95 + actions: [ 96 + IconButton( 97 + icon: const Icon(Icons.trending_up_outlined), 98 + tooltip: 'Trending', 99 + onPressed: () => context.push('/trending'), 100 + ), 101 + IconButton( 102 + icon: const Icon(Icons.rss_feed), 103 + tooltip: 'Manage Feeds', 104 + onPressed: () => context.push('/feeds'), 105 + ), 106 + ], 96 107 bottom: _FeedTabBar( 97 108 feeds: pinnedFeeds, 98 109 prefsState: prefsState,
+176
lib/features/feed/presentation/trending_screen.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/core/network/app_view_provider.dart'; 5 + import 'package:lazurite/core/network/app_view_router.dart'; 6 + import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; 7 + import 'package:lazurite/features/feed/data/feed_repository.dart'; 8 + import 'package:lazurite/features/feed/data/trending_join.dart'; 9 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 10 + import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 11 + import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 12 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 13 + import 'package:url_launcher/url_launcher.dart'; 14 + 15 + class TrendingScreen extends StatefulWidget { 16 + const TrendingScreen({super.key}); 17 + 18 + @override 19 + State<TrendingScreen> createState() => _TrendingScreenState(); 20 + } 21 + 22 + class _TrendingScreenState extends State<TrendingScreen> { 23 + TrendingScreenData? _data; 24 + bool _loading = true; 25 + String? _errorMessage; 26 + 27 + @override 28 + void initState() { 29 + super.initState(); 30 + _load(); 31 + } 32 + 33 + Future<void> _load() async { 34 + if (!mounted) { 35 + return; 36 + } 37 + 38 + setState(() { 39 + _loading = true; 40 + _errorMessage = null; 41 + }); 42 + 43 + try { 44 + final result = await context.read<FeedRepository>().getTrendingScreenData(limit: 10); 45 + if (!mounted) { 46 + return; 47 + } 48 + 49 + setState(() { 50 + _data = result; 51 + _loading = false; 52 + }); 53 + } catch (error) { 54 + if (!mounted) { 55 + return; 56 + } 57 + 58 + setState(() { 59 + _errorMessage = 'Failed to load trending topics: $error'; 60 + _loading = false; 61 + }); 62 + } 63 + } 64 + 65 + Future<void> _onTapTopic(EnrichedTrendingTopic topic) async { 66 + final providerSetting = context.read<SettingsCubit>().state.appViewProvider; 67 + final provider = AppViewProviders.descriptorForSetting(providerSetting); 68 + final router = AppViewRouter(provider: provider); 69 + final resolution = router.resolveTrendLink(topic.topic.link); 70 + 71 + if (resolution.inAppRoute != null) { 72 + await context.push(resolution.inAppRoute!); 73 + return; 74 + } 75 + 76 + await launchUrl(resolution.externalUri, mode: LaunchMode.externalApplication); 77 + } 78 + 79 + @override 80 + Widget build(BuildContext context) { 81 + return Scaffold( 82 + appBar: const LazuriteAppBar(sectionLabel: 'Trending'), 83 + body: _buildBody(context), 84 + ); 85 + } 86 + 87 + Widget _buildBody(BuildContext context) { 88 + if (_loading) { 89 + return const LoadingState(message: 'Loading trending topics'); 90 + } 91 + 92 + if (_errorMessage != null) { 93 + return ErrorState(title: 'Failed to load trending', message: _errorMessage!, onRetry: _load); 94 + } 95 + 96 + final data = _data; 97 + if (data == null || data.isEmpty) { 98 + return const EmptyState(icon: Icons.trending_up_outlined, message: 'No trending topics right now'); 99 + } 100 + 101 + final rows = <Widget>[ 102 + if (data.metadataUnavailable) 103 + Padding( 104 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), 105 + child: Container( 106 + width: double.infinity, 107 + decoration: BoxDecoration( 108 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 109 + borderRadius: BorderRadius.circular(12), 110 + ), 111 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), 112 + child: const Text('Metadata temporarily unavailable'), 113 + ), 114 + ), 115 + const _SectionHeader(title: 'Topics'), 116 + ...data.topics.map((item) => _TrendTile(item: item, onTap: () => _onTapTopic(item))), 117 + if (data.suggested.isNotEmpty) const _SectionHeader(title: 'Suggested'), 118 + ...data.suggested.map((item) => _TrendTile(item: item, onTap: () => _onTapTopic(item))), 119 + const SizedBox(height: 16), 120 + ]; 121 + 122 + return RefreshIndicator( 123 + onRefresh: _load, 124 + child: ListView(children: rows), 125 + ); 126 + } 127 + } 128 + 129 + class _SectionHeader extends StatelessWidget { 130 + const _SectionHeader({required this.title}); 131 + 132 + final String title; 133 + 134 + @override 135 + Widget build(BuildContext context) { 136 + return Padding( 137 + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), 138 + child: Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 139 + ); 140 + } 141 + } 142 + 143 + class _TrendTile extends StatelessWidget { 144 + const _TrendTile({required this.item, required this.onTap}); 145 + 146 + final EnrichedTrendingTopic item; 147 + final VoidCallback onTap; 148 + 149 + @override 150 + Widget build(BuildContext context) { 151 + final trend = item.trend; 152 + final subtitleLines = <String>[]; 153 + final description = item.topic.description?.trim(); 154 + if (description != null && description.isNotEmpty) { 155 + subtitleLines.add(description); 156 + } 157 + if (trend != null) { 158 + subtitleLines.add('${trend.postCount} posts'); 159 + if (trend.category != null && trend.category!.trim().isNotEmpty) { 160 + subtitleLines.add('Category: ${trend.category}'); 161 + } 162 + } 163 + 164 + return Padding( 165 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), 166 + child: Card( 167 + child: ListTile( 168 + onTap: onTap, 169 + title: Text(item.topic.displayName?.trim().isNotEmpty == true ? item.topic.displayName! : item.topic.topic), 170 + subtitle: subtitleLines.isEmpty ? null : Text(subtitleLines.join(' · ')), 171 + trailing: const Icon(Icons.open_in_new), 172 + ), 173 + ), 174 + ); 175 + } 176 + }
+229
lib/features/search/cubit/topic_cubit.dart
··· 1 + import 'package:bluesky/app_bsky_feed_defs.dart'; 2 + import 'package:equatable/equatable.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/search/data/search_repository.dart'; 5 + 6 + enum TopicSort { 7 + top, 8 + latest; 9 + 10 + String get apiValue => name; 11 + 12 + String get label => switch (this) { 13 + TopicSort.top => 'Top', 14 + TopicSort.latest => 'Latest', 15 + }; 16 + } 17 + 18 + enum TopicTimelineStatus { initial, loading, loaded, error } 19 + 20 + class TopicTimeline extends Equatable { 21 + const TopicTimeline._({ 22 + required this.status, 23 + this.posts = const [], 24 + this.cursor, 25 + this.errorMessage, 26 + this.isLoadingMore = false, 27 + }); 28 + 29 + const TopicTimeline.initial() : this._(status: TopicTimelineStatus.initial); 30 + 31 + final TopicTimelineStatus status; 32 + final List<PostView> posts; 33 + final String? cursor; 34 + final String? errorMessage; 35 + final bool isLoadingMore; 36 + 37 + bool get isLoading => status == TopicTimelineStatus.loading; 38 + bool get hasError => status == TopicTimelineStatus.error; 39 + 40 + static const Object _unset = Object(); 41 + 42 + TopicTimeline copyWith({ 43 + TopicTimelineStatus? status, 44 + List<PostView>? posts, 45 + Object? cursor = _unset, 46 + Object? errorMessage = _unset, 47 + bool? isLoadingMore, 48 + }) { 49 + return TopicTimeline._( 50 + status: status ?? this.status, 51 + posts: posts ?? this.posts, 52 + cursor: identical(cursor, _unset) ? this.cursor : cursor as String?, 53 + errorMessage: identical(errorMessage, _unset) ? this.errorMessage : errorMessage as String?, 54 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 55 + ); 56 + } 57 + 58 + @override 59 + List<Object?> get props => [status, posts, cursor, errorMessage, isLoadingMore]; 60 + } 61 + 62 + class TopicState extends Equatable { 63 + const TopicState({ 64 + required this.topic, 65 + this.displayName, 66 + this.currentSort = TopicSort.top, 67 + this.topTimeline = const TopicTimeline.initial(), 68 + this.latestTimeline = const TopicTimeline.initial(), 69 + }); 70 + 71 + final String topic; 72 + final String? displayName; 73 + final TopicSort currentSort; 74 + final TopicTimeline topTimeline; 75 + final TopicTimeline latestTimeline; 76 + 77 + TopicTimeline get currentTimeline { 78 + return currentSort == TopicSort.top ? topTimeline : latestTimeline; 79 + } 80 + 81 + bool get isMissingTopic => topic.isEmpty; 82 + 83 + TopicState copyWith({ 84 + String? topic, 85 + Object? displayName = TopicTimeline._unset, 86 + TopicSort? currentSort, 87 + TopicTimeline? topTimeline, 88 + TopicTimeline? latestTimeline, 89 + }) { 90 + return TopicState( 91 + topic: topic ?? this.topic, 92 + displayName: identical(displayName, TopicTimeline._unset) ? this.displayName : displayName as String?, 93 + currentSort: currentSort ?? this.currentSort, 94 + topTimeline: topTimeline ?? this.topTimeline, 95 + latestTimeline: latestTimeline ?? this.latestTimeline, 96 + ); 97 + } 98 + 99 + @override 100 + List<Object?> get props => [topic, displayName, currentSort, topTimeline, latestTimeline]; 101 + } 102 + 103 + class TopicCubit extends Cubit<TopicState> { 104 + TopicCubit({required SearchRepository searchRepository, required String topic}) 105 + : _searchRepository = searchRepository, 106 + super(TopicState(topic: topic.trim())); 107 + 108 + final SearchRepository _searchRepository; 109 + 110 + Future<void> initialize() async { 111 + if (state.isMissingTopic) { 112 + return; 113 + } 114 + await _load(sort: TopicSort.top, refresh: true); 115 + } 116 + 117 + Future<void> switchSort(TopicSort sort) async { 118 + if (state.currentSort == sort) { 119 + return; 120 + } 121 + emit(state.copyWith(currentSort: sort)); 122 + 123 + final timeline = _timelineFor(sort, state); 124 + if (timeline.status == TopicTimelineStatus.initial) { 125 + await _load(sort: sort, refresh: true); 126 + } 127 + } 128 + 129 + Future<void> refreshCurrent() async { 130 + await _load(sort: state.currentSort, refresh: true); 131 + } 132 + 133 + Future<void> loadMoreCurrent() async { 134 + await _load(sort: state.currentSort, loadMore: true); 135 + } 136 + 137 + Future<void> _load({required TopicSort sort, bool refresh = false, bool loadMore = false}) async { 138 + if (state.isMissingTopic) { 139 + return; 140 + } 141 + 142 + final timeline = _timelineFor(sort, state); 143 + if (loadMore) { 144 + if (timeline.isLoadingMore || timeline.cursor == null || timeline.isLoading) { 145 + return; 146 + } 147 + _setTimeline(sort, timeline.copyWith(isLoadingMore: true, errorMessage: null)); 148 + } else { 149 + if (timeline.isLoading) { 150 + return; 151 + } 152 + if (refresh || timeline.status == TopicTimelineStatus.initial || timeline.hasError) { 153 + _setTimeline( 154 + sort, 155 + timeline.copyWith( 156 + status: TopicTimelineStatus.loading, 157 + posts: refresh ? const [] : timeline.posts, 158 + cursor: refresh ? null : timeline.cursor, 159 + errorMessage: null, 160 + isLoadingMore: false, 161 + ), 162 + ); 163 + } 164 + } 165 + 166 + try { 167 + final result = await _searchRepository.searchTopicPosts( 168 + topic: state.topic, 169 + sort: sort.apiValue, 170 + cursor: loadMore ? timeline.cursor : null, 171 + limit: 25, 172 + ); 173 + 174 + final mergedPosts = loadMore ? [...timeline.posts, ...result.posts] : result.posts; 175 + final nameFromResult = result.topicName?.trim(); 176 + final nextDisplayName = (nameFromResult == null || nameFromResult.isEmpty) ? state.displayName : nameFromResult; 177 + 178 + _setTimeline( 179 + sort, 180 + TopicTimeline._( 181 + status: TopicTimelineStatus.loaded, 182 + posts: mergedPosts, 183 + cursor: result.cursor, 184 + errorMessage: null, 185 + isLoadingMore: false, 186 + ), 187 + ); 188 + 189 + if (nextDisplayName != state.displayName) { 190 + emit(state.copyWith(displayName: nextDisplayName)); 191 + } 192 + } catch (_) { 193 + if (loadMore) { 194 + _setTimeline( 195 + sort, 196 + timeline.copyWith( 197 + status: timeline.posts.isEmpty ? TopicTimelineStatus.error : TopicTimelineStatus.loaded, 198 + errorMessage: timeline.posts.isEmpty ? 'Failed to load posts.' : null, 199 + isLoadingMore: false, 200 + ), 201 + ); 202 + return; 203 + } 204 + 205 + _setTimeline( 206 + sort, 207 + TopicTimeline._( 208 + status: TopicTimelineStatus.error, 209 + posts: timeline.posts, 210 + cursor: timeline.cursor, 211 + errorMessage: 'Failed to load posts.', 212 + isLoadingMore: false, 213 + ), 214 + ); 215 + } 216 + } 217 + 218 + TopicTimeline _timelineFor(TopicSort sort, TopicState sourceState) { 219 + return sort == TopicSort.top ? sourceState.topTimeline : sourceState.latestTimeline; 220 + } 221 + 222 + void _setTimeline(TopicSort sort, TopicTimeline timeline) { 223 + if (sort == TopicSort.top) { 224 + emit(state.copyWith(topTimeline: timeline)); 225 + } else { 226 + emit(state.copyWith(latestTimeline: timeline)); 227 + } 228 + } 229 + }
+104
lib/features/search/data/search_repository.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:atproto_core/atproto_core.dart'; 1 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 5 import 'package:bluesky/app_bsky_feed_defs.dart'; 3 6 import 'package:bluesky/app_bsky_feed_searchposts.dart'; 4 7 import 'package:bluesky/app_bsky_graph_defs.dart'; 5 8 import 'package:bluesky/bluesky.dart'; 6 9 import 'package:lazurite/core/network/app_view_request_context.dart'; 10 + import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 7 11 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 8 12 9 13 class SearchRepository { ··· 22 26 final Bluesky _bluesky; 23 27 final ModerationService? _moderationService; 24 28 final AppViewRequestContext _appViewContext; 29 + static const int _maxBlackskyTopicFeedLimit = 25; 25 30 26 31 Future<SearchPostsResult> searchPosts({ 27 32 required String query, ··· 93 98 return _filterBasicProfiles(response.data.actors); 94 99 } 95 100 101 + Future<TopicPostsResult> searchTopicPosts({ 102 + required String topic, 103 + String sort = 'top', 104 + String? cursor, 105 + int limit = 25, 106 + }) async { 107 + final normalizedTopic = topic.trim(); 108 + if (normalizedTopic.isEmpty) { 109 + return TopicPostsResult(posts: const []); 110 + } 111 + 112 + final provider = _appViewContext.resolveProviderKey(); 113 + final isBlackskyNumericTopic = provider == 'blacksky' && RegExp(r'^\d+$').hasMatch(normalizedTopic); 114 + if (isBlackskyNumericTopic) { 115 + return _searchBlackskyTopicFeed(topicId: normalizedTopic, cursor: cursor, limit: limit); 116 + } 117 + 118 + final result = await searchPosts(query: normalizedTopic, sort: sort, cursor: cursor, limit: limit); 119 + return TopicPostsResult(posts: result.posts, cursor: result.cursor, topicName: normalizedTopic); 120 + } 121 + 122 + Future<TopicPostsResult> _searchBlackskyTopicFeed({ 123 + required String topicId, 124 + String? cursor, 125 + required int limit, 126 + }) async { 127 + final clampedLimit = limit.clamp(1, _maxBlackskyTopicFeedLimit); 128 + final params = <String, String>{ 129 + 'topicId': topicId, 130 + 'limit': clampedLimit.toString(), 131 + if (cursor != null && cursor.isNotEmpty) 'cursor': cursor, 132 + }; 133 + 134 + final uri = Uri.https(_appViewContext.publicServiceHost(), '/xrpc/app.bsky.unspecced.getTopicFeed', params); 135 + final baseHeaders = await _moderationService?.headersForRequest(); 136 + final headers = _withoutAppViewProxyHeader(_appViewContext.appBskyHeaders(baseHeaders)); 137 + final response = await XrpcNetworkInterceptor.wrapGetClient()(uri, headers: headers); 138 + if (response.statusCode >= 400) { 139 + throw Exception('Failed to fetch Blacksky topic feed (status ${response.statusCode})'); 140 + } 141 + 142 + final decoded = jsonDecode(response.body) as Map<String, dynamic>; 143 + final rawPostUris = decoded['posts'] as List<dynamic>? ?? const []; 144 + final atUris = <AtUri>[]; 145 + for (final raw in rawPostUris) { 146 + final value = raw is String ? raw.trim() : ''; 147 + if (value.isEmpty) { 148 + continue; 149 + } 150 + try { 151 + atUris.add(AtUri.parse(value)); 152 + } catch (_) {} 153 + } 154 + 155 + if (atUris.isEmpty) { 156 + return TopicPostsResult( 157 + posts: const [], 158 + cursor: decoded['cursor'] as String?, 159 + topicName: _topicNameFromDecoded(decoded), 160 + ); 161 + } 162 + 163 + final hydrated = await _bluesky.feed.getPosts( 164 + uris: atUris, 165 + $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 166 + ); 167 + 168 + return TopicPostsResult( 169 + posts: _filterPosts(hydrated.data.posts), 170 + cursor: decoded['cursor'] as String?, 171 + topicName: _topicNameFromDecoded(decoded), 172 + ); 173 + } 174 + 175 + String? _topicNameFromDecoded(Map<String, dynamic> decoded) { 176 + final topic = decoded['topic']; 177 + if (topic is Map<String, dynamic>) { 178 + final name = topic['name']; 179 + if (name is String && name.trim().isNotEmpty) { 180 + return name.trim(); 181 + } 182 + } 183 + return null; 184 + } 185 + 186 + Map<String, String> _withoutAppViewProxyHeader(Map<String, String> headers) { 187 + final copy = Map<String, String>.from(headers); 188 + copy.removeWhere((key, _) => key.toLowerCase() == 'atproto-proxy'); 189 + return copy; 190 + } 191 + 96 192 List<PostView> _filterPosts(List<PostView> posts) { 97 193 final moderationService = _moderationService; 98 194 if (moderationService == null) { ··· 149 245 final List<GeneratorView> feeds; 150 246 final String? cursor; 151 247 } 248 + 249 + class TopicPostsResult { 250 + TopicPostsResult({required this.posts, this.cursor, this.topicName}); 251 + 252 + final List<PostView> posts; 253 + final String? cursor; 254 + final String? topicName; 255 + }
+168
lib/features/search/presentation/topic_screen.dart
··· 1 + import 'package:bluesky/app_bsky_feed_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 5 + import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 6 + import 'package:lazurite/features/search/cubit/topic_cubit.dart'; 7 + import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 8 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 9 + 10 + class TopicScreen extends StatefulWidget { 11 + const TopicScreen({super.key, required this.topic}); 12 + 13 + final String topic; 14 + 15 + @override 16 + State<TopicScreen> createState() => _TopicScreenState(); 17 + } 18 + 19 + class _TopicScreenState extends State<TopicScreen> { 20 + final ScrollController _scrollController = ScrollController(); 21 + final Set<String> _seenPostUris = <String>{}; 22 + 23 + @override 24 + void initState() { 25 + super.initState(); 26 + _scrollController.addListener(_onScroll); 27 + WidgetsBinding.instance.addPostFrameCallback((_) { 28 + if (mounted) { 29 + context.read<TopicCubit>().initialize(); 30 + } 31 + }); 32 + } 33 + 34 + @override 35 + void dispose() { 36 + _scrollController.removeListener(_onScroll); 37 + _scrollController.dispose(); 38 + super.dispose(); 39 + } 40 + 41 + void _onScroll() { 42 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 43 + context.read<TopicCubit>().loadMoreCurrent(); 44 + } 45 + } 46 + 47 + @override 48 + Widget build(BuildContext context) { 49 + return BlocBuilder<TopicCubit, TopicState>( 50 + builder: (context, state) { 51 + final title = state.displayName?.trim().isNotEmpty == true ? state.displayName! : state.topic; 52 + return Scaffold( 53 + appBar: AppBar(title: Text(title.isEmpty ? 'Topic' : title)), 54 + body: Column( 55 + children: [ 56 + _buildSortToggle(context, state), 57 + Expanded(child: _buildBody(context, state)), 58 + ], 59 + ), 60 + ); 61 + }, 62 + ); 63 + } 64 + 65 + Widget _buildSortToggle(BuildContext context, TopicState state) { 66 + final theme = Theme.of(context); 67 + 68 + return Container( 69 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 70 + decoration: BoxDecoration( 71 + border: Border(bottom: BorderSide(color: theme.dividerColor)), 72 + ), 73 + child: Row( 74 + children: [ 75 + Text( 76 + 'Sort by', 77 + style: theme.textTheme.bodySmall?.copyWith( 78 + color: theme.colorScheme.onSurfaceVariant, 79 + fontWeight: FontWeight.w700, 80 + ), 81 + ), 82 + const SizedBox(width: 8), 83 + Container( 84 + decoration: BoxDecoration( 85 + color: theme.colorScheme.surfaceContainerHighest, 86 + borderRadius: BorderRadius.circular(8), 87 + ), 88 + child: Row( 89 + mainAxisSize: MainAxisSize.min, 90 + children: TopicSort.values 91 + .map((sort) { 92 + final isSelected = state.currentSort == sort; 93 + final labelColor = isSelected ? theme.colorScheme.onPrimary : theme.colorScheme.onSurfaceVariant; 94 + 95 + return GestureDetector( 96 + onTap: () => context.read<TopicCubit>().switchSort(sort), 97 + child: Container( 98 + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), 99 + decoration: BoxDecoration( 100 + color: isSelected ? theme.colorScheme.primary : null, 101 + borderRadius: BorderRadius.circular(8), 102 + ), 103 + child: Text( 104 + sort.label, 105 + style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500, color: labelColor), 106 + ), 107 + ), 108 + ); 109 + }) 110 + .toList(growable: false), 111 + ), 112 + ), 113 + ], 114 + ), 115 + ); 116 + } 117 + 118 + Widget _buildBody(BuildContext context, TopicState state) { 119 + final timeline = state.currentTimeline; 120 + final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 121 + if (timeline.isLoading && timeline.posts.isEmpty) { 122 + return const Center(child: CircularProgressIndicator()); 123 + } 124 + 125 + if (timeline.hasError && timeline.posts.isEmpty) { 126 + return Center( 127 + child: Column( 128 + mainAxisSize: MainAxisSize.min, 129 + children: [ 130 + Text(timeline.errorMessage ?? 'Failed to load posts.'), 131 + const SizedBox(height: 12), 132 + FilledButton(onPressed: () => context.read<TopicCubit>().refreshCurrent(), child: const Text('Retry')), 133 + ], 134 + ), 135 + ); 136 + } 137 + 138 + if (timeline.posts.isEmpty) { 139 + return const Center(child: Text('No posts found for this topic.')); 140 + } 141 + 142 + return AnimatedRefreshIndicator( 143 + onRefresh: () => context.read<TopicCubit>().refreshCurrent(), 144 + child: ListView.builder( 145 + controller: _scrollController, 146 + itemCount: timeline.posts.length + (timeline.isLoadingMore ? 1 : 0), 147 + itemBuilder: (context, index) { 148 + if (index == timeline.posts.length) { 149 + return const Center( 150 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 151 + ); 152 + } 153 + final post = timeline.posts[index]; 154 + final postUri = post.uri.toString(); 155 + return StaggeredEntrance( 156 + itemKey: postUri, 157 + index: index, 158 + seenKeys: _seenPostUris, 159 + child: PostCardWithActions( 160 + feedViewPost: FeedViewPost(post: post), 161 + accountDid: accountDid, 162 + ), 163 + ); 164 + }, 165 + ), 166 + ); 167 + } 168 + }
+4
lib/shared/presentation/helpers/navigation_helpers.dart
··· 44 44 45 45 return path == '/feeds' || 46 46 path.startsWith('/feeds/') || 47 + path == '/feed' || 48 + path.startsWith('/feed/') || 49 + path == '/trending' || 50 + path.startsWith('/trending/') || 47 51 path == '/settings' || 48 52 path.startsWith('/settings/') || 49 53 path == '/search' ||
+32
test/core/network/app_view_router_test.dart
··· 28 28 final router = AppViewRouter(provider: AppViewProviders.bluesky); 29 29 expect(router.resolveWebLink('https://example.com/path').toString(), equals('https://example.com/path')); 30 30 }); 31 + 32 + test('resolves profile feed trend link to feed-detail route params', () { 33 + final router = AppViewRouter(provider: AppViewProviders.bluesky); 34 + 35 + final resolved = router.resolveTrendLink('/profile/alice.bsky.social/feed/aaabbb'); 36 + expect(resolved.inAppRoute, equals('/feed?actor=alice.bsky.social&rkey=aaabbb')); 37 + expect(resolved.externalUri.toString(), equals('https://bsky.app/profile/alice.bsky.social/feed/aaabbb')); 38 + }); 39 + 40 + test('resolves topic trend links to in-app topic route', () { 41 + final router = AppViewRouter(provider: AppViewProviders.blacksky); 42 + 43 + final resolved = router.resolveTrendLink('/topic/dartlang'); 44 + expect(resolved.inAppRoute, equals('/topic?topic=dartlang')); 45 + expect(resolved.externalUri.toString(), equals('https://blacksky.community/topic/dartlang')); 46 + }); 47 + 48 + test('degrades unknown trend links to external open', () { 49 + final router = AppViewRouter(provider: AppViewProviders.bluesky); 50 + 51 + final resolved = router.resolveTrendLink('/weird/path'); 52 + expect(resolved.inAppRoute, isNull); 53 + expect(resolved.externalUri.toString(), equals('https://bsky.app/weird/path')); 54 + }); 55 + 56 + test('does not deep-link non-provider hosts', () { 57 + final router = AppViewRouter(provider: AppViewProviders.bluesky); 58 + 59 + final resolved = router.resolveTrendLink('https://example.com/profile/alice/feed/xyz'); 60 + expect(resolved.inAppRoute, isNull); 61 + expect(resolved.externalUri.toString(), equals('https://example.com/profile/alice/feed/xyz')); 62 + }); 31 63 }); 32 64 }
+5 -6
test/core/router/compose_route_extra_parser_test.dart
··· 1 1 import 'package:flutter_test/flutter_test.dart'; 2 - import 'package:lazurite/core/router/app_router.dart'; 3 2 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 4 3 5 4 void main() { 6 - group('parseComposeRouteExtra', () { 5 + group('ComposeRouteArgs.parseExtra', () { 7 6 test('returns args unchanged when already ComposeRouteArgs', () { 8 7 const args = ComposeRouteArgs(replyParentUri: 'at://parent', replyParentCid: 'cid-parent'); 9 8 10 - final parsed = parseComposeRouteExtra(args); 9 + final parsed = ComposeRouteArgs.parseExtra(args); 11 10 12 11 expect(parsed.replyParentUri, 'at://parent'); 13 12 expect(parsed.replyParentCid, 'cid-parent'); 14 13 }); 15 14 16 15 test('parses legacy map payload used by reply actions', () { 17 - final parsed = parseComposeRouteExtra({ 16 + final parsed = ComposeRouteArgs.parseExtra({ 18 17 'replyParentUri': 'at://parent', 19 18 'replyParentCid': 'cid-parent', 20 19 'replyRootUri': 'at://root', ··· 30 29 }); 31 30 32 31 test('parses edit context fields from map payload', () { 33 - final parsed = parseComposeRouteExtra({ 32 + final parsed = ComposeRouteArgs.parseExtra({ 34 33 'initialText': 'updated post', 35 34 'editPostUri': 'at://did:plc:test/app.bsky.feed.post/abc123', 36 35 'editPostCid': 'cid-123', ··· 45 44 }); 46 45 47 46 test('returns empty args for unsupported payload types', () { 48 - final parsed = parseComposeRouteExtra(42); 47 + final parsed = ComposeRouteArgs.parseExtra(42); 49 48 50 49 expect(parsed.replyParentUri, isNull); 51 50 expect(parsed.replyParentCid, isNull);
+89
test/features/feed/data/trending_join_test.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:bluesky/app_bsky_unspecced_defs.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/features/feed/data/trending_join.dart'; 5 + 6 + void main() { 7 + group('trendLinkJoinKey', () { 8 + test('parses /topic links', () { 9 + expect(trendLinkJoinKey('/topic/DartLang'), equals('topic:dartlang')); 10 + }); 11 + 12 + test('parses /profile/<actor>/feed/<rkey> links', () { 13 + expect(trendLinkJoinKey('/profile/alice.bsky.social/feed/aaabbb'), equals('feed:alice.bsky.social:aaabbb')); 14 + }); 15 + 16 + test('returns null for unsupported link formats', () { 17 + expect(trendLinkJoinKey('/unknown/path'), isNull); 18 + }); 19 + }); 20 + 21 + group('normalizeTrendTopic', () { 22 + test('normalizes case, hash prefix, and whitespace', () { 23 + expect(normalizeTrendTopic(' #Dart Lang '), equals('dart lang')); 24 + }); 25 + }); 26 + 27 + group('enrichTrendingTopics', () { 28 + test('prefers parsed link key match over normalized topic match', () { 29 + final topics = [const TrendingTopic(topic: 'Dart', link: '/topic/dart')]; 30 + final trends = [ 31 + _trend(topic: '#Dart', link: '/topic/something-else', startedAt: DateTime.utc(2026, 1, 2)), 32 + _trend(topic: 'Different', link: '/topic/dart', startedAt: DateTime.utc(2026, 1, 1)), 33 + ]; 34 + 35 + final result = enrichTrendingTopics(topics: topics, trends: trends); 36 + expect(result.single.trend?.link, equals('/topic/dart')); 37 + }); 38 + 39 + test('uses normalized topic match when link key match is absent', () { 40 + final topics = [const TrendingTopic(topic: '#Dart Lang', link: '/unknown/path')]; 41 + final trends = [_trend(topic: 'dart lang', link: '/topic/dartlang', startedAt: DateTime.utc(2026, 1, 1))]; 42 + 43 + final result = enrichTrendingTopics(topics: topics, trends: trends); 44 + expect(result.single.trend?.topic, equals('dart lang')); 45 + }); 46 + 47 + test('chooses newest startedAt when multiple matches exist', () { 48 + final topics = [const TrendingTopic(topic: 'Dart', link: '/topic/dart')]; 49 + final trends = [ 50 + _trend(topic: 'Dart', link: '/topic/dart', startedAt: DateTime.utc(2026, 1, 1)), 51 + _trend(topic: 'Dart', link: '/topic/dart', startedAt: DateTime.utc(2026, 1, 2)), 52 + ]; 53 + 54 + final result = enrichTrendingTopics(topics: topics, trends: trends); 55 + expect(result.single.trend?.startedAt, equals(DateTime.utc(2026, 1, 2))); 56 + }); 57 + 58 + test('breaks startedAt ties using lexicographically smallest link', () { 59 + final topics = [const TrendingTopic(topic: 'Dart', link: '/unknown')]; 60 + final sameTime = DateTime.utc(2026, 1, 1); 61 + final trends = [ 62 + _trend(topic: 'Dart', link: '/topic/z', startedAt: sameTime), 63 + _trend(topic: 'Dart', link: '/topic/a', startedAt: sameTime), 64 + ]; 65 + 66 + final result = enrichTrendingTopics(topics: topics, trends: trends); 67 + expect(result.single.trend?.link, equals('/topic/a')); 68 + }); 69 + 70 + test('returns topic row with null metadata when no match exists', () { 71 + final topics = [const TrendingTopic(topic: 'Dart', link: '/topic/dart')]; 72 + final trends = [_trend(topic: 'Flutter', link: '/topic/flutter', startedAt: DateTime.utc(2026, 1, 1))]; 73 + 74 + final result = enrichTrendingTopics(topics: topics, trends: trends); 75 + expect(result.single.trend, isNull); 76 + }); 77 + }); 78 + } 79 + 80 + TrendView _trend({required String topic, required String link, required DateTime startedAt}) { 81 + return TrendView( 82 + topic: topic, 83 + displayName: topic, 84 + link: link, 85 + startedAt: startedAt, 86 + postCount: 10, 87 + actors: const [ProfileViewBasic(did: 'did:plc:actor', handle: 'actor.bsky.social')], 88 + ); 89 + }
+204
test/features/feed/presentation/feed_detail_screen_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:atproto_core/atproto_core.dart'; 4 + import 'package:bloc_test/bloc_test.dart'; 5 + import 'package:bluesky/app_bsky_actor_defs.dart'; 6 + import 'package:bluesky/app_bsky_feed_defs.dart'; 7 + import 'package:flutter/material.dart'; 8 + import 'package:flutter_bloc/flutter_bloc.dart'; 9 + import 'package:flutter_test/flutter_test.dart'; 10 + import 'package:lazurite/core/theme/app_theme.dart'; 11 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 12 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 13 + import 'package:lazurite/features/feed/data/feed_repository.dart'; 14 + import 'package:lazurite/features/feed/presentation/feed_detail_screen.dart'; 15 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 16 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 17 + import 'package:mocktail/mocktail.dart'; 18 + 19 + class MockFeedRepository extends Mock implements FeedRepository {} 20 + 21 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 22 + 23 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 24 + 25 + void main() { 26 + late MockFeedRepository feedRepository; 27 + late MockAuthBloc authBloc; 28 + late MockSettingsCubit settingsCubit; 29 + 30 + final feedUri = AtUri.parse('at://did:plc:alice/app.bsky.feed.generator/aaabbb'); 31 + 32 + setUp(() { 33 + feedRepository = MockFeedRepository(); 34 + authBloc = MockAuthBloc(); 35 + settingsCubit = MockSettingsCubit(); 36 + when(() => authBloc.state).thenReturn( 37 + const AuthState.authenticated(AuthTokens(accessToken: 'access', did: 'did:plc:me', handle: 'me.bsky.social')), 38 + ); 39 + whenListen( 40 + authBloc, 41 + const Stream<AuthState>.empty(), 42 + initialState: const AuthState.authenticated( 43 + AuthTokens(accessToken: 'access', did: 'did:plc:me', handle: 'me.bsky.social'), 44 + ), 45 + ); 46 + when(() => settingsCubit.state).thenReturn( 47 + const SettingsState( 48 + themePalette: AppThemePalette.oxocarbon, 49 + themeVariant: AppThemeVariant.dark, 50 + useSystemTheme: false, 51 + ), 52 + ); 53 + whenListen( 54 + settingsCubit, 55 + const Stream<SettingsState>.empty(), 56 + initialState: const SettingsState( 57 + themePalette: AppThemePalette.oxocarbon, 58 + themeVariant: AppThemeVariant.dark, 59 + useSystemTheme: false, 60 + ), 61 + ); 62 + }); 63 + 64 + Widget buildSubject() { 65 + return MaterialApp( 66 + home: RepositoryProvider<FeedRepository>.value( 67 + value: feedRepository, 68 + child: MultiBlocProvider( 69 + providers: [ 70 + BlocProvider<AuthBloc>.value(value: authBloc), 71 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 72 + ], 73 + child: FeedDetailScreen(feedUri: feedUri), 74 + ), 75 + ), 76 + ); 77 + } 78 + 79 + testWidgets('shows loading state while feed is loading', (tester) async { 80 + final completer = Completer<FeedResult>(); 81 + when( 82 + () => feedRepository.getFeedGenerator(feedUri), 83 + ).thenAnswer((_) async => _generatorView(displayName: 'Discover')); 84 + when( 85 + () => feedRepository.getFeed( 86 + feedUri: feedUri, 87 + cursor: any(named: 'cursor'), 88 + limit: any(named: 'limit'), 89 + ), 90 + ).thenAnswer((_) => completer.future); 91 + 92 + await tester.pumpWidget(buildSubject()); 93 + 94 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 95 + completer.complete(FeedResult(posts: const [])); 96 + }); 97 + 98 + testWidgets('shows empty state when feed has no posts', (tester) async { 99 + when( 100 + () => feedRepository.getFeedGenerator(feedUri), 101 + ).thenAnswer((_) async => _generatorView(displayName: 'Discover')); 102 + when( 103 + () => feedRepository.getFeed( 104 + feedUri: feedUri, 105 + cursor: any(named: 'cursor'), 106 + limit: any(named: 'limit'), 107 + ), 108 + ).thenAnswer((_) async => FeedResult(posts: const [])); 109 + 110 + await tester.pumpWidget(buildSubject()); 111 + await tester.pumpAndSettle(); 112 + 113 + expect(find.text('No posts yet'), findsOneWidget); 114 + }); 115 + 116 + testWidgets('shows feed title from generator metadata', (tester) async { 117 + when( 118 + () => feedRepository.getFeedGenerator(feedUri), 119 + ).thenAnswer((_) async => _generatorView(displayName: 'My Feed')); 120 + when( 121 + () => feedRepository.getFeed( 122 + feedUri: feedUri, 123 + cursor: any(named: 'cursor'), 124 + limit: any(named: 'limit'), 125 + ), 126 + ).thenAnswer((_) async => FeedResult(posts: const [])); 127 + 128 + await tester.pumpWidget(buildSubject()); 129 + await tester.pumpAndSettle(); 130 + 131 + expect(find.text('My Feed'), findsOneWidget); 132 + }); 133 + 134 + testWidgets('shows error state and supports retry', (tester) async { 135 + when( 136 + () => feedRepository.getFeedGenerator(feedUri), 137 + ).thenAnswer((_) async => _generatorView(displayName: 'Discover')); 138 + when( 139 + () => feedRepository.getFeed( 140 + feedUri: feedUri, 141 + cursor: any(named: 'cursor'), 142 + limit: any(named: 'limit'), 143 + ), 144 + ).thenThrow(Exception('boom')); 145 + 146 + await tester.pumpWidget(buildSubject()); 147 + await tester.pumpAndSettle(); 148 + 149 + expect(find.text('Failed to load feed'), findsOneWidget); 150 + expect(find.text('Retry'), findsOneWidget); 151 + 152 + await tester.tap(find.text('Retry')); 153 + await tester.pumpAndSettle(); 154 + 155 + verify(() => feedRepository.getFeed(feedUri: feedUri, cursor: null, limit: 50)).called(greaterThanOrEqualTo(2)); 156 + }); 157 + 158 + testWidgets('resolves handle actor+rkey to did-backed feed URI before loading', (tester) async { 159 + when( 160 + () => feedRepository.resolveFeedGeneratorUri(actor: 'alice.bsky.social', rkey: 'aaabbb'), 161 + ).thenAnswer((_) async => feedUri); 162 + when( 163 + () => feedRepository.getFeedGenerator(feedUri), 164 + ).thenAnswer((_) async => _generatorView(displayName: 'My Feed')); 165 + when( 166 + () => feedRepository.getFeed( 167 + feedUri: feedUri, 168 + cursor: any(named: 'cursor'), 169 + limit: any(named: 'limit'), 170 + ), 171 + ).thenAnswer((_) async => FeedResult(posts: const [])); 172 + 173 + await tester.pumpWidget( 174 + MaterialApp( 175 + home: RepositoryProvider<FeedRepository>.value( 176 + value: feedRepository, 177 + child: MultiBlocProvider( 178 + providers: [ 179 + BlocProvider<AuthBloc>.value(value: authBloc), 180 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 181 + ], 182 + child: const FeedDetailScreen(actor: 'alice.bsky.social', rkey: 'aaabbb'), 183 + ), 184 + ), 185 + ), 186 + ); 187 + await tester.pumpAndSettle(); 188 + 189 + verify(() => feedRepository.resolveFeedGeneratorUri(actor: 'alice.bsky.social', rkey: 'aaabbb')).called(1); 190 + verify(() => feedRepository.getFeedGenerator(feedUri)).called(1); 191 + verify(() => feedRepository.getFeed(feedUri: feedUri, cursor: null, limit: 50)).called(1); 192 + }); 193 + } 194 + 195 + GeneratorView _generatorView({required String displayName}) { 196 + return GeneratorView( 197 + uri: AtUri.parse('at://did:plc:creator/app.bsky.feed.generator/discover'), 198 + cid: 'cid-gen', 199 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 200 + did: 'did:plc:creator', 201 + displayName: displayName, 202 + indexedAt: DateTime.utc(2026, 1, 1), 203 + ); 204 + }
+2 -1
test/features/feed/presentation/home_feed_screen_test.dart
··· 320 320 }); 321 321 322 322 group('HomeFeedScreen', () { 323 - testWidgets('shows feeds action without the messages shortcut in the app bar', (tester) async { 323 + testWidgets('shows trending and feeds actions without the messages shortcut in the app bar', (tester) async { 324 324 final feedPreferencesCubit = MockFeedPreferencesCubit(); 325 325 final feedRepository = MockFeedRepository(); 326 326 final completer = Completer<FeedResult>(); ··· 340 340 ); 341 341 await tester.pump(); 342 342 343 + expect(find.byIcon(Icons.trending_up_outlined), findsOneWidget); 343 344 expect(find.byIcon(Icons.rss_feed), findsOneWidget); 344 345 expect(find.byIcon(Icons.chat_bubble_outline), findsNothing); 345 346 });
+141
test/features/feed/presentation/trending_screen_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:bloc_test/bloc_test.dart'; 4 + import 'package:bluesky/app_bsky_unspecced_defs.dart'; 5 + import 'package:flutter/material.dart'; 6 + import 'package:flutter_bloc/flutter_bloc.dart'; 7 + import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:lazurite/core/theme/app_theme.dart'; 9 + import 'package:lazurite/features/feed/data/feed_repository.dart'; 10 + import 'package:lazurite/features/feed/data/trending_join.dart'; 11 + import 'package:lazurite/features/feed/presentation/trending_screen.dart'; 12 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 13 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 14 + import 'package:mocktail/mocktail.dart'; 15 + 16 + class MockFeedRepository extends Mock implements FeedRepository {} 17 + 18 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 19 + 20 + void main() { 21 + late MockFeedRepository feedRepository; 22 + late MockSettingsCubit settingsCubit; 23 + 24 + setUp(() { 25 + feedRepository = MockFeedRepository(); 26 + settingsCubit = MockSettingsCubit(); 27 + when(() => settingsCubit.state).thenReturn( 28 + const SettingsState( 29 + themePalette: AppThemePalette.oxocarbon, 30 + themeVariant: AppThemeVariant.dark, 31 + useSystemTheme: false, 32 + ), 33 + ); 34 + whenListen( 35 + settingsCubit, 36 + const Stream<SettingsState>.empty(), 37 + initialState: const SettingsState( 38 + themePalette: AppThemePalette.oxocarbon, 39 + themeVariant: AppThemeVariant.dark, 40 + useSystemTheme: false, 41 + ), 42 + ); 43 + }); 44 + 45 + Widget buildSubject() { 46 + return MaterialApp( 47 + home: RepositoryProvider<FeedRepository>.value( 48 + value: feedRepository, 49 + child: BlocProvider<SettingsCubit>.value(value: settingsCubit, child: const TrendingScreen()), 50 + ), 51 + ); 52 + } 53 + 54 + testWidgets('shows loading state while data is in flight', (tester) async { 55 + final completer = Completer<TrendingScreenData>(); 56 + when(() => feedRepository.getTrendingScreenData(limit: any(named: 'limit'))).thenAnswer((_) => completer.future); 57 + 58 + await tester.pumpWidget(buildSubject()); 59 + 60 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 61 + completer.complete(_data(topics: [_topic('Dart', '/topic/dart')], suggested: const [])); 62 + }); 63 + 64 + testWidgets('shows empty state when provider returns no topics', (tester) async { 65 + when( 66 + () => feedRepository.getTrendingScreenData(limit: any(named: 'limit')), 67 + ).thenAnswer((_) async => _data(topics: const [], suggested: const [])); 68 + 69 + await tester.pumpWidget(buildSubject()); 70 + await tester.pumpAndSettle(); 71 + 72 + expect(find.text('No trending topics right now'), findsOneWidget); 73 + }); 74 + 75 + testWidgets('hides suggested section when it is empty', (tester) async { 76 + when( 77 + () => feedRepository.getTrendingScreenData(limit: any(named: 'limit')), 78 + ).thenAnswer((_) async => _data(topics: [_topic('Dart', '/topic/dart')], suggested: const [])); 79 + 80 + await tester.pumpWidget(buildSubject()); 81 + await tester.pumpAndSettle(); 82 + 83 + expect(find.text('Topics'), findsOneWidget); 84 + expect(find.text('Suggested'), findsNothing); 85 + }); 86 + 87 + testWidgets('shows suggested section when data is present', (tester) async { 88 + when(() => feedRepository.getTrendingScreenData(limit: any(named: 'limit'))).thenAnswer( 89 + (_) async => _data(topics: [_topic('Dart', '/topic/dart')], suggested: [_topic('Flutter', '/topic/flutter')]), 90 + ); 91 + 92 + await tester.pumpWidget(buildSubject()); 93 + await tester.pumpAndSettle(); 94 + 95 + expect(find.text('Topics'), findsOneWidget); 96 + expect(find.text('Suggested'), findsOneWidget); 97 + expect(find.text('Dart'), findsOneWidget); 98 + expect(find.text('Flutter'), findsOneWidget); 99 + }); 100 + 101 + testWidgets('shows metadata unavailable banner in degraded mode', (tester) async { 102 + when(() => feedRepository.getTrendingScreenData(limit: any(named: 'limit'))).thenAnswer( 103 + (_) async => _data(topics: [_topic('Dart', '/topic/dart')], suggested: const [], metadataUnavailable: true), 104 + ); 105 + 106 + await tester.pumpWidget(buildSubject()); 107 + await tester.pumpAndSettle(); 108 + 109 + expect(find.text('Metadata temporarily unavailable'), findsOneWidget); 110 + }); 111 + 112 + testWidgets('shows error state and retries loading', (tester) async { 113 + when(() => feedRepository.getTrendingScreenData(limit: any(named: 'limit'))).thenThrow(Exception('boom')); 114 + 115 + await tester.pumpWidget(buildSubject()); 116 + await tester.pumpAndSettle(); 117 + 118 + expect(find.text('Failed to load trending'), findsOneWidget); 119 + expect(find.text('Retry'), findsOneWidget); 120 + 121 + await tester.tap(find.text('Retry')); 122 + await tester.pumpAndSettle(); 123 + 124 + verify(() => feedRepository.getTrendingScreenData(limit: 10)).called(greaterThanOrEqualTo(2)); 125 + }); 126 + } 127 + 128 + TrendingScreenData _data({ 129 + required List<TrendingTopic> topics, 130 + required List<TrendingTopic> suggested, 131 + bool metadataUnavailable = false, 132 + }) { 133 + return TrendingScreenData( 134 + topics: topics.map((topic) => EnrichedTrendingTopic(topic: topic)).toList(growable: false), 135 + suggested: suggested.map((topic) => EnrichedTrendingTopic(topic: topic)).toList(growable: false), 136 + metadataUnavailable: metadataUnavailable, 137 + ); 138 + } 139 + 140 + TrendingTopic _topic(String topic, String link) => 141 + TrendingTopic(topic: topic, displayName: topic, link: link, description: 'About $topic');
+141
test/features/search/cubit/topic_cubit_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:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/search/cubit/topic_cubit.dart'; 6 + import 'package:lazurite/features/search/data/search_repository.dart'; 7 + import 'package:mocktail/mocktail.dart'; 8 + 9 + class MockSearchRepository extends Mock implements SearchRepository {} 10 + 11 + PostView _post(String uri, String text) { 12 + return PostView( 13 + uri: AtUri.parse(uri), 14 + cid: 'cid-${uri.hashCode}', 15 + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 16 + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': '2026-01-01T00:00:00.000Z'}, 17 + indexedAt: DateTime.utc(2026, 1, 1), 18 + ); 19 + } 20 + 21 + void main() { 22 + late MockSearchRepository searchRepository; 23 + 24 + setUp(() { 25 + searchRepository = MockSearchRepository(); 26 + }); 27 + 28 + group('TopicCubit', () { 29 + test('initialize loads top timeline for topic query', () async { 30 + final topPost = _post('at://did:plc:author/app.bsky.feed.post/1', 'Top post'); 31 + 32 + when( 33 + () => searchRepository.searchTopicPosts( 34 + topic: '1441', 35 + sort: 'top', 36 + cursor: any(named: 'cursor'), 37 + limit: any(named: 'limit'), 38 + ), 39 + ).thenAnswer((_) async => TopicPostsResult(posts: [topPost], cursor: 'cursor-1', topicName: 'Politics')); 40 + 41 + final cubit = TopicCubit(searchRepository: searchRepository, topic: '1441'); 42 + addTearDown(cubit.close); 43 + 44 + await cubit.initialize(); 45 + 46 + expect(cubit.state.topTimeline.status, TopicTimelineStatus.loaded); 47 + expect(cubit.state.topTimeline.posts, [topPost]); 48 + expect(cubit.state.topTimeline.cursor, 'cursor-1'); 49 + expect(cubit.state.displayName, 'Politics'); 50 + verify( 51 + () => searchRepository.searchTopicPosts( 52 + topic: '1441', 53 + sort: 'top', 54 + cursor: any(named: 'cursor'), 55 + limit: 25, 56 + ), 57 + ).called(1); 58 + }); 59 + 60 + test('switchSort loads latest timeline and preserves top timeline', () async { 61 + final topPost = _post('at://did:plc:author/app.bsky.feed.post/1', 'Top post'); 62 + final latestPost = _post('at://did:plc:author/app.bsky.feed.post/2', 'Latest post'); 63 + 64 + when( 65 + () => searchRepository.searchTopicPosts( 66 + topic: 'sports', 67 + sort: 'top', 68 + cursor: any(named: 'cursor'), 69 + limit: any(named: 'limit'), 70 + ), 71 + ).thenAnswer((_) async => TopicPostsResult(posts: [topPost], cursor: null)); 72 + 73 + when( 74 + () => searchRepository.searchTopicPosts( 75 + topic: 'sports', 76 + sort: 'latest', 77 + cursor: any(named: 'cursor'), 78 + limit: any(named: 'limit'), 79 + ), 80 + ).thenAnswer((_) async => TopicPostsResult(posts: [latestPost], cursor: null)); 81 + 82 + final cubit = TopicCubit(searchRepository: searchRepository, topic: 'sports'); 83 + addTearDown(cubit.close); 84 + 85 + await cubit.initialize(); 86 + await cubit.switchSort(TopicSort.latest); 87 + 88 + expect(cubit.state.currentSort, TopicSort.latest); 89 + expect(cubit.state.topTimeline.posts, [topPost]); 90 + expect(cubit.state.latestTimeline.posts, [latestPost]); 91 + }); 92 + 93 + test('loadMoreCurrent appends posts using cursor', () async { 94 + final firstPost = _post('at://did:plc:author/app.bsky.feed.post/1', 'First'); 95 + final secondPost = _post('at://did:plc:author/app.bsky.feed.post/2', 'Second'); 96 + 97 + when( 98 + () => searchRepository.searchTopicPosts( 99 + topic: 'sports', 100 + sort: 'top', 101 + cursor: any(named: 'cursor'), 102 + limit: any(named: 'limit'), 103 + ), 104 + ).thenAnswer((invocation) async { 105 + final cursor = invocation.namedArguments[#cursor] as String?; 106 + if (cursor == null) { 107 + return TopicPostsResult(posts: [firstPost], cursor: 'next'); 108 + } 109 + return TopicPostsResult(posts: [secondPost], cursor: null); 110 + }); 111 + 112 + final cubit = TopicCubit(searchRepository: searchRepository, topic: 'sports'); 113 + addTearDown(cubit.close); 114 + 115 + await cubit.initialize(); 116 + await cubit.loadMoreCurrent(); 117 + 118 + expect(cubit.state.topTimeline.posts, [firstPost, secondPost]); 119 + expect(cubit.state.topTimeline.cursor, isNull); 120 + }); 121 + 122 + test('missing topic is ignored and no requests are sent', () async { 123 + final cubit = TopicCubit(searchRepository: searchRepository, topic: ''); 124 + addTearDown(cubit.close); 125 + 126 + await cubit.initialize(); 127 + await cubit.loadMoreCurrent(); 128 + await cubit.refreshCurrent(); 129 + 130 + verifyNever( 131 + () => searchRepository.searchTopicPosts( 132 + topic: any(named: 'topic'), 133 + sort: any(named: 'sort'), 134 + cursor: any(named: 'cursor'), 135 + limit: any(named: 'limit'), 136 + ), 137 + ); 138 + expect(cubit.state.isMissingTopic, isTrue); 139 + }); 140 + }); 141 + }