[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

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

search posts (#75)

* search

* separate user result

* melhor

* puta que pariu essa buceta de models ta sempre errado misericordia porra

* cursors

* ala ele denovo

* better censor

authored by

Davi Rodrigues and committed by
GitHub
e0dd8a34 7f179840

+1052 -200
+46 -20
lib/src/core/network/atproto/data/models/feed_models.dart
··· 4 4 import 'package:flutter/foundation.dart'; 5 5 import 'package:freezed_annotation/freezed_annotation.dart'; 6 6 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 7 + import 'package:sparksocial/src/core/utils/uri_converter.dart'; 7 8 8 9 part 'feed_models.freezed.dart'; 9 10 part 'feed_models.g.dart'; ··· 390 391 391 392 @FreezedUnionValue('app.bsky.embed.record#view') 392 393 @JsonSerializable(explicitToJson: true) 393 - const factory EmbedView.bskyRecord({required EmbedViewBskyRecordViewRecord record}) = EmbedViewBskyRecord; 394 - 395 - @FreezedUnionValue('app.bsky.embed.record#viewRecord') 396 - @JsonSerializable(explicitToJson: true) 397 - const factory EmbedView.bskyRecordViewRecord({ 398 - @AtUriConverter() required AtUri uri, 399 - required String cid, 400 - required ProfileViewBasic author, 401 - required dynamic value, 402 - required DateTime indexedAt, 403 - @Default([]) List<Label> labels, 404 - int? replyCount, 405 - int? repostCount, 406 - int? likeCount, 407 - int? quoteCount, 408 - @Default([]) List<EmbedView> embeds, 409 - }) = EmbedViewBskyRecordViewRecord; 394 + const factory EmbedView.bskyRecord({required EmbedViewRecord record}) = EmbedViewBskyRecord; 410 395 411 396 @FreezedUnionValue('app.bsky.embed.recordWithMedia#view') 412 397 @JsonSerializable(explicitToJson: true) 413 - const factory EmbedView.bskyRecordWithMedia({required EmbedViewBskyRecord record, required EmbedView media}) = 398 + const factory EmbedView.bskyRecordWithMedia({required EmbedViewRecord record, required EmbedView media}) = 414 399 EmbedViewBskyRecordWithMedia; 415 400 416 401 @FreezedUnionValue('app.bsky.embed.external#view') 417 402 @JsonSerializable(explicitToJson: true) 418 - const factory EmbedView.bskyExternal({required EmbedViewExternal external, required String cid}) = EmbedViewBskyExternal; 403 + const factory EmbedView.bskyExternal({required EmbedViewExternal external}) = EmbedViewBskyExternal; 419 404 420 405 factory EmbedView.fromJson(Map<String, dynamic> json) => _$EmbedViewFromJson(json); 421 406 } ··· 427 412 required String uri, 428 413 @Default('') String title, 429 414 @Default('') String description, 430 - @AtUriConverter() AtUri? thumb, 415 + @UriConverter() Uri? thumb, 431 416 }) = _EmbedViewExternal; 432 417 const EmbedViewExternal._(); 433 418 434 419 factory EmbedViewExternal.fromJson(Map<String, dynamic> json) => _$EmbedViewExternalFromJson(json); 420 + } 421 + 422 + @Freezed(unionKey: r'$type') 423 + sealed class EmbedViewRecord with _$EmbedViewRecord { 424 + const EmbedViewRecord._(); 425 + 426 + /// A full, viewable record. 427 + @FreezedUnionValue('app.bsky.embed.record#viewRecord') 428 + @JsonSerializable(explicitToJson: true) 429 + const factory EmbedViewRecord.record({ 430 + @AtUriConverter() required AtUri uri, 431 + required String cid, 432 + required ProfileViewBasic author, 433 + required dynamic value, // This is typically a PostRecord 434 + required DateTime indexedAt, 435 + @Default([]) List<Label> labels, 436 + int? replyCount, 437 + int? repostCount, 438 + int? likeCount, 439 + int? quoteCount, 440 + @Default([]) List<EmbedView> embeds, 441 + }) = EmbedViewRecord_Record; 442 + 443 + /// A placeholder for a record that could not be found. 444 + @FreezedUnionValue('app.bsky.embed.record#viewNotFound') 445 + @JsonSerializable(explicitToJson: true) 446 + const factory EmbedViewRecord.notFound({ 447 + @AtUriConverter() required AtUri uri, 448 + required bool notFound, 449 + }) = EmbedViewRecord_NotFound; 450 + 451 + /// A placeholder for a record that is blocked. 452 + @FreezedUnionValue('app.bsky.embed.record#viewBlocked') 453 + @JsonSerializable(explicitToJson: true) 454 + const factory EmbedViewRecord.blocked({ 455 + @AtUriConverter() required AtUri uri, 456 + required bool blocked, 457 + required BlockedAuthor author, 458 + }) = EmbedViewRecord_Blocked; 459 + 460 + factory EmbedViewRecord.fromJson(Map<String, dynamic> json) => _$EmbedViewRecordFromJson(json); 435 461 } 436 462 437 463 @freezed
+1 -1
lib/src/core/network/atproto/data/models/records.dart
··· 127 127 @FreezedUnionValue('app.bsky.embed.recordWithMedia') 128 128 @JsonSerializable(explicitToJson: true) 129 129 const factory Embed.bskyRecordWithMedia({ 130 - required StrongRef record, 130 + required EmbedBskyRecord record, 131 131 required Embed media, 132 132 }) = EmbedBskyRecordWithMedia; 133 133
+11
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 139 139 /// 140 140 /// [storyUris] List of story URIs to fetch 141 141 Future<List<StoryView>> getStoryViews(List<AtUri> storyUris); 142 + 143 + /// Search for posts 144 + /// [query] The search query string 145 + /// [limit] The number of items to return (default 20) 146 + /// [cursor] Pagination cursor for the next set of results 147 + Future<({List<PostView> posts, String? cursor})> searchPosts( 148 + String query, { 149 + int limit = 20, 150 + String sort = 'latest', 151 + String? cursor, 152 + }); 142 153 }
+47 -1
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 5 5 import 'package:atproto/atproto.dart'; 6 6 import 'package:atproto/core.dart'; 7 7 import 'package:bluesky/bluesky.dart' as bsky; 8 - /// embed converter 9 8 // ignore: implementation_imports 10 9 import 'package:bluesky/src/services/entities/converter/embed_converter.dart'; 11 10 import 'package:get_it/get_it.dart'; ··· 887 886 _logger.e('Failed to post story: ${response.status} ${response.data}'); 888 887 throw Exception('Failed to post story: ${response.status} ${response.data}'); 889 888 } 889 + }); 890 + } 891 + 892 + @override 893 + Future<({List<PostView> posts, String? cursor})> searchPosts( 894 + String query, { 895 + int limit = 20, 896 + String sort = 'latest', 897 + String? cursor, 898 + }) async { 899 + _logger.d('Searching posts with query: $query, limit: $limit, sort: $sort, cursor: $cursor'); 900 + 901 + return _client.executeWithRetry(() async { 902 + if (!_client.authRepository.isAuthenticated) { 903 + _logger.w('Not authenticated'); 904 + throw Exception('Not authenticated'); 905 + } 906 + 907 + final atproto = _client.authRepository.atproto; 908 + if (atproto == null) { 909 + _logger.e('AtProto not initialized'); 910 + throw Exception('AtProto not initialized'); 911 + } 912 + 913 + final parameters = <String, dynamic>{ 914 + 'q': query, 915 + 'limit': limit, 916 + 'sort': sort, 917 + 'cursor': cursor, 918 + }; 919 + 920 + final response = await atproto.get( 921 + NSID.parse('so.sprk.feed.searchPosts'), 922 + parameters: parameters, 923 + headers: {'atproto-proxy': _client.sprkDid}, 924 + to: (jsonMap) => jsonMap, 925 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 926 + ); 927 + 928 + final posts = (response.data['posts']! as List<dynamic>) 929 + .map((post) => post as Map<String, dynamic>) 930 + .map(PostView.fromJson) 931 + .toList(); 932 + 933 + final newCursor = response.data['cursor'] as String?; 934 + 935 + return (posts: posts, cursor: newCursor); 890 936 }); 891 937 } 892 938
+25
lib/src/core/utils/label_utils.dart
··· 29 29 30 30 final settingsRepository = GetIt.instance<SettingsRepository>(); 31 31 32 + 33 + final masterBlur = await settingsRepository.getFeedBlurEnabled(); 34 + if (!masterBlur) return false; // If blur setting is not enabled, blur nothing 35 + 32 36 for (final label in labels) { 33 37 try { 34 38 final preference = await settingsRepository.getLabelPreference(label.value); ··· 84 88 } 85 89 86 90 return informLabels; 91 + } 92 + 93 + static Future<bool> shouldHideContent(List<Label> labels) async { 94 + if (labels.isEmpty) return false; 95 + 96 + final settingsRepository = GetIt.instance<SettingsRepository>(); 97 + final hideAdultContent = await settingsRepository.getHideAdultContent(); 98 + 99 + for (final label in labels) { 100 + try { 101 + final preference = await settingsRepository.getLabelPreference(label.value); 102 + if (preference.setting == Setting.hide || (preference.adultOnly && hideAdultContent)) { 103 + return true; 104 + } 105 + } catch (e) { 106 + // If no preference found, continue checking other labels 107 + continue; 108 + } 109 + } 110 + 111 + return false; 87 112 } 88 113 }
+34 -32
lib/src/core/widgets/content_warning_overlay.dart
··· 16 16 final List<String> warningLabels; 17 17 final Widget? child; 18 18 final bool shouldBlur; // content blur 19 + 19 20 @override 20 21 Widget build(BuildContext context) { 21 22 return Stack( ··· 24 25 if (child != null) 25 26 if (shouldBlur) ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 40, sigmaY: 40), child: child) else child!, 26 27 // Warning overlay 27 - Positioned.fill( 28 - child: Center( 29 - child: Padding( 30 - padding: const EdgeInsets.all(24), 31 - child: Column( 32 - mainAxisAlignment: MainAxisAlignment.center, 33 - children: [ 34 - const Icon(Icons.warning_amber_rounded, color: AppColors.white, size: 48), 35 - const SizedBox(height: 16), 36 - const Text( 37 - 'Content Warning', 38 - style: TextStyle(color: AppColors.white, fontSize: 24, fontWeight: FontWeight.bold), 39 - textAlign: TextAlign.center, 40 - ), 41 - const SizedBox(height: 12), 42 - Text( 43 - 'This content has been flagged for:\n${warningLabels.join(', ')}', 44 - style: const TextStyle(color: AppColors.white, fontSize: 16), 45 - textAlign: TextAlign.center, 46 - ), 47 - const SizedBox(height: 24), 48 - ElevatedButton( 49 - onPressed: onViewContent, 50 - style: ElevatedButton.styleFrom( 51 - backgroundColor: AppColors.white, 52 - foregroundColor: AppColors.black, 53 - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), 54 - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), 28 + if (shouldBlur) 29 + Positioned.fill( 30 + child: Center( 31 + child: Padding( 32 + padding: const EdgeInsets.all(24), 33 + child: Column( 34 + mainAxisAlignment: MainAxisAlignment.center, 35 + children: [ 36 + const Icon(Icons.warning_amber_rounded, color: AppColors.white, size: 48), 37 + const SizedBox(height: 16), 38 + const Text( 39 + 'Content Warning', 40 + style: TextStyle(color: AppColors.white, fontSize: 24, fontWeight: FontWeight.bold), 41 + textAlign: TextAlign.center, 55 42 ), 56 - child: const Text('View Content', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), 57 - ), 58 - ], 43 + const SizedBox(height: 12), 44 + Text( 45 + 'This content has been flagged for:\n${warningLabels.join(', ')}', 46 + style: const TextStyle(color: AppColors.white, fontSize: 16), 47 + textAlign: TextAlign.center, 48 + ), 49 + const SizedBox(height: 24), 50 + ElevatedButton( 51 + onPressed: onViewContent, 52 + style: ElevatedButton.styleFrom( 53 + backgroundColor: AppColors.white, 54 + foregroundColor: AppColors.black, 55 + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), 56 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), 57 + ), 58 + child: const Text('View Content', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), 59 + ), 60 + ], 61 + ), 59 62 ), 60 63 ), 61 64 ), 62 - ), 63 65 ], 64 66 ); 65 67 }
+8 -10
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 26 26 } 27 27 28 28 class _StandalonePostPageState extends ConsumerState<StandalonePostPage> { 29 - Future<dynamic>? _postFuture; 29 + Future<PostView>? _postFuture; 30 30 int? _lastUpdateCount; 31 31 final GlobalKey<PostVideoPlayerState> _videoPlayerKey = GlobalKey<PostVideoPlayerState>(); 32 32 bool _showWarningOverlay = false; ··· 41 41 42 42 void _loadPost() { 43 43 _postFuture = _loadPostWithFallback(); 44 + _postFuture?.then((post) { 45 + if (mounted) { 46 + _checkContentWarning(post); 47 + } 48 + }); 44 49 } 45 50 46 51 Future<PostView> _loadPostWithFallback() async { ··· 126 131 body: SafeArea( 127 132 child: _postFuture == null 128 133 ? const Center(child: CircularProgressIndicator()) 129 - : FutureBuilder( 134 + : FutureBuilder<PostView>( 130 135 future: _postFuture, 131 136 builder: (context, snapshot) { 132 137 if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { 133 - final postData = snapshot.data! as PostView; 134 - 135 - // Check for content warning on post load 136 - WidgetsBinding.instance.addPostFrameCallback((_) { 137 - if (mounted) { 138 - _checkContentWarning(postData); 139 - } 140 - }); 138 + final postData = snapshot.data!; 141 139 142 140 final mainContent = Stack( 143 141 children: [
+1 -3
lib/src/features/profile/providers/profile_feed_provider.dart
··· 173 173 image: (images) => false, 174 174 bskyImages: (images) => false, 175 175 bskyRecord: (record) => false, 176 - bskyRecordViewRecord: 177 - (uri, cid, author, value, indexedAt, labels, replyCount, repostCount, likeCount, quoteCount, embeds) => false, 178 - bskyExternal: (external, cid) => false, 176 + bskyExternal: (external) => false, 179 177 ); 180 178 } 181 179
+226
lib/src/features/search/providers/post_search_provider.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:atproto_core/atproto_core.dart'; 4 + import 'package:bluesky/bluesky.dart' as bsky; 5 + import 'package:get_it/get_it.dart'; 6 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 7 + import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 8 + import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 9 + import 'package:sparksocial/src/core/utils/label_utils.dart'; 10 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 11 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 12 + import 'package:sparksocial/src/features/search/providers/post_search_state.dart'; 13 + 14 + part 'post_search_provider.g.dart'; 15 + 16 + /// Search provider for post search functionality 17 + @riverpod 18 + class PostSearch extends _$PostSearch { 19 + Timer? _debounce; 20 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('PostSearchProvider'); 21 + final FeedRepository _feedRepository = GetIt.instance<SprkRepository>().feed; 22 + final AuthRepository _authRepository = GetIt.instance<AuthRepository>(); 23 + 24 + @override 25 + PostSearchState build() { 26 + ref.onDispose(() { 27 + _debounce?.cancel(); 28 + }); 29 + 30 + return PostSearchState.initial(); 31 + } 32 + 33 + /// Update the search query and trigger search with debounce 34 + void updateQuery(String query) { 35 + // Update query and reset pagination state 36 + state = state.copyWith(query: query, searchResults: [], sprkNextCursor: null, bskyNextCursor: null, error: null); 37 + 38 + if (query.isEmpty) { 39 + state = state.copyWith(isLoading: false); 40 + return; 41 + } 42 + 43 + // Set loading state immediately for non-empty queries 44 + state = state.copyWith(isLoading: true); 45 + 46 + // Debounce the search 47 + if (_debounce?.isActive ?? false) _debounce!.cancel(); 48 + _debounce = Timer(const Duration(milliseconds: 500), () { 49 + _searchPosts(query); 50 + }); 51 + } 52 + 53 + /// Search for posts with the given query 54 + Future<void> _searchPosts(String query) async { 55 + if (query.isEmpty) return; 56 + 57 + state = state.copyWith(isLoading: true, error: null); 58 + 59 + try { 60 + final bskySession = _authRepository.session; 61 + if (bskySession == null) { 62 + return; 63 + } 64 + 65 + final bskyApi = bsky.Bluesky.fromSession(bskySession); 66 + final sprkSearch = _feedRepository.searchPosts(query); 67 + final bskySearch = bskyApi.feed.searchPosts(query, sort: 'top'); 68 + 69 + final results = await Future.wait([sprkSearch, bskySearch]); 70 + 71 + final sprkResponse = results[0] as ({String? cursor, List<PostView> posts}); 72 + final bskyResponse = results[1] as XRPCResponse<bsky.PostsByQuery>; 73 + 74 + final bskyPosts = bskyResponse.data.posts 75 + .asMap() 76 + .entries 77 + .map((entry) { 78 + final index = entry.key; 79 + final post = entry.value; 80 + 81 + try { 82 + final postJson = post.toJson(); 83 + if (postJson['record']['reply'] != null || post.embed == null) { 84 + return null; 85 + } 86 + return PostView.fromJson(postJson); 87 + } catch (e, stackTrace) { 88 + final postJson = post.toJson(); 89 + _logger.e('Failed to convert bsky post ${index + 1}/${bskyResponse.data.posts.length}'); 90 + _logger.e('Post URI: ${post.uri}'); 91 + _logger.e('Post JSON: $postJson'); 92 + _logger.e('Error: $e'); 93 + _logger.e('Stack trace: $stackTrace'); 94 + return null; 95 + } 96 + }) 97 + .where((post) => post != null && post.hasSupportedMedia) 98 + .cast<PostView>() 99 + .toList(); 100 + 101 + _logger.d('Successfully converted ${bskyPosts.length}/${bskyResponse.data.posts.length} bsky posts'); 102 + 103 + final filteredSprkPosts = await _filterHiddenPosts(sprkResponse.posts); 104 + final filteredBskyPosts = await _filterHiddenPosts(bskyPosts); 105 + 106 + final combinedPosts = [...filteredSprkPosts, ...filteredBskyPosts]; 107 + 108 + state = state.copyWith( 109 + searchResults: combinedPosts, 110 + sprkNextCursor: sprkResponse.cursor, 111 + bskyNextCursor: bskyResponse.data.cursor, 112 + isLoading: false, 113 + ); 114 + _logger.d( 115 + 'Search completed with ${combinedPosts.length} results, sprkNextCursor: ${sprkResponse.cursor}, bskyNextCursor: ${bskyResponse.data.cursor}', 116 + ); 117 + 118 + // If we have very few results, try to load more immediately 119 + if (state.searchResults.length < 10 && (state.sprkNextCursor != null || state.bskyNextCursor != null)) { 120 + await loadMorePosts(); 121 + } 122 + } catch (e) { 123 + _logger.e('Error searching posts: $e'); 124 + state = state.copyWith(error: e.toString(), isLoading: false); 125 + } 126 + } 127 + 128 + /// Load more posts using the next cursor if available 129 + Future<void> loadMorePosts() async { 130 + if (state.isLoadingMore) return; 131 + 132 + state = state.copyWith(isLoadingMore: true); 133 + 134 + try { 135 + await _loadMorePostsRecursive(); 136 + } catch (e) { 137 + _logger.e('Error loading more posts: $e'); 138 + state = state.copyWith(error: e.toString(), isLoadingMore: false); 139 + } finally { 140 + if (state.isLoadingMore) { 141 + state = state.copyWith(isLoadingMore: false); 142 + } 143 + } 144 + } 145 + 146 + Future<void> _loadMorePostsRecursive() async { 147 + final sprkCursor = state.sprkNextCursor; 148 + if (sprkCursor != null && sprkCursor.isNotEmpty) { 149 + final response = await _feedRepository.searchPosts(state.query, cursor: sprkCursor); 150 + final filteredPosts = await _filterHiddenPosts(response.posts); 151 + state = state.copyWith( 152 + searchResults: [...state.searchResults, ...filteredPosts], 153 + sprkNextCursor: response.cursor, 154 + ); 155 + 156 + // If sprk is exhausted now and we have a bsky cursor, fetch from bsky. 157 + if ((response.cursor == null || response.cursor!.isEmpty) && 158 + (state.bskyNextCursor != null && state.bskyNextCursor!.isNotEmpty)) { 159 + await _loadMorePostsRecursive(); 160 + } 161 + return; 162 + } 163 + 164 + final bskyCursor = state.bskyNextCursor; 165 + if (bskyCursor != null && bskyCursor.isNotEmpty) { 166 + final bskySession = _authRepository.session; 167 + if (bskySession == null) { 168 + return; 169 + } 170 + final bskyApi = bsky.Bluesky.fromSession(bskySession); 171 + final response = await bskyApi.feed.searchPosts(state.query, sort: 'latest', cursor: bskyCursor); 172 + 173 + final bskyPosts = response.data.posts 174 + .asMap() 175 + .entries 176 + .map((entry) { 177 + final index = entry.key; 178 + final post = entry.value; 179 + 180 + try { 181 + final postJson = post.toJson(); 182 + if (postJson['record']['reply'] != null || post.embed == null) { 183 + return null; 184 + } 185 + return PostView.fromJson(postJson); 186 + } catch (e, stackTrace) { 187 + final postJson = post.toJson(); 188 + _logger.e('Failed to convert bsky post ${index + 1}/${response.data.posts.length}'); 189 + _logger.e('Post URI: ${post.uri}'); 190 + _logger.e('Post JSON: $postJson'); 191 + _logger.e('Error: $e'); 192 + _logger.e('Stack trace: $stackTrace'); 193 + return null; 194 + } 195 + }) 196 + .where((post) => post != null && post.hasSupportedMedia) 197 + .cast<PostView>() 198 + .toList(); 199 + 200 + final initialCount = state.searchResults.length; 201 + final filteredBskyPosts = await _filterHiddenPosts(bskyPosts); 202 + state = state.copyWith( 203 + searchResults: [...state.searchResults, ...filteredBskyPosts], 204 + bskyNextCursor: response.data.cursor, 205 + ); 206 + 207 + // If we still have few results and a cursor, and we actually added new posts, recurse. 208 + if (state.searchResults.length < 10 && 209 + (state.bskyNextCursor != null && state.bskyNextCursor!.isNotEmpty) && 210 + state.searchResults.length > initialCount) { 211 + await _loadMorePostsRecursive(); 212 + } 213 + } 214 + } 215 + 216 + Future<List<PostView>> _filterHiddenPosts(List<PostView> posts) async { 217 + final filteredPosts = <PostView>[]; 218 + for (final post in posts) { 219 + if (!await LabelUtils.shouldHideContent(post.labels ?? [])) { 220 + filteredPosts.add(post); 221 + } 222 + } 223 + 224 + return filteredPosts; 225 + } 226 + }
+39
lib/src/features/search/providers/post_search_state.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 3 + 4 + part 'post_search_state.freezed.dart'; 5 + part 'post_search_state.g.dart'; 6 + 7 + /// Represents the state of the post search 8 + @freezed 9 + class PostSearchState with _$PostSearchState { 10 + /// Creates a new post search state 11 + const factory PostSearchState({ 12 + /// Whether search results are loading 13 + @Default(false) bool isLoading, 14 + 15 + /// Search results - list of posts 16 + @Default([]) List<PostView> searchResults, 17 + 18 + /// Cursor for the next page of results (if any) 19 + String? sprkNextCursor, 20 + 21 + /// Cursor for the next page of bsky results (if any) 22 + String? bskyNextCursor, 23 + 24 + /// Whether more results are currently loading 25 + @Default(false) bool isLoadingMore, 26 + 27 + /// Error message if search failed 28 + String? error, 29 + 30 + /// Current search query 31 + @Default('') String query, 32 + }) = _PostSearchState; 33 + 34 + /// Initial empty state 35 + factory PostSearchState.initial() => const PostSearchState(); 36 + 37 + /// Factory to create from json 38 + factory PostSearchState.fromJson(Map<String, dynamic> json) => _$PostSearchStateFromJson(json); 39 + }
+200
lib/src/features/search/ui/pages/post_results.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 + import 'package:sparksocial/src/core/routing/app_router.dart'; 5 + import 'package:sparksocial/src/features/search/providers/post_search_provider.dart'; 6 + import 'package:sparksocial/src/features/search/ui/widgets/post_card.dart'; 7 + 8 + class PostResults extends ConsumerStatefulWidget { 9 + const PostResults({super.key}); 10 + 11 + @override 12 + ConsumerState<PostResults> createState() => _PostResultsState(); 13 + } 14 + 15 + class _PostResultsState extends ConsumerState<PostResults> with AutomaticKeepAliveClientMixin { 16 + final ScrollController _scrollController = ScrollController(); 17 + 18 + @override 19 + bool get wantKeepAlive => true; 20 + 21 + @override 22 + void initState() { 23 + super.initState(); 24 + _scrollController.addListener(_onScroll); 25 + } 26 + 27 + @override 28 + void dispose() { 29 + _scrollController.removeListener(_onScroll); 30 + _scrollController.dispose(); 31 + super.dispose(); 32 + } 33 + 34 + void _onScroll() { 35 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 36 + ref.read(postSearchProvider.notifier).loadMorePosts(); 37 + } 38 + } 39 + 40 + @override 41 + Widget build(BuildContext context) { 42 + super.build(context); 43 + final state = ref.watch(postSearchProvider); 44 + final theme = Theme.of(context); 45 + 46 + if (state.isLoading && state.searchResults.isEmpty) { 47 + return const Center( 48 + child: Padding( 49 + padding: EdgeInsets.all(24), 50 + child: CircularProgressIndicator(), 51 + ), 52 + ); 53 + } 54 + 55 + if (state.error != null) { 56 + return Center( 57 + child: Padding( 58 + padding: const EdgeInsets.all(24), 59 + child: Column( 60 + mainAxisAlignment: MainAxisAlignment.center, 61 + children: [ 62 + Icon( 63 + Icons.error_outline, 64 + size: 48, 65 + color: theme.colorScheme.error, 66 + ), 67 + const SizedBox(height: 16), 68 + Text( 69 + 'Something went wrong', 70 + style: theme.textTheme.titleMedium?.copyWith( 71 + color: theme.colorScheme.error, 72 + ), 73 + ), 74 + const SizedBox(height: 8), 75 + Text( 76 + state.error!, 77 + style: theme.textTheme.bodyMedium?.copyWith( 78 + color: theme.colorScheme.onSurfaceVariant, 79 + ), 80 + textAlign: TextAlign.center, 81 + ), 82 + ], 83 + ), 84 + ), 85 + ); 86 + } 87 + 88 + if (state.query.isEmpty) { 89 + return Center( 90 + child: Padding( 91 + padding: const EdgeInsets.all(24), 92 + child: Column( 93 + mainAxisAlignment: MainAxisAlignment.center, 94 + children: [ 95 + Icon( 96 + Icons.search, 97 + size: 64, 98 + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), 99 + ), 100 + const SizedBox(height: 16), 101 + Text( 102 + 'Search for posts', 103 + style: theme.textTheme.headlineSmall?.copyWith( 104 + color: theme.colorScheme.onSurfaceVariant, 105 + ), 106 + ), 107 + const SizedBox(height: 8), 108 + Text( 109 + 'Enter a search term to find posts', 110 + style: theme.textTheme.bodyMedium?.copyWith( 111 + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), 112 + ), 113 + ), 114 + ], 115 + ), 116 + ), 117 + ); 118 + } 119 + 120 + if (state.searchResults.isEmpty && !state.isLoading) { 121 + return Center( 122 + child: Padding( 123 + padding: const EdgeInsets.all(24), 124 + child: Column( 125 + mainAxisAlignment: MainAxisAlignment.center, 126 + children: [ 127 + Icon( 128 + Icons.search_off, 129 + size: 64, 130 + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), 131 + ), 132 + const SizedBox(height: 16), 133 + Text( 134 + 'No posts found', 135 + style: theme.textTheme.headlineSmall?.copyWith( 136 + color: theme.colorScheme.onSurfaceVariant, 137 + ), 138 + ), 139 + const SizedBox(height: 8), 140 + Text( 141 + 'Try searching with different keywords', 142 + style: theme.textTheme.bodyMedium?.copyWith( 143 + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), 144 + ), 145 + ), 146 + ], 147 + ), 148 + ), 149 + ); 150 + } 151 + 152 + return CustomScrollView( 153 + controller: _scrollController, 154 + slivers: [ 155 + // Grid of posts 156 + SliverPadding( 157 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 158 + sliver: SliverGrid( 159 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 160 + crossAxisCount: 2, 161 + crossAxisSpacing: 12, 162 + mainAxisSpacing: 12, 163 + childAspectRatio: 0.45, // Adjust this to control card height 164 + ), 165 + delegate: SliverChildBuilderDelegate( 166 + (context, index) { 167 + if (index >= state.searchResults.length) { 168 + return const Center(child: CircularProgressIndicator()); 169 + } 170 + 171 + final post = state.searchResults[index]; 172 + return PostCard( 173 + post: post, 174 + onTap: () { 175 + context.router.push(StandalonePostRoute(postUri: post.uri.toString())); 176 + }, 177 + ); 178 + }, 179 + childCount: state.searchResults.length, 180 + ), 181 + ), 182 + ), 183 + 184 + // Loading indicator for pagination 185 + if (state.isLoadingMore) 186 + const SliverToBoxAdapter( 187 + child: Padding( 188 + padding: EdgeInsets.all(24), 189 + child: Center(child: CircularProgressIndicator()), 190 + ), 191 + ), 192 + 193 + // Bottom padding 194 + const SliverToBoxAdapter( 195 + child: SizedBox(height: 24), 196 + ), 197 + ], 198 + ); 199 + } 200 + }
+28 -133
lib/src/features/search/ui/pages/search_page.dart
··· 1 - import 'package:atproto_core/atproto_core.dart'; 2 1 import 'package:auto_route/auto_route.dart'; 3 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 3 import 'package:flutter/material.dart'; 5 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 - import 'package:sparksocial/src/core/routing/app_router.dart'; 5 + import 'package:sparksocial/src/features/search/providers/post_search_provider.dart'; 7 6 import 'package:sparksocial/src/features/search/providers/search_provider.dart'; 8 - import 'package:sparksocial/src/features/search/ui/widgets/suggested_account_card.dart'; 7 + import 'package:sparksocial/src/features/search/ui/pages/post_results.dart'; 8 + import 'package:sparksocial/src/features/search/ui/pages/user_results.dart'; 9 9 import 'package:sparksocial/src/features/stories/providers/stories_by_author.dart'; 10 10 import 'package:sparksocial/src/features/stories/ui/widgets/stories_list.dart'; 11 11 ··· 22 22 final TextEditingController _searchController = TextEditingController(); 23 23 24 24 @override 25 - void initState() { 26 - super.initState(); 27 - _searchController.addListener(_onSearchChanged); 28 - } 29 - 30 - @override 31 25 void dispose() { 32 - _searchController.removeListener(_onSearchChanged); 33 26 _searchController.dispose(); 34 27 super.dispose(); 35 28 } 36 29 37 - void _onSearchChanged() { 38 - ref.read(searchProvider.notifier).updateQuery(_searchController.text.trim()); 30 + void _onSearchChanged(String query) { 31 + final trimmedQuery = query.trim(); 32 + ref.read(searchProvider.notifier).updateQuery(trimmedQuery); 33 + ref.read(postSearchProvider.notifier).updateQuery(trimmedQuery); 39 34 } 40 35 41 36 @override ··· 45 40 final colorScheme = theme.colorScheme; 46 41 47 42 return DefaultTabController( 48 - length: 1, 43 + length: 2, 49 44 child: Scaffold( 50 45 backgroundColor: colorScheme.surface, 51 46 body: SafeArea( ··· 56 51 padding: const EdgeInsets.all(16), 57 52 child: TextField( 58 53 controller: _searchController, 54 + onChanged: _onSearchChanged, 59 55 decoration: InputDecoration( 60 - hintText: 'Search users', 56 + hintText: 'Search users, posts...', 61 57 prefixIcon: Icon(FluentIcons.search_24_regular, color: theme.textTheme.bodyMedium?.color), 62 58 filled: true, 63 59 suffixIcon: _searchController.text.isNotEmpty ··· 67 63 onPressed: () { 68 64 _searchController.clear(); 69 65 ref.read(searchProvider.notifier).updateQuery(''); 66 + ref.read(postSearchProvider.notifier).updateQuery(''); 70 67 }, 71 68 icon: const Icon(FluentIcons.dismiss_24_regular), 72 69 ) ··· 85 82 ), 86 83 ), 87 84 // ==== Stories or Search Results ==== 88 - if (searchState.query.isEmpty) ...[ 85 + if (searchState.query.isEmpty) 89 86 Expanded( 90 87 child: RefreshIndicator( 91 88 onRefresh: () async { 92 89 // Refresh the stories timeline 93 90 ref.invalidate(storiesByAuthorProvider()); 94 91 }, 95 - child: CustomScrollView( 92 + child: const CustomScrollView( 96 93 slivers: [ 97 - const SliverToBoxAdapter(child: StoriesList()), 94 + SliverToBoxAdapter(child: StoriesList()), 98 95 SliverFillRemaining( 99 96 hasScrollBody: false, 100 97 child: Center( 101 - child: Column( 102 - mainAxisAlignment: MainAxisAlignment.center, 103 - children: [ 104 - Icon(FluentIcons.search_24_regular, size: 48, color: theme.textTheme.bodyMedium?.color), 105 - const SizedBox(height: 16), 106 - Text( 107 - 'Search for users', 108 - style: TextStyle(fontSize: 16, color: theme.textTheme.bodyMedium?.color), 109 - ), 110 - const SizedBox(height: 8), 111 - Text( 112 - 'Tap the search bar above to find people', 113 - style: TextStyle(fontSize: 14, color: theme.textTheme.bodyMedium?.color?.withAlpha(180)), 114 - textAlign: TextAlign.center, 115 - ), 116 - ], 117 - ), 98 + child: Text('Discover new content'), 118 99 ), 119 100 ), 120 101 ], 121 102 ), 122 103 ), 104 + ) 105 + else ...[ 106 + const TabBar( 107 + tabs: [ 108 + Tab(text: 'Posts'), 109 + Tab(text: 'Users'), 110 + ], 123 111 ), 124 - ] else ...[ 125 - Theme( 126 - data: Theme.of(context).copyWith(tabBarTheme: const TabBarThemeData(dividerColor: Colors.transparent)), 127 - child: TabBar( 128 - tabs: const [Tab(text: 'Users')], 129 - indicatorColor: colorScheme.primary, 130 - labelColor: theme.textTheme.bodyLarge?.color, 131 - unselectedLabelColor: theme.textTheme.bodyMedium?.color, 112 + const Expanded( 113 + child: TabBarView( 114 + children: [ 115 + PostResults(), 116 + UserResults(), 117 + ], 132 118 ), 133 119 ), 134 - const Expanded(child: TabBarView(children: [UserResults()])), 135 120 ], 136 121 ], 137 122 ), ··· 140 125 ); 141 126 } 142 127 } 143 - 144 - class UserResults extends ConsumerStatefulWidget { 145 - const UserResults({super.key}); 146 - 147 - @override 148 - ConsumerState<UserResults> createState() => _UserResultsState(); 149 - } 150 - 151 - class _UserResultsState extends ConsumerState<UserResults> { 152 - final ScrollController _scrollController = ScrollController(); 153 - 154 - @override 155 - void initState() { 156 - super.initState(); 157 - _scrollController.addListener(_onScroll); 158 - } 159 - 160 - @override 161 - void dispose() { 162 - _scrollController.removeListener(_onScroll); 163 - _scrollController.dispose(); 164 - super.dispose(); 165 - } 166 - 167 - void _onScroll() { 168 - if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 169 - // Trigger pagination when close to the bottom 170 - ref.read(searchProvider.notifier).loadMoreUsers(); 171 - } 172 - } 173 - 174 - @override 175 - Widget build(BuildContext context) { 176 - final state = ref.watch(searchProvider); 177 - 178 - if (state.isLoading && state.searchResults.isEmpty) { 179 - return const Center(child: CircularProgressIndicator()); 180 - } 181 - if (state.error != null) { 182 - return Center( 183 - child: Text(state.error!, style: const TextStyle(color: Colors.red)), 184 - ); 185 - } 186 - if (state.query.isEmpty) { 187 - return const SizedBox.shrink(); 188 - } 189 - 190 - final itemCount = state.isLoadingMore ? state.searchResults.length + 1 : state.searchResults.length; 191 - 192 - return ListView.builder( 193 - controller: _scrollController, 194 - itemCount: itemCount, 195 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 196 - itemBuilder: (context, index) { 197 - if (index >= state.searchResults.length) { 198 - // Loading indicator at bottom 199 - return const Padding( 200 - padding: EdgeInsets.all(16), 201 - child: Center(child: CircularProgressIndicator()), 202 - ); 203 - } 204 - 205 - final actor = state.searchResults[index]; 206 - 207 - // Check if the user is being followed 208 - final isFollowing = actor.viewer?.following != null; 209 - 210 - return Padding( 211 - padding: const EdgeInsets.only(bottom: 8), 212 - child: SuggestedAccountCard( 213 - username: actor.displayName ?? actor.handle, 214 - handle: '@${actor.handle}', 215 - avatarUrl: actor.avatar?.toString() ?? '', 216 - description: actor.description ?? '', 217 - onTap: () { 218 - if (actor.did.isNotEmpty) { 219 - context.router.push(ProfileRoute(did: actor.did)); 220 - } 221 - }, 222 - showFollowButton: !ref.read(searchProvider.notifier).isCurrentUser(actor.did), 223 - isFollowing: isFollowing, 224 - onFollowTap: () => ref.read(searchProvider.notifier).followUser(actor.did), 225 - onUnfollowTap: () => 226 - ref.read(searchProvider.notifier).unfollowUser(actor.did, actor.viewer?.following ?? AtUri.parse('')), 227 - ), 228 - ); 229 - }, 230 - ); 231 - } 232 - }
+196
lib/src/features/search/ui/pages/user_results.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:auto_route/auto_route.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 + import 'package:sparksocial/src/core/routing/app_router.dart'; 6 + import 'package:sparksocial/src/features/search/providers/search_provider.dart'; 7 + import 'package:sparksocial/src/features/search/ui/widgets/suggested_account_card.dart'; 8 + 9 + class UserResults extends ConsumerStatefulWidget { 10 + const UserResults({super.key}); 11 + 12 + @override 13 + ConsumerState<UserResults> createState() => _UserResultsState(); 14 + } 15 + 16 + class _UserResultsState extends ConsumerState<UserResults> with AutomaticKeepAliveClientMixin { 17 + final ScrollController _scrollController = ScrollController(); 18 + 19 + @override 20 + bool get wantKeepAlive => true; 21 + 22 + @override 23 + void initState() { 24 + super.initState(); 25 + _scrollController.addListener(_onScroll); 26 + } 27 + 28 + @override 29 + void dispose() { 30 + _scrollController.removeListener(_onScroll); 31 + _scrollController.dispose(); 32 + super.dispose(); 33 + } 34 + 35 + void _onScroll() { 36 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 37 + // Trigger pagination when close to the bottom 38 + ref.read(searchProvider.notifier).loadMoreUsers(); 39 + } 40 + } 41 + 42 + @override 43 + Widget build(BuildContext context) { 44 + super.build(context); 45 + final state = ref.watch(searchProvider); 46 + final theme = Theme.of(context); 47 + 48 + if (state.isLoading && state.searchResults.isEmpty) { 49 + return const Center( 50 + child: Padding( 51 + padding: EdgeInsets.all(24), 52 + child: CircularProgressIndicator(), 53 + ), 54 + ); 55 + } 56 + 57 + if (state.error != null) { 58 + return Center( 59 + child: Padding( 60 + padding: const EdgeInsets.all(24), 61 + child: Column( 62 + mainAxisAlignment: MainAxisAlignment.center, 63 + children: [ 64 + Icon( 65 + Icons.error_outline, 66 + size: 48, 67 + color: theme.colorScheme.error, 68 + ), 69 + const SizedBox(height: 16), 70 + Text( 71 + 'Something went wrong', 72 + style: theme.textTheme.titleMedium?.copyWith( 73 + color: theme.colorScheme.error, 74 + ), 75 + ), 76 + const SizedBox(height: 8), 77 + Text( 78 + state.error!, 79 + style: theme.textTheme.bodyMedium?.copyWith( 80 + color: theme.colorScheme.onSurfaceVariant, 81 + ), 82 + textAlign: TextAlign.center, 83 + ), 84 + ], 85 + ), 86 + ), 87 + ); 88 + } 89 + 90 + if (state.query.isEmpty) { 91 + return Center( 92 + child: Padding( 93 + padding: const EdgeInsets.all(24), 94 + child: Column( 95 + mainAxisAlignment: MainAxisAlignment.center, 96 + children: [ 97 + Icon( 98 + Icons.people_outline, 99 + size: 64, 100 + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), 101 + ), 102 + const SizedBox(height: 16), 103 + Text( 104 + 'Search for users', 105 + style: theme.textTheme.headlineSmall?.copyWith( 106 + color: theme.colorScheme.onSurfaceVariant, 107 + ), 108 + ), 109 + const SizedBox(height: 8), 110 + Text( 111 + 'Enter a search term to find users', 112 + style: theme.textTheme.bodyMedium?.copyWith( 113 + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), 114 + ), 115 + ), 116 + ], 117 + ), 118 + ), 119 + ); 120 + } 121 + 122 + if (state.searchResults.isEmpty && !state.isLoading) { 123 + return Center( 124 + child: Padding( 125 + padding: const EdgeInsets.all(24), 126 + child: Column( 127 + mainAxisAlignment: MainAxisAlignment.center, 128 + children: [ 129 + Icon( 130 + Icons.person_search, 131 + size: 64, 132 + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), 133 + ), 134 + const SizedBox(height: 16), 135 + Text( 136 + 'No users found', 137 + style: theme.textTheme.headlineSmall?.copyWith( 138 + color: theme.colorScheme.onSurfaceVariant, 139 + ), 140 + ), 141 + const SizedBox(height: 8), 142 + Text( 143 + 'Try searching with different keywords', 144 + style: theme.textTheme.bodyMedium?.copyWith( 145 + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), 146 + ), 147 + ), 148 + ], 149 + ), 150 + ), 151 + ); 152 + } 153 + 154 + final itemCount = state.isLoadingMore ? state.searchResults.length + 1 : state.searchResults.length; 155 + 156 + return ListView.builder( 157 + controller: _scrollController, 158 + itemCount: itemCount, 159 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 160 + itemBuilder: (context, index) { 161 + if (index >= state.searchResults.length) { 162 + // Loading indicator at bottom 163 + return const Padding( 164 + padding: EdgeInsets.all(16), 165 + child: Center(child: CircularProgressIndicator()), 166 + ); 167 + } 168 + 169 + final actor = state.searchResults[index]; 170 + 171 + // Check if the user is being followed 172 + final isFollowing = actor.viewer?.following != null; 173 + 174 + return Padding( 175 + padding: const EdgeInsets.only(bottom: 8), 176 + child: SuggestedAccountCard( 177 + username: actor.displayName ?? actor.handle, 178 + handle: '@${actor.handle}', 179 + avatarUrl: actor.avatar?.toString() ?? '', 180 + description: actor.description ?? '', 181 + onTap: () { 182 + if (actor.did.isNotEmpty) { 183 + context.router.push(ProfileRoute(did: actor.did)); 184 + } 185 + }, 186 + showFollowButton: !ref.read(searchProvider.notifier).isCurrentUser(actor.did), 187 + isFollowing: isFollowing, 188 + onFollowTap: () => ref.read(searchProvider.notifier).followUser(actor.did), 189 + onUnfollowTap: () => 190 + ref.read(searchProvider.notifier).unfollowUser(actor.did, actor.viewer?.following ?? AtUri.parse('')), 191 + ), 192 + ); 193 + }, 194 + ); 195 + } 196 + }
+190
lib/src/features/search/ui/widgets/post_card.dart
··· 1 + import 'dart:ui'; 2 + 3 + import 'package:cached_network_image/cached_network_image.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 6 + import 'package:sparksocial/src/core/utils/label_utils.dart'; 7 + import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 8 + 9 + class PostCard extends StatefulWidget { 10 + const PostCard({required this.post, super.key, this.onTap}); 11 + final PostView post; 12 + final VoidCallback? onTap; 13 + 14 + @override 15 + State<PostCard> createState() => _PostCardState(); 16 + } 17 + 18 + class _PostCardState extends State<PostCard> { 19 + bool _shouldBlur = false; 20 + 21 + @override 22 + void initState() { 23 + super.initState(); 24 + _checkContentWarning(); 25 + } 26 + 27 + @override 28 + void didUpdateWidget(covariant PostCard oldWidget) { 29 + super.didUpdateWidget(oldWidget); 30 + if (widget.post.uri != oldWidget.post.uri) { 31 + _checkContentWarning(); 32 + } 33 + } 34 + 35 + Future<void> _checkContentWarning() async { 36 + final labels = widget.post.labels ?? []; 37 + final shouldBlur = labels.isNotEmpty ? await LabelUtils.shouldBlurContent(labels) : false; 38 + if (mounted) { 39 + setState(() => _shouldBlur = shouldBlur); 40 + } 41 + } 42 + 43 + @override 44 + Widget build(BuildContext context) { 45 + final theme = Theme.of(context); 46 + final colorScheme = theme.colorScheme; 47 + 48 + return GestureDetector( 49 + onTap: widget.onTap, 50 + child: Container( 51 + decoration: BoxDecoration( 52 + color: colorScheme.surfaceContainerLow, 53 + borderRadius: BorderRadius.circular(16), 54 + ), 55 + child: Column( 56 + crossAxisAlignment: CrossAxisAlignment.start, 57 + children: [ 58 + Expanded( 59 + child: AspectRatio( 60 + aspectRatio: 9 / 16, 61 + child: Container( 62 + height: 300, 63 + width: double.infinity, 64 + decoration: BoxDecoration( 65 + color: colorScheme.surfaceContainer, 66 + borderRadius: const BorderRadius.all( 67 + Radius.circular(16), 68 + ), 69 + ), 70 + child: widget.post.embed != null 71 + ? ClipRRect( 72 + borderRadius: const BorderRadius.all( 73 + Radius.circular(16), 74 + ), 75 + child: _buildMediaContent(colorScheme), 76 + ) 77 + : Container( 78 + decoration: BoxDecoration( 79 + gradient: LinearGradient( 80 + begin: Alignment.topLeft, 81 + end: Alignment.bottomRight, 82 + colors: [colorScheme.surfaceContainer, colorScheme.surfaceContainerHigh], 83 + ), 84 + ), 85 + child: Center( 86 + child: Icon( 87 + Icons.image, 88 + size: 48, 89 + color: colorScheme.onSurfaceVariant, 90 + ), 91 + ), 92 + ), 93 + ), 94 + ), 95 + ), 96 + 97 + Padding( 98 + padding: const EdgeInsets.symmetric(vertical: 6), 99 + child: Column( 100 + crossAxisAlignment: CrossAxisAlignment.start, 101 + children: [ 102 + if (widget.post.record.text?.isNotEmpty ?? false) 103 + Text( 104 + widget.post.record.text!, 105 + style: theme.textTheme.bodySmall?.copyWith( 106 + color: colorScheme.onSurfaceVariant, 107 + ), 108 + maxLines: 2, 109 + overflow: TextOverflow.ellipsis, 110 + 111 + ), 112 + if (widget.post.record.text?.isNotEmpty ?? false) const SizedBox(height: 8), 113 + Row( 114 + children: [ 115 + UserAvatar( 116 + imageUrl: widget.post.author.avatar?.toString() ?? '', 117 + username: widget.post.author.displayName ?? widget.post.author.handle, 118 + size: 20, 119 + ), 120 + const SizedBox(width: 8), 121 + Expanded( 122 + child: Text( 123 + widget.post.author.displayName ?? widget.post.author.handle, 124 + style: theme.textTheme.titleSmall?.copyWith( 125 + fontWeight: FontWeight.w600, 126 + color: colorScheme.onSurface, 127 + ), 128 + overflow: TextOverflow.ellipsis, 129 + ), 130 + ), 131 + Icon( 132 + Icons.favorite, 133 + size: 16, 134 + color: colorScheme.error, 135 + ), 136 + const SizedBox(width: 4), 137 + Text( 138 + _formatCount(widget.post.likeCount ?? 0), 139 + style: theme.textTheme.bodySmall?.copyWith( 140 + fontWeight: FontWeight.w500, 141 + color: colorScheme.onSurfaceVariant, 142 + ), 143 + ), 144 + ], 145 + ), 146 + ], 147 + ), 148 + ), 149 + ], 150 + ), 151 + ), 152 + ); 153 + } 154 + 155 + Widget _buildMediaContent(ColorScheme colorScheme) { 156 + final thumbnailUrl = widget.post.thumbnailUrl; 157 + 158 + final imageWidget = CachedNetworkImage( 159 + imageUrl: thumbnailUrl, 160 + fit: BoxFit.cover, 161 + placeholder: (context, url) => Center( 162 + child: CircularProgressIndicator( 163 + color: colorScheme.primary, 164 + ), 165 + ), 166 + errorWidget: (context, url, error) => Icon( 167 + Icons.error, 168 + color: colorScheme.error, 169 + ), 170 + ); 171 + 172 + if (_shouldBlur) { 173 + return ImageFiltered( 174 + imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), 175 + child: imageWidget, 176 + ); 177 + } 178 + 179 + return imageWidget; 180 + } 181 + 182 + String _formatCount(int count) { 183 + if (count >= 1000000) { 184 + return '${(count / 1000000).toStringAsFixed(1)}M'; 185 + } else if (count >= 1000) { 186 + return '${(count / 1000).toStringAsFixed(1)}K'; 187 + } 188 + return count.toString(); 189 + } 190 + }