[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.

começou a dentadura (de novo) (#69)

Co-authored-by: daviirodrig <30713947+daviirodrig@users.noreply.github.com>

authored by

Jean Carlo Polo
daviirodrig
and committed by
GitHub
68c393cc 432ba0ae

+1634 -165
+25 -12
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 95 95 _logger.d('Getting posts on bluesky API for: ${uris.length} URIs'); 96 96 final blueskyClient = bsky.Bluesky.fromSession(_client.authRepository.session!); 97 97 final posts = await blueskyClient.feed.getPosts(uris: uris); 98 - final filteredPosts = filter ? _parseAndFilterPosts<PostView>( 99 - rawPosts: posts.data.posts, 100 - fromJson: PostView.fromJson, 101 - getPostView: (post) => post, 102 - source: 'bsky', 103 - ) : posts.data.posts.map((post) => PostView.fromJson(post.toJson())).toList(); 98 + final filteredPosts = filter 99 + ? _parseAndFilterPosts<PostView>( 100 + rawPosts: posts.data.posts, 101 + fromJson: PostView.fromJson, 102 + getPostView: (post) => post, 103 + source: 'bsky', 104 + ) 105 + : posts.data.posts.map((post) => PostView.fromJson(post.toJson())).toList(); 104 106 return filteredPosts; 105 107 } 106 108 return _client.executeWithRetry(() async { ··· 705 707 706 708 List<Label> labels = []; 707 709 710 + final List<String> labelers = sources?.isNotEmpty == true ? sources! : ['did:plc:pbgyr67hftvpoqtvaurpsctc']; 711 + 712 + final parameters = {'uriPatterns': uris, 'sources': labelers, 'limit': limit, 'cursor': cursor}; 713 + 708 714 final response = await atproto.get( 709 715 NSID.parse('com.atproto.label.queryLabels'), 710 - parameters: {'uriPatterns': uris, 'sources': sources, 'limit': limit, 'cursor': cursor}, 716 + headers: {'atproto-proxy': 'did:plc:pbgyr67hftvpoqtvaurpsctc#atproto_labeler'}, 717 + parameters: parameters, 718 + to: (jsonMap) => jsonMap, 719 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 711 720 ); 712 - 713 - if (response.data case EmptyData()) { 714 - return (labels: labels, cursor: null); 715 - } 721 + _logger.d('parameters: $parameters'); 722 + _logger.d('Labels retrieved: ${response.data}'); 716 723 717 724 for (final label in response.data['labels'] as List<dynamic>) { 718 - labels.add(Label.fromJson(label as Map<String, Object?>)); 725 + final cleanLabel = label as Map<String, Object?>; 726 + cleanLabel.remove('sig'); // i am NOT going to convert that sig string into a UInt8List i am going to PASS OUT and DIE 727 + cleanLabel.putIfAbsent( 728 + 'src', 729 + () => 'did:plc:pbgyr67hftvpoqtvaurpsctc', 730 + ); // fix this when there's multiple labelers support. for now idgaf. src is null for some reason in the response 731 + labels.add(Label.fromJson(cleanLabel)); 719 732 } 720 733 721 734 return (labels: labels, cursor: response.data['cursor'] as String?);
-1
lib/src/core/routing/app_router.dart
··· 63 63 page: FeedSettingsRoute.page, 64 64 path: '/settings', 65 65 customRouteBuilder: feedSettingsBuilder, 66 - children: [AutoRoute(page: FeedListRoute.page, path: 'list')], // settings tabs 67 66 ), 68 67 69 68 // Deep linking routes or routes that will be pushed on top of everything
+1
lib/src/core/routing/pages.dart
··· 21 21 export 'package:sparksocial/src/features/settings/ui/pages/feed_settings_page.dart'; 22 22 export 'package:sparksocial/src/features/home/ui/pages/placeholder_page.dart'; 23 23 export 'package:sparksocial/src/features/settings/ui/pages/feed_list_page.dart'; 24 + export 'package:sparksocial/src/features/settings/ui/pages/label_settings_page.dart'; 24 25 export 'package:sparksocial/src/features/comments/ui/pages/comments_page.dart'; 25 26 export 'package:sparksocial/src/features/feed/ui/pages/standalone_post_page.dart'; 26 27 export 'package:sparksocial/src/features/comments/ui/pages/replies_page.dart';
+3
lib/src/core/storage/cache/download_manager_impl.dart
··· 21 21 _activeFeed = await GetIt.instance<SettingsRepository>().getActiveFeed(); 22 22 } 23 23 24 + @override 25 + bool get poolFull => _tasks.length >= FeedState.poolSize; 26 + 24 27 late final SQLCacheInterface _sqlCache; 25 28 late final SparkLogger _logger; 26 29 late final CacheManagerInterface _cacheManager;
+2
lib/src/core/storage/cache/download_manager_interface.dart
··· 69 69 /// This should be called when the manager is no longer needed to prevent 70 70 /// resource leaks and ensure graceful shutdown of ongoing operations. 71 71 Future<void> dispose(); 72 + 73 + bool get poolFull; 72 74 }
+82 -51
lib/src/core/storage/preferences/settings_repository_impl.dart
··· 40 40 // "nudity", 41 41 // "nsfl", 42 42 // "gore", 43 - await _storageManager.preferences.setObject<LabelPreference>( 43 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 44 44 '${StorageKeys.labelPreferenceKey}_!hide', 45 45 LabelPreference( 46 46 value: '!hide', ··· 49 49 defaultSetting: Setting.hide, 50 50 setting: Setting.hide, 51 51 adultOnly: false, 52 - ), 52 + ).toJson(), 53 53 ); 54 - await _storageManager.preferences.setObject<LabelPreference>( 54 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 55 55 '${StorageKeys.labelPreferenceKey}_!no-promote', 56 56 LabelPreference( 57 57 value: '!no-promote', ··· 60 60 defaultSetting: Setting.hide, 61 61 setting: Setting.hide, 62 62 adultOnly: false, 63 - ), 63 + ).toJson(), 64 64 ); 65 - await _storageManager.preferences.setObject<LabelPreference>( 65 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 66 66 '${StorageKeys.labelPreferenceKey}_!warn', 67 67 LabelPreference( 68 68 value: '!warn', ··· 71 71 defaultSetting: Setting.warn, 72 72 setting: Setting.warn, 73 73 adultOnly: false, 74 - ), 74 + ).toJson(), 75 75 ); 76 - await _storageManager.preferences.setObject<LabelPreference>( 76 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 77 77 '${StorageKeys.labelPreferenceKey}_!no-unauthenticated', 78 78 LabelPreference( 79 79 value: '!no-unauthenticated', ··· 82 82 defaultSetting: Setting.ignore, 83 83 setting: Setting.ignore, 84 84 adultOnly: false, 85 - ), 85 + ).toJson(), 86 86 ); 87 - await _storageManager.preferences.setObject<LabelPreference>( 87 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 88 88 '${StorageKeys.labelPreferenceKey}_dmca-violation', 89 89 LabelPreference( 90 90 value: 'dmca-violation', ··· 93 93 defaultSetting: Setting.hide, 94 94 setting: Setting.hide, 95 95 adultOnly: false, 96 - ), 96 + ).toJson(), 97 97 ); 98 - await _storageManager.preferences.setObject<LabelPreference>( 98 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 99 99 '${StorageKeys.labelPreferenceKey}_doxxing', 100 100 LabelPreference( 101 101 value: 'doxxing', ··· 104 104 defaultSetting: Setting.warn, 105 105 setting: Setting.warn, 106 106 adultOnly: false, 107 - ), 107 + ).toJson(), 108 108 ); 109 - await _storageManager.preferences.setObject<LabelPreference>( 109 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 110 110 '${StorageKeys.labelPreferenceKey}_porn', 111 111 LabelPreference( 112 112 value: 'porn', ··· 115 115 defaultSetting: Setting.warn, 116 116 setting: Setting.warn, 117 117 adultOnly: true, 118 - ), 118 + ).toJson(), 119 119 ); 120 - await _storageManager.preferences.setObject<LabelPreference>( 120 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 121 121 '${StorageKeys.labelPreferenceKey}_sexual', 122 122 LabelPreference( 123 123 value: 'sexual', ··· 126 126 defaultSetting: Setting.warn, 127 127 setting: Setting.warn, 128 128 adultOnly: true, 129 - ), 129 + ).toJson(), 130 130 ); 131 - await _storageManager.preferences.setObject<LabelPreference>( 131 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 132 132 '${StorageKeys.labelPreferenceKey}_nudity', 133 133 LabelPreference( 134 134 value: 'nudity', 135 135 blurs: Blurs.content, 136 136 severity: Severity.alert, 137 - defaultSetting: Setting.ignore, 138 - setting: Setting.ignore, 137 + defaultSetting: Setting.warn, 138 + setting: Setting.warn, 139 139 adultOnly: false, 140 - ), 140 + ).toJson(), 141 141 ); 142 - await _storageManager.preferences.setObject<LabelPreference>( 142 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 143 143 '${StorageKeys.labelPreferenceKey}_nsfl', 144 144 LabelPreference( 145 145 value: 'nsfl', ··· 148 148 defaultSetting: Setting.warn, 149 149 setting: Setting.warn, 150 150 adultOnly: true, 151 - ), 151 + ).toJson(), 152 152 ); 153 - await _storageManager.preferences.setObject<LabelPreference>( 154 - '${StorageKeys.labelPreferenceKey}_!hide', 153 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 154 + '${StorageKeys.labelPreferenceKey}_gore', 155 155 LabelPreference( 156 156 value: 'gore', 157 157 blurs: Blurs.content, ··· 159 159 defaultSetting: Setting.warn, 160 160 setting: Setting.warn, 161 161 adultOnly: true, 162 - ), 162 + ).toJson(), 163 163 ); 164 164 } 165 165 } ··· 297 297 } 298 298 await _storageManager.preferences.setObject<List<String>>(StorageKeys.followedLabelers, labelers); 299 299 for (var labelPreference in labelPreferences) { 300 - await _storageManager.preferences.setObject<LabelPreference>( 300 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 301 301 '${StorageKeys.labelPreferenceKey}_${labelPreference.value}', 302 - labelPreference, 302 + labelPreference.toJson(), 303 303 ); 304 304 } 305 305 } 306 306 307 307 @override 308 308 Future<LabelPreference> getLabelPreference(String value) async { 309 - final labelPreference = await _storageManager.preferences.getObject<LabelPreference>( 309 + final rawJson = await _storageManager.preferences.getObject<Map<String, dynamic>>( 310 310 '${StorageKeys.labelPreferenceKey}_$value', 311 311 ); 312 - if (labelPreference == null) { 312 + if (rawJson == null) { 313 313 throw Exception('Label preference not found'); 314 314 } 315 - return labelPreference; 315 + 316 + try { 317 + return LabelPreference.fromJson(rawJson); 318 + } catch (e) { 319 + _logger.e('Error deserializing label preference for $value: $e'); 320 + throw Exception('Failed to deserialize label preference'); 321 + } 316 322 } 317 323 318 - Future<void> _setDefaultLabelPreferences(String value, Setting setting) async { 319 - final labelPreference = await _storageManager.preferences.getObject<LabelPreference>( 320 - '${StorageKeys.labelPreferenceKey}_$value', 321 - ); 322 - await _storageManager.preferences.setObject<LabelPreference>( 323 - '${StorageKeys.labelPreferenceKey}_$value', 324 - labelPreference!.copyWith(setting: setting), 325 - ); 326 - } 324 + 327 325 328 326 @override 329 327 Future<void> setLabelPreference(String value, Blurs blurs, Severity severity, bool adultOnly, Setting setting) async { 330 - if (defaultLabels.contains(value)) { 331 - await _setDefaultLabelPreferences(value, setting); 332 - } else { 333 - final labelPreference = await _storageManager.preferences.getObject<LabelPreference>( 334 - '${StorageKeys.labelPreferenceKey}_$value', 335 - ); 336 - if (labelPreference == null) { 337 - throw Exception('Label preference not found'); 328 + // Check if a preference already exists 329 + final existingRawJson = await _storageManager.preferences.getObject<Map<String, dynamic>>( 330 + '${StorageKeys.labelPreferenceKey}_$value', 331 + ); 332 + 333 + if (existingRawJson != null) { 334 + try { 335 + // Update existing preference 336 + final existingPreference = LabelPreference.fromJson(existingRawJson); 337 + final newLabelPreference = existingPreference.copyWith( 338 + blurs: blurs, 339 + severity: severity, 340 + adultOnly: adultOnly, 341 + setting: setting, 342 + ); 343 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 344 + '${StorageKeys.labelPreferenceKey}_$value', 345 + newLabelPreference.toJson(), 346 + ); 347 + _logger.d('Label preference updated: $value'); 348 + } catch (e) { 349 + _logger.e('Error updating existing label preference for $value: $e'); 350 + // If we can't deserialize existing, create new 351 + final newLabelPreference = LabelPreference( 352 + value: value, 353 + blurs: blurs, 354 + severity: severity, 355 + defaultSetting: setting, 356 + setting: setting, 357 + adultOnly: adultOnly, 358 + ); 359 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 360 + '${StorageKeys.labelPreferenceKey}_$value', 361 + newLabelPreference.toJson(), 362 + ); 363 + _logger.d('Label preference created (after error): $value'); 338 364 } 339 - final newLabelPreference = labelPreference.copyWith( 365 + } else { 366 + // Create new preference 367 + final newLabelPreference = LabelPreference( 368 + value: value, 340 369 blurs: blurs, 341 370 severity: severity, 342 - adultOnly: adultOnly, 371 + defaultSetting: setting, 343 372 setting: setting, 373 + adultOnly: adultOnly, 344 374 ); 345 - await _storageManager.preferences.setObject<LabelPreference>( 375 + await _storageManager.preferences.setObject<Map<String, dynamic>>( 346 376 '${StorageKeys.labelPreferenceKey}_$value', 347 - newLabelPreference, 377 + newLabelPreference.toJson(), 348 378 ); 379 + _logger.d('Label preference created: $value'); 349 380 } 350 381 } 351 382
+88
lib/src/core/utils/label_utils.dart
··· 1 + import 'package:atproto/atproto.dart'; 2 + import 'package:get_it/get_it.dart'; 3 + import 'package:sparksocial/src/core/network/atproto/data/models/labeler_models.dart'; 4 + import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; 5 + 6 + class LabelUtils { 7 + static Future<bool> shouldShowWarning(List<Label> labels) async { 8 + if (labels.isEmpty) return false; 9 + 10 + final settingsRepository = GetIt.instance<SettingsRepository>(); 11 + 12 + for (final label in labels) { 13 + try { 14 + final preference = await settingsRepository.getLabelPreference(label.value); 15 + if (preference.severity == Severity.alert && preference.setting == Setting.warn) { 16 + return true; 17 + } 18 + } catch (e) { 19 + // If no preference found, continue checking other labels 20 + continue; 21 + } 22 + } 23 + 24 + return false; 25 + } 26 + 27 + static Future<bool> shouldBlurContent(List<Label> labels) async { 28 + if (labels.isEmpty) return false; 29 + 30 + final settingsRepository = GetIt.instance<SettingsRepository>(); 31 + 32 + for (final label in labels) { 33 + try { 34 + final preference = await settingsRepository.getLabelPreference(label.value); 35 + if (preference.blurs == Blurs.content || preference.blurs == Blurs.media && preference.setting == Setting.warn) { 36 + return true; 37 + } 38 + } catch (e) { 39 + // If no preference found, continue checking other labels 40 + continue; 41 + } 42 + } 43 + 44 + return false; 45 + } 46 + 47 + static Future<List<String>> getWarningLabels(List<Label> labels) async { 48 + if (labels.isEmpty) return []; 49 + 50 + final settingsRepository = GetIt.instance<SettingsRepository>(); 51 + final warningLabels = <String>[]; 52 + 53 + for (final label in labels) { 54 + try { 55 + final preference = await settingsRepository.getLabelPreference(label.value); 56 + if (preference.severity == Severity.alert && preference.setting == Setting.warn) { 57 + warningLabels.add(label.value); 58 + } 59 + } catch (e) { 60 + // If no preference found, continue checking other labels 61 + continue; 62 + } 63 + } 64 + 65 + return warningLabels; 66 + } 67 + 68 + static Future<List<String>> getInformLabels(List<Label> labels) async { 69 + if (labels.isEmpty) return []; 70 + 71 + final settingsRepository = GetIt.instance<SettingsRepository>(); 72 + final informLabels = <String>[]; 73 + 74 + for (final label in labels) { 75 + try { 76 + final preference = await settingsRepository.getLabelPreference(label.value); 77 + if (preference.severity == Severity.inform && preference.setting == Setting.warn) { 78 + informLabels.add(label.value); 79 + } 80 + } catch (e) { 81 + // If no preference found, continue checking other labels 82 + continue; 83 + } 84 + } 85 + 86 + return informLabels; 87 + } 88 + }
+66
lib/src/core/widgets/content_warning_overlay.dart
··· 1 + import 'dart:ui'; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 5 + 6 + class ContentWarningOverlay extends StatelessWidget { 7 + const ContentWarningOverlay({ 8 + super.key, 9 + required this.onViewContent, 10 + required this.warningLabels, 11 + this.shouldBlur = false, 12 + this.child, 13 + }); 14 + 15 + final VoidCallback onViewContent; 16 + final List<String> warningLabels; 17 + final Widget? child; 18 + final bool shouldBlur; // content blur 19 + @override 20 + Widget build(BuildContext context) { 21 + return Stack( 22 + children: [ 23 + // Original content (blurred/hidden) 24 + if (child != null) 25 + if (shouldBlur) ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 40.0, sigmaY: 40.0), child: child!) else child!, 26 + // Warning overlay 27 + Positioned.fill( 28 + child: Center( 29 + child: Padding( 30 + padding: const EdgeInsets.all(24.0), 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)), 55 + ), 56 + child: const Text('View Content', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), 57 + ), 58 + ], 59 + ), 60 + ), 61 + ), 62 + ), 63 + ], 64 + ); 65 + } 66 + }
+86 -9
lib/src/features/feed/providers/feed_provider.dart
··· 17 17 import 'package:sparksocial/src/features/feed/providers/feed_state.dart'; 18 18 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 19 19 import 'package:sparksocial/src/core/storage/cache/cache_manager_interface.dart'; 20 + import 'package:sparksocial/src/core/network/atproto/data/models/labeler_models.dart'; 20 21 21 22 part 'feed_provider.g.dart'; 22 23 ··· 253 254 loadingFirstLoad = fetchedCount != 0; 254 255 } 255 256 257 + // Filter out posts that should be hidden based on label preferences 258 + final filteredUris = await _filterHiddenPosts(uris, extraInfo); 259 + 256 260 state = state.copyWith( 257 - loadedPosts: uris, 261 + loadedPosts: filteredUris, 258 262 freshPostCount: 0, // Set to 0 as per strategy 259 263 extraInfo: extraInfo, 260 264 cursor: newCursor, // Store the cursor from fetch 261 265 loadingFirstLoad: loadingFirstLoad, 262 266 ); 263 267 _isWaitingForFreshPostsAtEnd = state.length <= 1; 264 - _logger.d('First load finished with ${uris.length} posts'); 268 + _logger.d('First load finished with ${filteredUris.length} posts (${uris.length - filteredUris.length} hidden)'); 265 269 } catch (e, stackTrace) { 266 270 _logger.e('Error in loadAndUpdateFirstLoad: $e', stackTrace: stackTrace); 267 - state = state.copyWith(loadingFirstLoad: false, error: true, isEndOfNetworkFeed: true); 268 - _isWaitingForFreshPostsAtEnd = true; 271 + state = state.copyWith(loadingFirstLoad: false, error: true); 269 272 } finally { 270 273 _isLoadingInProgress = false; 271 274 } ··· 347 350 } 348 351 _logger.d('Downloading ${nonExistingUris.length} new posts'); 349 352 final nonExistingPosts = await _feedRepository.getPosts(nonExistingUris, bluesky: _shouldUseBlueskyAPI()); 353 + 354 + // gets the subscribed labels for the new posts 355 + final followedLabelers = await _settingsRepository.getFollowedLabelers(); 356 + List<Label> newPostLabels = []; 357 + try { 358 + final (cursor: _, labels: fetchedLabels) = await _feedRepository.getLabels(nonExistingUris, sources: followedLabelers); 359 + newPostLabels = fetchedLabels; 360 + } catch (e) { 361 + _logger.e('Error getting labels for new posts: $e'); 362 + newPostLabels = []; 363 + } 364 + 365 + List<PostView> postsWithLabels = []; 366 + for (var post in nonExistingPosts) { 367 + newPostLabels.addAll(post.labels ?? []); // labels from the post 368 + if (post.record.selfLabels != null) { 369 + final recordLabels = <Label>[]; 370 + for (SelfLabel selfLabel in post.record.selfLabels!) { 371 + recordLabels.add( 372 + Label(uri: post.uri.toString(), value: selfLabel.value, src: post.uri.toString(), createdAt: post.indexedAt), 373 + ); 374 + } 375 + newPostLabels.addAll(recordLabels); // self labels 376 + } 377 + postsWithLabels.add(post.copyWith(labels: newPostLabels)); 378 + } 379 + 350 380 int newPostsCached = 0; 351 381 int errorCount = 0; 352 - for (PostView post in nonExistingPosts) { 382 + for (PostView post in postsWithLabels) { 353 383 // concurrent execution 354 384 _downloadManager.submitTask( 355 385 DownloadTask( ··· 370 400 // it is divided in half to prevent the feed from getting stuck loading big files 371 401 // (the other half will keep being downloaded, but you can start downloading another batch to be more efficient) 372 402 // should use pool to have a limit on the number of concurrent downloads 373 - if (newPostsCached == (nonExistingPosts.length - errorCount) >> 1) { 403 + if (newPostsCached == (nonExistingPosts.length - errorCount) >> 1 && !_downloadManager.poolFull) { 374 404 _isCaching = false; 375 405 _logger.d('Set isCaching to false after downloading $newPostsCached posts'); 376 406 } ··· 493 523 extraInfo.updateAll((key, value) => (postLabels: value.postLabels, hardcodedFeedExtraInfo: newExtraInfos[key])); 494 524 } 495 525 } 526 + 527 + // Filter out posts that should be hidden based on label preferences 528 + final filteredUris = await _filterHiddenPosts(uris, extraInfo); 529 + 496 530 state = state.copyWith( 497 - loadedPosts: [...state.loadedPosts, ...uris], 498 - freshPostCount: state.freshPostCount - uris.length, // Only subtract the actual new posts loaded 531 + loadedPosts: [...state.loadedPosts, ...filteredUris], 532 + freshPostCount: state.freshPostCount - filteredUris.length, // Only subtract the actual new posts loaded 499 533 extraInfo: extraInfo, 500 534 loadingFirstLoad: false, 501 535 ); 502 536 503 - _logger.d('Load complete. Total loaded posts: ${state.loadedPosts.length}, remaining fresh: ${state.freshPostCount}'); 537 + _logger.d( 538 + 'Load complete. Total loaded posts: ${state.loadedPosts.length}, remaining fresh: ${state.freshPostCount} (${uris.length - filteredUris.length} hidden)', 539 + ); 504 540 } else { 505 541 _logger.d('No fresh posts available to load (freshPostCount: ${state.freshPostCount})'); 506 542 } ··· 577 613 578 614 _logger.d('Removing post ${uri.toString()}, adjusting index from $currentIndex to $newIndex'); 579 615 state = state.copyWith(loadedPosts: updatedPosts, index: newIndex); 616 + } 617 + 618 + /// Checks if a post should be hidden based on its labels and user preferences 619 + Future<bool> _shouldHidePost(AtUri uri, List<Label> postLabels) async { 620 + final hideAdultContent = await _settingsRepository.getHideAdultContent(); 621 + for (final label in postLabels) { 622 + try { 623 + final labelPreference = await _settingsRepository.getLabelPreference(label.value); 624 + if (labelPreference.setting == Setting.hide || (labelPreference.adultOnly && hideAdultContent)) { 625 + _logger.d('Hiding post ${uri.toString()} due to label: ${label.value}'); 626 + return true; 627 + } 628 + } catch (e) { 629 + // Label preference not found, continue checking other labels 630 + continue; 631 + } 632 + } 633 + return false; 634 + } 635 + 636 + /// Filters URIs based on label preferences, removing posts that should be hidden 637 + Future<List<AtUri>> _filterHiddenPosts( 638 + List<AtUri> uris, 639 + LinkedHashMap<AtUri, ({List<Label> postLabels, HardcodedFeedExtraInfo? hardcodedFeedExtraInfo})> extraInfo, 640 + ) async { 641 + final filteredUris = <AtUri>[]; 642 + 643 + for (final uri in uris) { 644 + final postExtraInfo = extraInfo[uri]; 645 + if (postExtraInfo != null) { 646 + final shouldHide = await _shouldHidePost(uri, postExtraInfo.postLabels); 647 + if (!shouldHide) { 648 + filteredUris.add(uri); 649 + } 650 + } else { 651 + // No extra info means no labels, so include the post 652 + filteredUris.add(uri); 653 + } 654 + } 655 + 656 + return filteredUris; 580 657 } 581 658 }
+72 -10
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 12 12 import 'package:sparksocial/src/features/feed/ui/widgets/videos/video_player.dart'; 13 13 import 'package:sparksocial/src/core/routing/app_router.dart'; 14 14 import 'package:atproto_core/atproto_core.dart'; 15 + import 'package:sparksocial/src/core/widgets/content_warning_overlay.dart'; 16 + import 'package:sparksocial/src/core/utils/label_utils.dart'; 15 17 16 18 @RoutePage() 17 19 class StandalonePostPage extends ConsumerStatefulWidget { ··· 27 29 Future<dynamic>? _postFuture; 28 30 int? _lastUpdateCount; 29 31 final GlobalKey<PostVideoPlayerState> _videoPlayerKey = GlobalKey<PostVideoPlayerState>(); 32 + bool _showWarningOverlay = false; 33 + List<String> _warningLabels = []; 34 + bool _shouldBlurContent = false; 30 35 31 36 @override 32 37 void initState() { ··· 62 67 } 63 68 } 64 69 70 + Future<void> _checkContentWarning(PostView postData) async { 71 + final labels = postData.labels ?? []; 72 + 73 + if (labels.isNotEmpty) { 74 + final shouldShowWarning = await LabelUtils.shouldShowWarning(labels); 75 + final shouldBlurContent = await LabelUtils.shouldBlurContent(labels); 76 + if (shouldShowWarning) { 77 + final warningLabels = await LabelUtils.getWarningLabels(labels); 78 + setState(() { 79 + _showWarningOverlay = true; 80 + _warningLabels = warningLabels; 81 + _shouldBlurContent = shouldBlurContent; 82 + }); 83 + } else { 84 + setState(() { 85 + _showWarningOverlay = false; 86 + _warningLabels = []; 87 + }); 88 + } 89 + } else { 90 + setState(() { 91 + _showWarningOverlay = false; 92 + _warningLabels = []; 93 + }); 94 + } 95 + } 96 + 65 97 @override 66 98 Widget build(BuildContext context) { 67 99 // Watch for post updates to trigger reload ··· 97 129 if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { 98 130 final postData = snapshot.data! as PostView; 99 131 100 - return Stack( 132 + // Check for content warning on post load 133 + WidgetsBinding.instance.addPostFrameCallback((_) { 134 + if (mounted) { 135 + _checkContentWarning(postData); 136 + } 137 + }); 138 + 139 + final mainContent = Stack( 101 140 children: [ 102 141 // Main content 103 142 switch (postData.embed) { ··· 172 211 bottom: 32, 173 212 left: 4, 174 213 right: 80, 175 - child: InfoBar( 176 - username: postData.author.handle, 177 - description: postData.record.text ?? '', 178 - hashtags: postData.record.hashtags, 179 - isSprk: postData.uri.toString().contains('so.sprk'), 180 - onUsernameTap: () { 181 - // Pause video before navigating to profile 182 - _videoPlayerKey.currentState?.pauseVideo(); 183 - context.router.push(ProfileRoute(did: postData.author.did)); 214 + child: FutureBuilder<List<String>>( 215 + future: LabelUtils.getInformLabels(postData.labels ?? []), 216 + builder: (context, snapshot) { 217 + final informLabels = snapshot.data ?? []; 218 + return InfoBar( 219 + username: postData.author.handle, 220 + description: postData.record.text ?? '', 221 + hashtags: postData.record.hashtags, 222 + informLabels: informLabels, 223 + isSprk: postData.uri.toString().contains('so.sprk'), 224 + onUsernameTap: () { 225 + // Pause video before navigating to profile 226 + _videoPlayerKey.currentState?.pauseVideo(); 227 + context.router.push(ProfileRoute(did: postData.author.did)); 228 + }, 229 + ); 184 230 }, 185 231 ), 186 232 ), 187 233 ], 188 234 ); 235 + 236 + // Return main content with warning overlay if needed 237 + if (_showWarningOverlay && _warningLabels.isNotEmpty) { 238 + return ContentWarningOverlay( 239 + onViewContent: () { 240 + setState(() { 241 + _showWarningOverlay = false; 242 + }); 243 + }, 244 + warningLabels: _warningLabels, 245 + shouldBlur: _shouldBlurContent, 246 + child: mainContent, 247 + ); 248 + } 249 + 250 + return mainContent; 189 251 } 190 252 if (snapshot.hasError) { 191 253 return Center(
+13 -7
lib/src/features/feed/ui/widgets/feed/feeds_bar.dart
··· 4 4 import 'dart:ui' show lerpDouble; 5 5 import 'package:sparksocial/src/core/utils/logging/logging.dart'; 6 6 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 7 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 8 + import 'package:auto_route/auto_route.dart'; 9 + import 'package:sparksocial/src/core/routing/app_router.dart'; 7 10 8 11 class FeedsBar extends ConsumerStatefulWidget implements PreferredSizeWidget { 9 12 const FeedsBar({super.key, required this.pageController}); ··· 66 69 title: LayoutBuilder( 67 70 builder: (context, constraints) { 68 71 final availableWidth = constraints.maxWidth; 69 - final horizontalPadding = (availableWidth - totalWidth) / 2.0; 72 + // Account for the leading widget (back button) - approximately 56px 73 + final leadingWidth = 56.0; 74 + final centeringWidth = availableWidth - leadingWidth; 75 + final horizontalPadding = leadingWidth + (centeringWidth - totalWidth) / 2.0; 70 76 71 77 return SizedBox( 72 78 height: 44, ··· 172 178 }, 173 179 ), 174 180 actions: [ 175 - // IconButton( 176 - // icon: const Icon(FluentIcons.options_24_regular), 177 - // color: AppColors.white, 178 - // iconSize: 30, 179 - // onPressed: () => context.router.navigate(FeedSettingsRoute()), 180 - // ), 181 + IconButton( 182 + icon: const Icon(FluentIcons.options_24_regular), 183 + color: Colors.white, 184 + iconSize: 30, 185 + onPressed: () => context.router.navigate(const FeedSettingsRoute()), 186 + ), 181 187 ], 182 188 ); 183 189 }
+91 -9
lib/src/features/feed/ui/widgets/post/feed_post_widget.dart
··· 1 1 import 'package:auto_route/auto_route.dart'; 2 + import 'package:atproto/atproto.dart'; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 5 import 'package:get_it/get_it.dart'; ··· 15 16 import 'package:sparksocial/src/features/home/providers/navigation_provider.dart'; 16 17 import 'package:sparksocial/src/core/routing/app_router.dart'; 17 18 import 'package:sparksocial/src/core/widgets/heart_animation.dart'; 19 + import 'package:sparksocial/src/core/widgets/content_warning_overlay.dart'; 20 + import 'package:sparksocial/src/core/utils/label_utils.dart'; 18 21 19 22 class FeedPostWidget extends ConsumerStatefulWidget { 20 23 const FeedPostWidget({super.key, required this.index, required this.feed}); ··· 33 36 final GlobalKey<PostVideoPlayerState> _videoPlayerKey = GlobalKey<PostVideoPlayerState>(); 34 37 final GlobalKey<SideActionBarState> _sideActionBarKey = GlobalKey<SideActionBarState>(); 35 38 bool _isAnimatingHeart = false; 39 + bool _showWarningOverlay = false; 40 + bool _userDismissedWarning = false; 41 + List<String> _warningLabels = []; 36 42 37 43 @override 38 44 void initState() { ··· 92 98 } 93 99 } 94 100 101 + Future<void> _checkContentWarning(String postUri) async { 102 + final feedState = ref.read(feedNotifierProvider(widget.feed)); 103 + if (widget.index < feedState.loadedPosts.length) { 104 + final uri = feedState.loadedPosts[widget.index]; 105 + final extraInfo = feedState.extraInfo[uri]; 106 + 107 + if (extraInfo != null && extraInfo.postLabels.isNotEmpty && !_userDismissedWarning) { 108 + final shouldShowWarning = await LabelUtils.shouldShowWarning(extraInfo.postLabels); 109 + if (shouldShowWarning) { 110 + final warningLabels = await LabelUtils.getWarningLabels(extraInfo.postLabels); 111 + setState(() { 112 + _showWarningOverlay = true; 113 + _warningLabels = warningLabels; 114 + }); 115 + } else { 116 + setState(() { 117 + _showWarningOverlay = false; 118 + _warningLabels = []; 119 + }); 120 + } 121 + } else { 122 + setState(() { 123 + _showWarningOverlay = false; 124 + _warningLabels = []; 125 + }); 126 + } 127 + } 128 + } 129 + 95 130 @override 96 131 Widget build(BuildContext context) { 97 132 // Check if we need to reload post due to state changes ··· 115 150 setState(() { 116 151 _loadPost(); 117 152 }); 153 + _checkContentWarning(currentUri); 118 154 } 119 155 }); 120 156 } ··· 149 185 }, 150 186 ); 151 187 152 - return HeartAnimation( 188 + // Check for content warning on post load 189 + WidgetsBinding.instance.addPostFrameCallback((_) { 190 + if (mounted) { 191 + _checkContentWarning(postData.uri.toString()); 192 + } 193 + }); 194 + 195 + final mainContent = HeartAnimation( 153 196 isAnimating: _isAnimatingHeart, 154 197 onEnd: () { 155 198 setState(() { ··· 205 248 bottom: 32, 206 249 left: 4, 207 250 right: 80, 208 - child: InfoBar( 209 - username: postData.author.handle, 210 - description: postData.record.text ?? '', 211 - hashtags: postData.record.hashtags, 212 - isSprk: postData.uri.toString().contains('so.sprk'), 213 - onUsernameTap: () { 214 - _videoPlayerKey.currentState?.pauseVideo(); 215 - context.router.push(ProfileRoute(did: postData.author.did)); 251 + child: Builder( 252 + builder: (context) { 253 + final feedState = ref.read(feedNotifierProvider(widget.feed)); 254 + List<Label> labels = []; 255 + 256 + if (widget.index < feedState.loadedPosts.length) { 257 + final uri = feedState.loadedPosts[widget.index]; 258 + final extraInfo = feedState.extraInfo[uri]; 259 + if (extraInfo != null) { 260 + labels = extraInfo.postLabels; 261 + } 262 + } 263 + 264 + return FutureBuilder<List<String>>( 265 + future: LabelUtils.getInformLabels(labels), 266 + builder: (context, snapshot) { 267 + final informLabels = snapshot.data ?? []; 268 + return InfoBar( 269 + username: postData.author.handle, 270 + description: postData.record.text ?? '', 271 + hashtags: postData.record.hashtags, 272 + informLabels: informLabels, 273 + isSprk: postData.uri.toString().contains('so.sprk'), 274 + onUsernameTap: () { 275 + _videoPlayerKey.currentState?.pauseVideo(); 276 + context.router.push(ProfileRoute(did: postData.author.did)); 277 + }, 278 + ); 279 + }, 280 + ); 216 281 }, 217 282 ), 218 283 ), ··· 220 285 ), 221 286 ), 222 287 ); 288 + 289 + // Return main content with warning overlay if needed 290 + if (_showWarningOverlay && _warningLabels.isNotEmpty) { 291 + return ContentWarningOverlay( 292 + onViewContent: () { 293 + setState(() { 294 + _showWarningOverlay = false; 295 + _userDismissedWarning = true; // User has dismissed the warning 296 + }); 297 + }, 298 + warningLabels: _warningLabels, 299 + shouldBlur: true, 300 + child: mainContent, 301 + ); 302 + } 303 + 304 + return mainContent; 223 305 } 224 306 if (snapshot.hasError) { 225 307 return DecoratedBox(
+59
lib/src/features/feed/ui/widgets/post/info_bar.dart
··· 11 11 final String username; 12 12 final String description; 13 13 final List<String> hashtags; 14 + final List<String> informLabels; 14 15 final bool isSprk; 15 16 final String? altText; 16 17 final VoidCallback? onUsernameTap; ··· 22 23 required this.username, 23 24 required this.description, 24 25 required this.hashtags, 26 + this.informLabels = const [], 25 27 this.isSprk = false, 26 28 this.altText, 27 29 this.onUsernameTap, ··· 33 35 Widget build(BuildContext context) { 34 36 final hasDescription = description.isNotEmpty; 35 37 final hasHashtags = hashtags.isNotEmpty; 38 + final hasInformLabels = informLabels.isNotEmpty; 36 39 37 40 return Column( 38 41 crossAxisAlignment: CrossAxisAlignment.start, ··· 52 55 if (hasDescription && hasHashtags) const SizedBox(height: 6), 53 56 54 57 if (hasHashtags) SizedBox(height: 25, child: HashtagList(hashtags: hashtags, onHashtagTap: onHashtagTap)), 58 + 59 + if (hasInformLabels && (hasHashtags || hasDescription)) const SizedBox(height: 6), 60 + 61 + if (hasInformLabels) _InformLabels(labels: informLabels), 55 62 ], 56 63 ); 57 64 } ··· 92 99 ); 93 100 } 94 101 } 102 + 103 + class _InformLabels extends StatelessWidget { 104 + final List<String> labels; 105 + 106 + const _InformLabels({required this.labels}); 107 + 108 + @override 109 + Widget build(BuildContext context) { 110 + return Wrap( 111 + spacing: 6, 112 + runSpacing: 4, 113 + children: labels.map((label) => _InformLabelChip(label: label)).toList(), 114 + ); 115 + } 116 + } 117 + 118 + class _InformLabelChip extends StatelessWidget { 119 + final String label; 120 + 121 + const _InformLabelChip({required this.label}); 122 + 123 + @override 124 + Widget build(BuildContext context) { 125 + return Container( 126 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 127 + decoration: BoxDecoration( 128 + color: AppColors.blue.withAlpha(150), 129 + borderRadius: BorderRadius.circular(12), 130 + border: Border.all(color: AppColors.blue.withAlpha(100), width: 1), 131 + ), 132 + child: Row( 133 + mainAxisSize: MainAxisSize.min, 134 + children: [ 135 + Icon( 136 + FluentIcons.info_16_regular, 137 + color: AppColors.white, 138 + size: 12, 139 + ), 140 + const SizedBox(width: 4), 141 + Text( 142 + label, 143 + style: const TextStyle( 144 + color: AppColors.white, 145 + fontSize: 11, 146 + fontWeight: FontWeight.w500, 147 + ), 148 + ), 149 + ], 150 + ), 151 + ); 152 + } 153 + }
+94 -1
lib/src/features/profile/providers/profile_feed_provider.dart
··· 6 6 import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository.dart'; 7 7 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 8 8 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 9 + import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; 9 10 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 10 11 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 11 12 import 'package:sparksocial/src/features/profile/providers/profile_feed_state.dart'; 12 13 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 14 + import 'package:atproto/atproto.dart'; 13 15 14 16 part 'profile_feed_provider.g.dart'; 15 17 ··· 19 21 class ProfileFeed extends _$ProfileFeed { 20 22 late final FeedRepository _feedRepository; 21 23 late final SQLCacheInterface _sqlCache; 24 + late final SettingsRepository _settingsRepository; 22 25 late final SparkLogger _logger; 23 26 bool _isLoading = false; 24 27 ··· 26 29 Future<ProfileFeedState> build(AtUri profileUri, bool videosOnly) async { 27 30 _feedRepository = GetIt.instance<SprkRepository>().feed; 28 31 _sqlCache = GetIt.instance<SQLCacheInterface>(); 32 + _settingsRepository = GetIt.instance<SettingsRepository>(); 29 33 _logger = GetIt.instance<LogService>().getLogger('ProfileFeed ${profileUri.toString()}'); 30 34 31 35 try { ··· 99 103 newPosts.sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 100 104 allPosts.addAll(newPosts.map((post) => post.uri)); 101 105 102 - final filteredPosts = videosOnly 106 + // Get additional labels from followed labelers for new posts 107 + if (newPosts.isNotEmpty) { 108 + try { 109 + final followedLabelers = await _settingsRepository.getFollowedLabelers(); 110 + final newPostUris = newPosts.map((post) => post.uri).toList(); 111 + final (cursor: _, labels: additionalLabels) = await _feedRepository.getLabels(newPostUris, sources: followedLabelers); 112 + // Add the additional labels to the posts 113 + for (final label in additionalLabels) { 114 + _logger.d('Adding label ${label.value} to post ${label.uri}'); 115 + final uri = AtUri.parse(label.uri); 116 + final post = postViews[uri]; 117 + if (post != null) { 118 + final existingLabels = post.labels != null ? List<Label>.from(post.labels!) : <Label>[]; 119 + existingLabels.add(label); 120 + postViews[uri] = post.copyWith(labels: existingLabels); 121 + } 122 + } 123 + } catch (e) { 124 + _logger.e('Error fetching additional labels: $e'); 125 + } 126 + } 127 + 128 + // Filter by video/non-video type first 129 + final typeFilteredPosts = videosOnly 103 130 ? allPosts.where((uri) => postTypes[uri] == true).toList() 104 131 : allPosts.where((uri) => postTypes[uri] == false).toList(); 132 + 133 + // Then filter based on label preferences 134 + final filteredPosts = await _filterHiddenPosts(typeFilteredPosts, postViews); 105 135 106 136 final isEndOfNetwork = 107 137 (sparkResult.cursor == null && bskyResult.cursor == null) || ··· 200 230 _logger.e('Error refreshing posts: $e'); 201 231 state = AsyncValue.error(e, StackTrace.current); 202 232 } 233 + } 234 + 235 + /// Checks if a post should be hidden based on its labels and user preferences 236 + Future<bool> _shouldHidePost(AtUri uri, List<Label> postLabels) async { 237 + final hideAdultContent = await _settingsRepository.getHideAdultContent(); 238 + for (final label in postLabels) { 239 + try { 240 + final labelPreference = await _settingsRepository.getLabelPreference(label.value); 241 + if (labelPreference.setting == Setting.hide || (labelPreference.adultOnly && hideAdultContent)) { 242 + _logger.d('Hiding post ${uri.toString()} due to label: ${label.value}'); 243 + return true; 244 + } 245 + } catch (e) { 246 + // Label preference not found, continue checking other labels 247 + continue; 248 + } 249 + } 250 + return false; 251 + } 252 + 253 + /// Filters URIs based on label preferences, removing posts that should be hidden 254 + Future<List<AtUri>> _filterHiddenPosts( 255 + List<AtUri> uris, 256 + Map<AtUri, PostView> postViews, 257 + ) async { 258 + final filteredUris = <AtUri>[]; 259 + 260 + for (final uri in uris) { 261 + final postView = postViews[uri]; 262 + if (postView != null) { 263 + // Collect all labels for this post 264 + final postLabels = <Label>[]; 265 + 266 + // Add labels from the post itself 267 + if (postView.labels != null) { 268 + postLabels.addAll(postView.labels!); 269 + } 270 + 271 + // Add self labels from the post record 272 + if (postView.record.selfLabels != null) { 273 + for (SelfLabel selfLabel in postView.record.selfLabels!) { 274 + postLabels.add( 275 + Label( 276 + uri: postView.uri.toString(), 277 + value: selfLabel.value, 278 + src: postView.uri.toString(), 279 + createdAt: postView.indexedAt, 280 + ), 281 + ); 282 + } 283 + } 284 + 285 + final shouldHide = await _shouldHidePost(uri, postLabels); 286 + if (!shouldHide) { 287 + filteredUris.add(uri); 288 + } 289 + } else { 290 + // No post view means no labels, so include the post 291 + filteredUris.add(uri); 292 + } 293 + } 294 + 295 + return filteredUris; 203 296 } 204 297 }
+2 -1
lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart
··· 85 85 } 86 86 87 87 final postUri = state.loadedPosts[index]; 88 - return ProfileFeedPostWidget(postUri: postUri, profileUri: profileAtUri, videosOnly: widget.videosOnly); 88 + final post = state.postViews[postUri]; 89 + return ProfileFeedPostWidget(postUri: postUri, profileUri: profileAtUri, videosOnly: widget.videosOnly, post: post); 89 90 }, 90 91 ); 91 92 },
+85 -10
lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart
··· 14 14 import 'package:sparksocial/src/features/feed/ui/widgets/post/info_bar.dart'; 15 15 import 'package:sparksocial/src/features/feed/ui/widgets/videos/video_player.dart'; 16 16 import 'package:sparksocial/src/core/widgets/heart_animation.dart'; 17 + import 'package:sparksocial/src/core/utils/label_utils.dart'; 18 + import 'package:sparksocial/src/core/widgets/content_warning_overlay.dart'; 17 19 18 20 class ProfileFeedPostWidget extends ConsumerStatefulWidget { 19 21 final AtUri postUri; 20 22 final AtUri profileUri; 21 23 final bool videosOnly; 24 + final PostView? post; 22 25 23 - const ProfileFeedPostWidget({super.key, required this.postUri, required this.profileUri, required this.videosOnly}); 26 + const ProfileFeedPostWidget({super.key, required this.postUri, required this.profileUri, required this.videosOnly, this.post}); 24 27 25 28 @override 26 29 ConsumerState<ProfileFeedPostWidget> createState() => _ProfileFeedPostWidgetState(); ··· 29 32 class _ProfileFeedPostWidgetState extends ConsumerState<ProfileFeedPostWidget> { 30 33 bool _isAnimatingHeart = false; 31 34 final GlobalKey<SideActionBarState> _sideActionBarKey = GlobalKey<SideActionBarState>(); 35 + bool _showWarningOverlay = false; 36 + bool _shouldBlurContent = false; 37 + List<String> _warningLabels = []; 38 + 39 + @override 40 + void initState() { 41 + super.initState(); 42 + _loadPostWithFallback().then((post) { 43 + if (post != null) { 44 + _checkContentWarning(post); 45 + } 46 + }); 47 + } 32 48 33 49 Future<PostView?> _loadPostWithFallback() async { 50 + if (widget.post != null) { 51 + return widget.post; 52 + } 34 53 final sqlCache = GetIt.instance<SQLCacheInterface>(); 35 54 36 55 try { ··· 88 107 } 89 108 } 90 109 110 + Future<void> _checkContentWarning(PostView postData) async { 111 + final labels = postData.labels ?? []; 112 + 113 + if (labels.isNotEmpty) { 114 + final shouldShowWarning = await LabelUtils.shouldShowWarning(labels); 115 + 116 + final shouldBlurContent = await LabelUtils.shouldBlurContent(labels); 117 + 118 + if (shouldShowWarning) { 119 + final warningLabels = await LabelUtils.getWarningLabels(labels); 120 + if (mounted) { 121 + setState(() { 122 + _showWarningOverlay = true; 123 + _warningLabels = warningLabels; 124 + _shouldBlurContent = shouldBlurContent; 125 + }); 126 + } 127 + } else { 128 + if (mounted) { 129 + setState(() { 130 + _showWarningOverlay = false; 131 + _warningLabels = []; 132 + }); 133 + } 134 + } 135 + } else { 136 + if (mounted) { 137 + setState(() { 138 + _showWarningOverlay = false; 139 + _warningLabels = []; 140 + }); 141 + } 142 + } 143 + } 144 + 91 145 @override 92 146 Widget build(BuildContext context) { 93 147 return SafeArea( ··· 119 173 120 174 final post = snapshot.data!; 121 175 122 - // Create a simple post display similar to FeedPostWidget but without feed dependencies 123 - return HeartAnimation( 176 + final mainContent = HeartAnimation( 124 177 isAnimating: _isAnimatingHeart, 125 178 onEnd: () { 126 179 setState(() { ··· 168 221 bottom: 32, 169 222 left: 4, 170 223 right: 80, 171 - child: InfoBar( 172 - username: post.author.handle, 173 - description: post.record.text ?? '', 174 - hashtags: post.record.hashtags, 175 - isSprk: post.uri.toString().contains('so.sprk'), 176 - onUsernameTap: () { 177 - context.router.push(ProfileRoute(did: post.author.did)); 224 + child: FutureBuilder<List<String>>( 225 + future: LabelUtils.getInformLabels(post.labels ?? []), 226 + builder: (context, snapshot) { 227 + final informLabels = snapshot.data ?? []; 228 + return InfoBar( 229 + username: post.author.handle, 230 + description: post.record.text ?? '', 231 + hashtags: post.record.hashtags, 232 + informLabels: informLabels, 233 + isSprk: post.uri.toString().contains('so.sprk'), 234 + onUsernameTap: () { 235 + context.router.push(ProfileRoute(did: post.author.did)); 236 + }, 237 + ); 178 238 }, 179 239 ), 180 240 ), ··· 182 242 ), 183 243 ), 184 244 ); 245 + 246 + if (_showWarningOverlay) { 247 + return ContentWarningOverlay( 248 + onViewContent: () { 249 + setState(() { 250 + _showWarningOverlay = false; 251 + }); 252 + }, 253 + warningLabels: _warningLabels, 254 + shouldBlur: _shouldBlurContent, 255 + child: mainContent, 256 + ); 257 + } 258 + 259 + return mainContent; 185 260 }, 186 261 ), 187 262 );
+73 -22
lib/src/features/profile/ui/widgets/profile_grid_widget.dart
··· 1 - import 'package:atproto_core/atproto_core.dart'; 1 + import 'dart:ui'; 2 + 3 + import 'package:atproto/core.dart'; 2 4 import 'package:auto_route/auto_route.dart'; 3 5 import 'package:cached_network_image/cached_network_image.dart'; 4 6 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; ··· 8 10 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 9 11 import 'package:sparksocial/src/core/routing/app_router.dart'; 10 12 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 13 + import 'package:sparksocial/src/core/utils/label_utils.dart'; 11 14 import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; 12 15 13 16 class ProfileGridWidget extends ConsumerStatefulWidget { ··· 21 24 } 22 25 23 26 class _ProfileGridWidgetState extends ConsumerState<ProfileGridWidget> { 24 - final ScrollController _scrollController = ScrollController(); 27 + late final ScrollController scrollController; 25 28 26 29 @override 27 30 void initState() { 28 31 super.initState(); 29 - _scrollController.addListener(_onScroll); 32 + scrollController = ScrollController(); 33 + scrollController.addListener(_onScroll); 30 34 } 31 35 32 36 @override 33 37 void dispose() { 34 - _scrollController.dispose(); 38 + scrollController.removeListener(_onScroll); 39 + scrollController.dispose(); 35 40 super.dispose(); 36 41 } 37 42 38 43 void _onScroll() { 39 - if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 44 + if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200) { 40 45 ref.read(profileFeedProvider(widget.profileUri, widget.videosOnly).notifier).loadMore(); 41 46 } 42 47 } ··· 68 73 } 69 74 70 75 return GridView.builder( 71 - controller: _scrollController, 76 + controller: scrollController, 72 77 padding: const EdgeInsets.all(1), 73 78 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 74 79 crossAxisCount: 4, ··· 93 98 return const SizedBox.shrink(); 94 99 } 95 100 96 - return ProfileGridTile(postView: postView, postSource: postSource, onTap: () => _onPostTap(postUri)); 101 + return ProfileGridTile( 102 + postView: postView, 103 + postSource: postSource, 104 + onTap: () => _onPostTap(postUri), 105 + ); 97 106 }, 98 107 ); 99 108 }, ··· 135 144 } 136 145 } 137 146 138 - class ProfileGridTile extends StatelessWidget { 147 + class ProfileGridTile extends StatefulWidget { 139 148 final PostView postView; 140 149 final String? postSource; 141 150 final VoidCallback onTap; ··· 143 152 const ProfileGridTile({super.key, required this.postView, this.postSource, required this.onTap}); 144 153 145 154 @override 155 + State<ProfileGridTile> createState() => _ProfileGridTileState(); 156 + } 157 + 158 + class _ProfileGridTileState extends State<ProfileGridTile> { 159 + bool _shouldBlur = false; 160 + 161 + @override 162 + void initState() { 163 + super.initState(); 164 + _checkContentWarning(); 165 + } 166 + 167 + @override 168 + void didUpdateWidget(covariant ProfileGridTile oldWidget) { 169 + super.didUpdateWidget(oldWidget); 170 + if (widget.postView.uri != oldWidget.postView.uri) { 171 + _checkContentWarning(); 172 + } 173 + } 174 + 175 + Future<void> _checkContentWarning() async { 176 + final labels = widget.postView.labels ?? []; 177 + final shouldBlur = labels.isNotEmpty ? await LabelUtils.shouldBlurContent(labels) : false; 178 + if (mounted) { 179 + setState(() => _shouldBlur = shouldBlur); 180 + } 181 + } 182 + 183 + @override 146 184 Widget build(BuildContext context) { 147 - final thumbnailUrl = postView.thumbnailUrl; 185 + final thumbnailUrl = widget.postView.thumbnailUrl; 186 + 187 + final image = thumbnailUrl.isNotEmpty 188 + ? CachedNetworkImage( 189 + imageUrl: thumbnailUrl, 190 + fit: BoxFit.cover, 191 + placeholder: (context, url) => const SizedBox.shrink(), 192 + errorWidget: (context, url, error) => Container( 193 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 194 + child: const Center(child: Icon(FluentIcons.error_circle_24_regular, size: 20)), 195 + ), 196 + ) 197 + : Container( 198 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 199 + child: const Center(child: Icon(FluentIcons.image_off_24_regular, size: 20)), 200 + ); 148 201 149 202 return GestureDetector( 150 - onTap: onTap, 203 + onTap: widget.onTap, 151 204 child: Container( 152 205 color: AppColors.black, 153 206 child: thumbnailUrl.isNotEmpty 154 207 ? Stack( 155 208 fit: StackFit.expand, 156 209 children: [ 157 - CachedNetworkImage( 158 - imageUrl: thumbnailUrl, 159 - fit: BoxFit.cover, 160 - placeholder: (context, url) => const SizedBox.shrink(), 161 - errorWidget: (context, url, error) => Container( 162 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 163 - child: const Center(child: Icon(FluentIcons.error_circle_24_regular, size: 20)), 164 - ), 165 - ), 166 - if (postSource != null) 210 + if (_shouldBlur) 211 + ImageFiltered( 212 + imageFilter: ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0), 213 + child: image, 214 + ) 215 + else 216 + image, 217 + if (widget.postSource != null) 167 218 Positioned( 168 219 top: 4, 169 220 right: 4, ··· 171 222 padding: const EdgeInsets.all(4), 172 223 decoration: BoxDecoration(color: Colors.black.withAlpha(150), borderRadius: BorderRadius.circular(4)), 173 224 child: SvgPicture.asset( 174 - postSource == 'bsky' ? 'assets/images/bsky.svg' : 'assets/images/sprk.svg', 225 + widget.postSource == 'bsky' ? 'assets/images/bsky.svg' : 'assets/images/sprk.svg', 175 226 width: 12, 176 227 height: 12, 177 228 ), ··· 181 232 ) 182 233 : Container( 183 234 color: Theme.of(context).colorScheme.surfaceContainerHighest, 184 - child: const Center(child: Icon(FluentIcons.image_off_24_regular)), 235 + child: const Center(child: Icon(FluentIcons.image_off_24_regular, size: 20)), 185 236 ), 186 237 ), 187 238 );
+271 -3
lib/src/features/settings/ui/pages/feed_list_page.dart
··· 1 1 import 'package:auto_route/auto_route.dart'; 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 5 + import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 6 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 7 + import 'dart:ui' show lerpDouble; 4 8 5 9 @RoutePage() 6 - class FeedListPage extends ConsumerWidget { 10 + class FeedListPage extends ConsumerStatefulWidget { 7 11 const FeedListPage({super.key}); 8 12 9 13 @override 10 - Widget build(BuildContext context, WidgetRef ref) { 11 - return const Placeholder(); 14 + ConsumerState<FeedListPage> createState() => _FeedListPageState(); 15 + } 16 + 17 + class _FeedListPageState extends ConsumerState<FeedListPage> { 18 + bool _isReordering = false; 19 + 20 + @override 21 + Widget build(BuildContext context) { 22 + final settingsState = ref.watch(settingsProvider); 23 + final theme = Theme.of(context); 24 + final colorScheme = theme.colorScheme; 25 + 26 + return Scaffold( 27 + backgroundColor: colorScheme.surface, 28 + body: Column( 29 + children: [ 30 + // Settings toggles 31 + Padding( 32 + padding: const EdgeInsets.all(16), 33 + child: Column( 34 + crossAxisAlignment: CrossAxisAlignment.start, 35 + children: [ 36 + Text( 37 + 'Feed Options', 38 + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onSurface), 39 + ), 40 + const SizedBox(height: 16), 41 + 42 + // Feed Blur Toggle 43 + Card( 44 + child: SwitchListTile( 45 + title: Text( 46 + 'Blur Feed Content', 47 + style: TextStyle(fontWeight: FontWeight.w600, color: colorScheme.onSurface), 48 + ), 49 + subtitle: Text( 50 + 'Blur potentially sensitive content in feeds', 51 + style: TextStyle(color: colorScheme.onSurface.withAlpha(178), fontSize: 12), 52 + ), 53 + value: settingsState.feedBlurEnabled, 54 + onChanged: (value) { 55 + ref.read(settingsProvider.notifier).setFeedBlur(value); 56 + }, 57 + secondary: Icon(FluentIcons.eye_off_24_regular, color: colorScheme.primary), 58 + ), 59 + ), 60 + 61 + const SizedBox(height: 8), 62 + 63 + // Hide Adult Content Toggle 64 + Card( 65 + child: SwitchListTile( 66 + title: Text( 67 + 'Hide Adult Content', 68 + style: TextStyle(fontWeight: FontWeight.w600, color: colorScheme.onSurface), 69 + ), 70 + subtitle: Text( 71 + 'Hide content marked as adult/mature', 72 + style: TextStyle(color: colorScheme.onSurface.withAlpha(178), fontSize: 12), 73 + ), 74 + value: settingsState.hideAdultContent, 75 + onChanged: (value) { 76 + ref.read(settingsProvider.notifier).setHideAdultContent(value); 77 + }, 78 + secondary: Icon(FluentIcons.shield_24_regular, color: colorScheme.primary), 79 + ), 80 + ), 81 + 82 + const SizedBox(height: 8), 83 + 84 + // Post to Bluesky Toggle 85 + Card( 86 + child: SwitchListTile( 87 + title: Text( 88 + 'Cross-post to Bluesky', 89 + style: TextStyle(fontWeight: FontWeight.w600, color: colorScheme.onSurface), 90 + ), 91 + subtitle: Text( 92 + 'Automatically post to Bluesky when posting to Spark', 93 + style: TextStyle(color: colorScheme.onSurface.withAlpha(178), fontSize: 12), 94 + ), 95 + value: settingsState.postToBskyEnabled, 96 + onChanged: (value) { 97 + ref.read(settingsProvider.notifier).setPostToBsky(value); 98 + }, 99 + secondary: Icon(FluentIcons.share_24_regular, color: colorScheme.primary), 100 + ), 101 + ), 102 + ], 103 + ), 104 + ), 105 + 106 + // Feeds List 107 + Expanded( 108 + child: Column( 109 + crossAxisAlignment: CrossAxisAlignment.start, 110 + children: [ 111 + Padding( 112 + padding: const EdgeInsets.symmetric(horizontal: 16), 113 + child: Text( 114 + 'Your Feeds', 115 + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface), 116 + ), 117 + ), 118 + const SizedBox(height: 8), 119 + Expanded( 120 + child: ReorderableListView.builder( 121 + padding: const EdgeInsets.symmetric(horizontal: 16), 122 + itemCount: settingsState.feeds.length, 123 + onReorderStart: (index) { 124 + setState(() { 125 + _isReordering = true; 126 + }); 127 + }, 128 + onReorderEnd: (index) { 129 + setState(() { 130 + _isReordering = false; 131 + }); 132 + }, 133 + onReorder: (oldIndex, newIndex) async { 134 + if (_isReordering) return; 135 + 136 + setState(() => _isReordering = true); 137 + 138 + try { 139 + // Adjust newIndex if moving down the list 140 + if (newIndex > oldIndex) newIndex -= 1; 141 + 142 + await ref.read(settingsProvider.notifier).reorderFeed(oldIndex, newIndex); 143 + 144 + // Small delay to allow state to settle 145 + await Future.delayed(const Duration(milliseconds: 50)); 146 + } catch (e) { 147 + if (context.mounted) { 148 + ScaffoldMessenger.of(context).showSnackBar( 149 + SnackBar(content: Text('Failed to reorder feeds: $e')), 150 + ); 151 + } 152 + } finally { 153 + if (context.mounted) { 154 + setState(() => _isReordering = false); 155 + } 156 + } 157 + }, 158 + proxyDecorator: (child, index, animation) { 159 + return AnimatedBuilder( 160 + animation: animation, 161 + builder: (context, child) { 162 + final animValue = Curves.easeInOutCubic.transform(animation.value); 163 + final elevation = lerpDouble(2, 8, animValue)!; 164 + final scale = lerpDouble(1, 1.05, animValue)!; 165 + 166 + return Transform.scale( 167 + scale: scale, 168 + child: Material( 169 + elevation: elevation, 170 + borderRadius: BorderRadius.circular(12), 171 + shadowColor: colorScheme.shadow.withAlpha(100), 172 + color: Colors.transparent, 173 + child: child, 174 + ), 175 + ); 176 + }, 177 + child: child, 178 + ); 179 + }, 180 + itemBuilder: (context, index) { 181 + final feed = settingsState.feeds[index]; 182 + final isActive = settingsState.activeFeed == feed; 183 + 184 + return Card( 185 + key: ValueKey(feed.identifier), 186 + margin: const EdgeInsets.symmetric(vertical: 4), 187 + elevation: _isReordering ? 0 : 1, 188 + child: ListTile( 189 + enabled: !_isReordering, 190 + leading: Icon(_getFeedIcon(feed), color: isActive ? colorScheme.primary : colorScheme.onSurface), 191 + title: Text( 192 + feed.name, 193 + style: TextStyle( 194 + fontWeight: isActive ? FontWeight.bold : FontWeight.w500, 195 + color: isActive ? colorScheme.primary : colorScheme.onSurface, 196 + ), 197 + ), 198 + subtitle: Text( 199 + _getFeedDescription(feed), 200 + style: TextStyle(color: colorScheme.onSurface.withAlpha(178), fontSize: 12), 201 + ), 202 + trailing: Row( 203 + mainAxisSize: MainAxisSize.min, 204 + children: [ 205 + if (isActive) 206 + Container( 207 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), 208 + decoration: BoxDecoration( 209 + color: colorScheme.primary.withAlpha(51), 210 + borderRadius: BorderRadius.circular(12), 211 + ), 212 + child: Text( 213 + 'Active', 214 + style: TextStyle(fontSize: 10, color: colorScheme.primary, fontWeight: FontWeight.w600), 215 + ), 216 + ), 217 + const SizedBox(width: 8), 218 + Icon( 219 + FluentIcons.re_order_dots_vertical_24_regular, 220 + color: _isReordering 221 + ? colorScheme.primary.withAlpha(128) 222 + : colorScheme.onSurface.withAlpha(178), 223 + ), 224 + ], 225 + ), 226 + onTap: _isReordering ? null : () { 227 + ref.read(settingsProvider.notifier).setActiveFeed(feed); 228 + }, 229 + ), 230 + ); 231 + }, 232 + ), 233 + ), 234 + ], 235 + ), 236 + ), 237 + ], 238 + ), 239 + ); 240 + } 241 + 242 + IconData _getFeedIcon(Feed feed) { 243 + return feed.when( 244 + custom: (name, uri) => FluentIcons.feed_24_regular, 245 + hardCoded: (hardCodedFeed) { 246 + switch (hardCodedFeed) { 247 + case HardCodedFeedEnum.following: 248 + return FluentIcons.people_24_regular; 249 + case HardCodedFeedEnum.mutuals: 250 + return FluentIcons.people_team_24_regular; 251 + case HardCodedFeedEnum.forYou: 252 + return FluentIcons.star_24_regular; 253 + case HardCodedFeedEnum.latestSprk: 254 + return FluentIcons.flash_24_regular; 255 + case HardCodedFeedEnum.shared: 256 + return FluentIcons.share_24_regular; 257 + } 258 + }, 259 + ); 260 + } 261 + 262 + String _getFeedDescription(Feed feed) { 263 + return feed.when( 264 + custom: (name, uri) => 'Custom algorithmic feed', 265 + hardCoded: (hardCodedFeed) { 266 + switch (hardCodedFeed) { 267 + case HardCodedFeedEnum.following: 268 + return 'Posts from accounts you follow'; 269 + case HardCodedFeedEnum.mutuals: 270 + return 'Posts from mutual connections'; 271 + case HardCodedFeedEnum.forYou: 272 + return 'Personalized content recommendations'; 273 + case HardCodedFeedEnum.latestSprk: 274 + return 'Latest posts from Spark community'; 275 + case HardCodedFeedEnum.shared: 276 + return 'Posts shared by friends in messages'; 277 + } 278 + }, 279 + ); 12 280 } 13 281 }
+49 -29
lib/src/features/settings/ui/pages/feed_settings_page.dart
··· 1 1 import 'package:auto_route/auto_route.dart'; 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 - import 'package:sparksocial/src/core/routing/app_router.dart'; 4 + import 'package:sparksocial/src/features/settings/ui/pages/feed_list_page.dart'; 5 + import 'package:sparksocial/src/features/settings/ui/pages/label_settings_page.dart'; 5 6 6 7 @RoutePage() 7 - class FeedSettingsPage extends ConsumerWidget { 8 + class FeedSettingsPage extends ConsumerStatefulWidget { 8 9 const FeedSettingsPage({super.key}); 9 10 10 11 @override 11 - Widget build(BuildContext context, WidgetRef ref) { 12 + ConsumerState<FeedSettingsPage> createState() => _FeedSettingsPageState(); 13 + } 14 + 15 + class _FeedSettingsPageState extends ConsumerState<FeedSettingsPage> 16 + with SingleTickerProviderStateMixin { 17 + late TabController _tabController; 18 + 19 + @override 20 + void initState() { 21 + super.initState(); 22 + _tabController = TabController(length: 2, vsync: this); 23 + } 24 + 25 + @override 26 + void dispose() { 27 + _tabController.dispose(); 28 + super.dispose(); 29 + } 30 + 31 + @override 32 + Widget build(BuildContext context) { 12 33 final colorScheme = Theme.of(context).colorScheme; 13 34 final backgroundColor = colorScheme.surface; 14 35 final textColor = colorScheme.onSurface; 15 36 16 - return AutoTabsRouter.tabBar( 17 - routes: const [FeedListRoute()], 18 - builder: (context, child, tabController) { 19 - return Scaffold( 20 - appBar: AppBar( 21 - title: const Text('Feed Settings'), 22 - centerTitle: true, 23 - leading: AutoLeadingButton(), 24 - backgroundColor: backgroundColor, 25 - foregroundColor: textColor, 26 - bottom: TabBar( 27 - controller: tabController, 28 - labelColor: textColor, 29 - unselectedLabelColor: textColor.withAlpha(127), 30 - isScrollable: true, 31 - onTap: (index) { 32 - tabController.animateTo(index); 33 - AutoTabsRouter.of(context).setActiveIndex(index); 34 - }, 35 - 36 - tabs: const [Tab(text: "Your Feeds")], 37 - ), 38 - ), 39 - body: child, 40 - ); 41 - }, 37 + return Scaffold( 38 + appBar: AppBar( 39 + title: const Text('Feed Settings'), 40 + centerTitle: true, 41 + leading: const AutoLeadingButton(), 42 + backgroundColor: backgroundColor, 43 + foregroundColor: textColor, 44 + bottom: TabBar( 45 + controller: _tabController, 46 + labelColor: textColor, 47 + unselectedLabelColor: textColor.withAlpha(127), 48 + isScrollable: false, 49 + tabs: const [ 50 + Tab(text: "Your Feeds"), 51 + Tab(text: "Content Labels"), 52 + ], 53 + ), 54 + ), 55 + body: TabBarView( 56 + controller: _tabController, 57 + children: const [ 58 + FeedListPage(), 59 + LabelSettingsPage(), 60 + ], 61 + ), 42 62 ); 43 63 } 44 64 }
+472
lib/src/features/settings/ui/pages/label_settings_page.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:get_it/get_it.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/models/labeler_models.dart'; 6 + import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; 7 + import 'package:sparksocial/src/core/utils/logging/logging.dart'; 8 + import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 9 + 10 + @RoutePage() 11 + class LabelSettingsPage extends ConsumerStatefulWidget { 12 + const LabelSettingsPage({super.key}); 13 + 14 + @override 15 + ConsumerState<LabelSettingsPage> createState() => _LabelSettingsPageState(); 16 + } 17 + 18 + class _LabelSettingsPageState extends ConsumerState<LabelSettingsPage> { 19 + late final SettingsRepository _settingsRepository; 20 + late final SparkLogger _logger; 21 + Map<String, LabelPreference> _labelPreferences = {}; 22 + List<String> _followedLabelers = []; 23 + bool _isLoading = true; 24 + 25 + @override 26 + void initState() { 27 + super.initState(); 28 + _settingsRepository = GetIt.instance<SettingsRepository>(); 29 + _logger = GetIt.instance<LogService>().getLogger('LabelSettingsPage'); 30 + _loadLabelSettings(); 31 + } 32 + 33 + Future<void> _loadLabelSettings() async { 34 + try { 35 + setState(() => _isLoading = true); 36 + 37 + final followedLabelers = await _settingsRepository.getFollowedLabelers(); 38 + final Map<String, LabelPreference> preferences = {}; 39 + 40 + _logger.d('Loading preferences for ${defaultLabels.length} default labels'); 41 + 42 + // Load preferences for default labels 43 + for (final label in defaultLabels) { 44 + try { 45 + final pref = await _settingsRepository.getLabelPreference(label); 46 + preferences[label] = pref; 47 + } catch (e) { 48 + _logger.w('Could not load preference for label: $label - Error: $e'); 49 + // Create a default preference for missing labels 50 + try { 51 + _logger.d('Creating default preference for missing label: $label'); 52 + final defaultPref = _createDefaultLabelPreference(label); 53 + await _settingsRepository.setLabelPreference( 54 + label, 55 + defaultPref.blurs, 56 + defaultPref.severity, 57 + defaultPref.adultOnly, 58 + defaultPref.setting, 59 + ); 60 + preferences[label] = defaultPref; 61 + _logger.d('Created and saved default preference for label: $label'); 62 + } catch (createError) { 63 + _logger.e('Failed to create default preference for label $label: $createError'); 64 + } 65 + } 66 + } 67 + 68 + setState(() { 69 + _followedLabelers = followedLabelers; 70 + _labelPreferences = preferences; 71 + _isLoading = false; 72 + }); 73 + 74 + _logger.d('Loaded ${preferences.length} label preferences successfully'); 75 + } catch (e) { 76 + _logger.e('Error loading label settings: $e'); 77 + setState(() => _isLoading = false); 78 + } 79 + } 80 + 81 + LabelPreference _createDefaultLabelPreference(String label) { 82 + // Create sensible defaults based on label type 83 + switch (label) { 84 + case '!hide': 85 + case 'dmca-violation': 86 + return LabelPreference( 87 + value: label, 88 + blurs: Blurs.content, 89 + severity: Severity.alert, 90 + defaultSetting: Setting.hide, 91 + setting: Setting.hide, 92 + adultOnly: false, 93 + ); 94 + case '!no-promote': 95 + return LabelPreference( 96 + value: label, 97 + blurs: Blurs.content, 98 + severity: Severity.alert, 99 + defaultSetting: Setting.hide, 100 + setting: Setting.hide, 101 + adultOnly: false, 102 + ); 103 + case '!warn': 104 + case 'doxxing': 105 + case 'porn': 106 + case 'sexual': 107 + case 'nsfl': 108 + case 'gore': 109 + return LabelPreference( 110 + value: label, 111 + blurs: Blurs.content, 112 + severity: Severity.alert, 113 + defaultSetting: Setting.warn, 114 + setting: Setting.warn, 115 + adultOnly: label == 'porn' || label == 'sexual' || label == 'nsfl' || label == 'gore', 116 + ); 117 + case '!no-unauthenticated': 118 + return LabelPreference( 119 + value: label, 120 + blurs: Blurs.none, 121 + severity: Severity.none, 122 + defaultSetting: Setting.ignore, 123 + setting: Setting.ignore, 124 + adultOnly: false, 125 + ); 126 + case 'nudity': 127 + return LabelPreference( 128 + value: label, 129 + blurs: Blurs.content, 130 + severity: Severity.alert, 131 + defaultSetting: Setting.ignore, 132 + setting: Setting.ignore, 133 + adultOnly: false, 134 + ); 135 + default: 136 + return LabelPreference( 137 + value: label, 138 + blurs: Blurs.content, 139 + severity: Severity.inform, 140 + defaultSetting: Setting.warn, 141 + setting: Setting.warn, 142 + adultOnly: false, 143 + ); 144 + } 145 + } 146 + 147 + Future<void> _setupDefaultPreferences() async { 148 + _logger.d('Setting up default label preferences manually'); 149 + for (final label in defaultLabels) { 150 + try { 151 + final defaultPref = _createDefaultLabelPreference(label); 152 + await _settingsRepository.setLabelPreference( 153 + label, 154 + defaultPref.blurs, 155 + defaultPref.severity, 156 + defaultPref.adultOnly, 157 + defaultPref.setting, 158 + ); 159 + _logger.d('Set up default preference for label: $label'); 160 + } catch (e) { 161 + _logger.e('Failed to set up default preference for label $label: $e'); 162 + } 163 + } 164 + } 165 + 166 + Future<void> _updateLabelPreference(String label, {Setting? setting, Blurs? blurs, Severity? severity}) async { 167 + try { 168 + final currentPref = _labelPreferences[label]; 169 + if (currentPref != null) { 170 + final newSetting = setting ?? currentPref.setting; 171 + final newBlurs = blurs ?? currentPref.blurs; 172 + final newSeverity = severity ?? currentPref.severity; 173 + 174 + await _settingsRepository.setLabelPreference(label, newBlurs, newSeverity, currentPref.adultOnly, newSetting); 175 + 176 + setState(() { 177 + _labelPreferences[label] = currentPref.copyWith(setting: newSetting, blurs: newBlurs, severity: newSeverity); 178 + }); 179 + } 180 + } catch (e) { 181 + _logger.e('Error updating label preference: $e'); 182 + if (mounted) { 183 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update preference: $e'))); 184 + } 185 + } 186 + } 187 + 188 + @override 189 + Widget build(BuildContext context) { 190 + final theme = Theme.of(context); 191 + final colorScheme = theme.colorScheme; 192 + final settingsState = ref.watch(settingsProvider); 193 + 194 + if (_isLoading) { 195 + return const Scaffold(body: Center(child: CircularProgressIndicator())); 196 + } 197 + 198 + return Scaffold( 199 + backgroundColor: colorScheme.surface, 200 + body: RefreshIndicator( 201 + onRefresh: _loadLabelSettings, 202 + child: ListView( 203 + padding: const EdgeInsets.symmetric(vertical: 8), 204 + children: [ 205 + // Header 206 + Padding( 207 + padding: const EdgeInsets.all(16), 208 + child: Column( 209 + crossAxisAlignment: CrossAxisAlignment.start, 210 + children: [ 211 + Text( 212 + 'Content Labels', 213 + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onSurface), 214 + ), 215 + const SizedBox(height: 8), 216 + Text( 217 + 'Configure how different types of content are handled in your feeds.', 218 + style: TextStyle(color: colorScheme.onSurface.withAlpha(178), fontSize: 14), 219 + ), 220 + ], 221 + ), 222 + ), 223 + 224 + // Followed Labelers section 225 + if (_followedLabelers.isNotEmpty) ...[ 226 + Padding( 227 + padding: const EdgeInsets.all(16), 228 + child: Text( 229 + 'Active Labelers', 230 + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colorScheme.onSurface), 231 + ), 232 + ), 233 + Card( 234 + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), 235 + child: Padding( 236 + padding: const EdgeInsets.all(16), 237 + child: Column( 238 + children: _followedLabelers.map((labeler) { 239 + return Padding( 240 + padding: const EdgeInsets.symmetric(vertical: 4), 241 + child: Row( 242 + children: [ 243 + Icon(Icons.verified, size: 16, color: colorScheme.primary), 244 + const SizedBox(width: 8), 245 + Expanded( 246 + child: Text(labeler, style: TextStyle(fontSize: 12, color: colorScheme.onSurface.withAlpha(178))), 247 + ), 248 + ], 249 + ), 250 + ); 251 + }).toList(), 252 + ), 253 + ), 254 + ), 255 + const SizedBox(height: 16), 256 + ], 257 + 258 + // Label preferences 259 + Padding( 260 + padding: const EdgeInsets.all(16), 261 + child: Column( 262 + crossAxisAlignment: CrossAxisAlignment.start, 263 + children: [ 264 + Text( 265 + 'Label Settings', 266 + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colorScheme.onSurface), 267 + ), 268 + if (settingsState.hideAdultContent) 269 + Padding( 270 + padding: const EdgeInsets.only(top: 4), 271 + child: Text( 272 + 'Adult content labels are hidden. Disable "Hide Adult Content" in the Your Feeds tab to show them.', 273 + style: TextStyle(fontSize: 12, color: colorScheme.onSurface.withAlpha(140), fontStyle: FontStyle.italic), 274 + ), 275 + ), 276 + ], 277 + ), 278 + ), 279 + 280 + // Build label setting tiles (filter out labels starting with !) 281 + if (_labelPreferences.isEmpty) 282 + Card( 283 + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), 284 + child: Padding( 285 + padding: const EdgeInsets.all(16), 286 + child: Column( 287 + children: [ 288 + Icon(Icons.warning_amber, size: 48, color: colorScheme.error), 289 + const SizedBox(height: 8), 290 + Text( 291 + 'No Label Preferences Found', 292 + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: colorScheme.error), 293 + ), 294 + const SizedBox(height: 8), 295 + Text( 296 + 'There was an issue loading your label preferences. Try refreshing or resetting to defaults.', 297 + style: TextStyle(color: colorScheme.onSurface.withAlpha(178)), 298 + textAlign: TextAlign.center, 299 + ), 300 + const SizedBox(height: 16), 301 + ElevatedButton( 302 + onPressed: () async { 303 + try { 304 + // Force setup of default preferences 305 + await _setupDefaultPreferences(); 306 + await _loadLabelSettings(); 307 + } catch (e) { 308 + if (context.mounted) { 309 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to setup defaults: $e'))); 310 + } 311 + } 312 + }, 313 + style: ElevatedButton.styleFrom(backgroundColor: colorScheme.primary), 314 + child: Text('Setup Default Preferences', style: TextStyle(color: colorScheme.onPrimary)), 315 + ), 316 + ], 317 + ), 318 + ), 319 + ) 320 + else 321 + // Filter out labels starting with "!" and optionally adult content 322 + ..._labelPreferences.entries 323 + .where((entry) { 324 + // Always filter out system labels starting with "!" 325 + if (entry.key.startsWith('!')) return false; 326 + 327 + // If hide adult content is enabled, filter out adult-only labels 328 + if (settingsState.hideAdultContent && entry.value.adultOnly) return false; 329 + 330 + return true; 331 + }) 332 + .map((entry) { 333 + return LabelSettingTile( 334 + label: entry.key, 335 + preference: entry.value, 336 + onPreferenceUpdate: _updateLabelPreference, 337 + ); 338 + }), 339 + 340 + const SizedBox(height: 32), 341 + ], 342 + ), 343 + ), 344 + ); 345 + } 346 + } 347 + 348 + class LabelSettingTile extends StatelessWidget { 349 + final String label; 350 + final LabelPreference preference; 351 + final Function(String label, {Setting? setting, Blurs? blurs, Severity? severity}) onPreferenceUpdate; 352 + 353 + const LabelSettingTile({ 354 + super.key, 355 + required this.label, 356 + required this.preference, 357 + required this.onPreferenceUpdate, 358 + }); 359 + 360 + @override 361 + Widget build(BuildContext context) { 362 + final theme = Theme.of(context); 363 + final colorScheme = theme.colorScheme; 364 + 365 + return Card( 366 + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), 367 + child: ExpansionTile( 368 + title: Text( 369 + label, 370 + style: TextStyle(fontWeight: FontWeight.w600, color: colorScheme.onSurface), 371 + ), 372 + subtitle: Text( 373 + 'Setting: ${preference.setting.value}${preference.adultOnly ? ' • Adult Only' : ''}', 374 + style: TextStyle(color: colorScheme.onSurface.withAlpha(178), fontSize: 12), 375 + ), 376 + children: [ 377 + Padding( 378 + padding: const EdgeInsets.all(16), 379 + child: Column( 380 + crossAxisAlignment: CrossAxisAlignment.start, 381 + children: [ 382 + Text( 383 + 'Content Action', 384 + style: TextStyle(fontWeight: FontWeight.w600, color: colorScheme.onSurface), 385 + ), 386 + const SizedBox(height: 8), 387 + Row( 388 + children: Setting.values.map((setting) { 389 + final isSelected = preference.setting == setting; 390 + return Expanded( 391 + child: Padding( 392 + padding: const EdgeInsets.symmetric(horizontal: 4), 393 + child: ElevatedButton( 394 + onPressed: () => onPreferenceUpdate(label, setting: setting), 395 + style: ElevatedButton.styleFrom( 396 + backgroundColor: isSelected ? colorScheme.primary : colorScheme.surface, 397 + foregroundColor: isSelected ? colorScheme.onPrimary : colorScheme.onSurface, 398 + elevation: isSelected ? 2 : 0, 399 + side: BorderSide(color: colorScheme.outline, width: 0.5), 400 + ), 401 + child: Text(setting.value.toUpperCase(), style: const TextStyle(fontSize: 12)), 402 + ), 403 + ), 404 + ); 405 + }).toList(), 406 + ), 407 + 408 + const SizedBox(height: 16), 409 + 410 + // Severity Settings 411 + Text( 412 + 'Severity Level', 413 + style: TextStyle(fontWeight: FontWeight.w600, color: colorScheme.onSurface), 414 + ), 415 + const SizedBox(height: 8), 416 + Row( 417 + children: Severity.values.map((sev) { 418 + final isSelected = preference.severity == sev; 419 + return Expanded( 420 + child: Padding( 421 + padding: const EdgeInsets.symmetric(horizontal: 4), 422 + child: ElevatedButton( 423 + onPressed: () => onPreferenceUpdate(label, severity: sev), 424 + style: ElevatedButton.styleFrom( 425 + backgroundColor: isSelected ? colorScheme.tertiary : colorScheme.surface, 426 + foregroundColor: isSelected ? colorScheme.onTertiary : colorScheme.onSurface, 427 + elevation: isSelected ? 2 : 0, 428 + side: BorderSide(color: colorScheme.outline, width: 0.5), 429 + ), 430 + child: Text(sev.value.toUpperCase(), style: const TextStyle(fontSize: 10)), 431 + ), 432 + ), 433 + ); 434 + }).toList(), 435 + ), 436 + // Only show blur settings if setting is 'warn' 437 + if (preference.setting == Setting.warn) ...[ 438 + const SizedBox(height: 16), 439 + Text( 440 + 'Blur Level', 441 + style: TextStyle(fontWeight: FontWeight.w600, color: colorScheme.onSurface), 442 + ), 443 + const SizedBox(height: 8), 444 + Row( 445 + children: Blurs.values.where((blur) => blur != Blurs.media).map((blur) { 446 + final isSelected = preference.blurs == blur; 447 + return Expanded( 448 + child: Padding( 449 + padding: const EdgeInsets.symmetric(horizontal: 4), 450 + child: ElevatedButton( 451 + onPressed: () => onPreferenceUpdate(label, blurs: blur), 452 + style: ElevatedButton.styleFrom( 453 + backgroundColor: isSelected ? colorScheme.secondary : colorScheme.surface, 454 + foregroundColor: isSelected ? colorScheme.onSecondary : colorScheme.onSurface, 455 + elevation: isSelected ? 2 : 0, 456 + side: BorderSide(color: colorScheme.outline, width: 0.5), 457 + ), 458 + child: Text(blur.value.toUpperCase(), style: const TextStyle(fontSize: 10)), 459 + ), 460 + ), 461 + ); 462 + }).toList(), 463 + ), 464 + ], 465 + ], 466 + ), 467 + ), 468 + ], 469 + ), 470 + ); 471 + } 472 + }