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

the denture has begun (#30)

* começou a dentadura

* remove unutilized imports

* lint errors fixed

authored by

Jean Carlo Polo and committed by
GitHub
0507b7aa 5383f5ab

+2869 -95
+2
.gitignore
··· 46 46 47 47 # Environment variables 48 48 .env 49 + 50 + .vscode/
+3 -3
android/app/build.gradle.kts
··· 75 75 76 76 signingConfigs { 77 77 create("release") { 78 - keyAlias = keystoreProperties["keyAlias"] as String 79 - keyPassword = keystoreProperties["keyPassword"] as String 78 + keyAlias = keystoreProperties["keyAlias"] as? String 79 + keyPassword = keystoreProperties["keyPassword"] as? String 80 80 storeFile = keystoreProperties["storeFile"]?.let { file(it) } 81 - storePassword = keystoreProperties["storePassword"] as String 81 + storePassword = keystoreProperties["storePassword"] as? String 82 82 } 83 83 } 84 84
+9
lib/main.dart
··· 25 25 import 'utils/app_colors.dart'; 26 26 import 'utils/app_theme.dart'; 27 27 import 'widgets/upload/upload_progress_indicator.dart'; 28 + import 'services/labeler_manager.dart'; 28 29 29 30 // Global RouteObserver instance 30 31 final RouteObserver<ModalRoute<void>> routeObserver = RouteObserver<ModalRoute<void>>(); ··· 80 81 ProxyProvider<AuthService, VideoService>( 81 82 create: (context) => VideoService(context.read<AuthService>()), 82 83 update: (_, authService, previousVideoService) => previousVideoService ?? VideoService(authService), 84 + ), 85 + ChangeNotifierProxyProvider2<AuthService, SettingsService, LabelerManager>( 86 + create: (context) => LabelerManager( 87 + context.read<AuthService>(), 88 + context.read<SettingsService>(), 89 + ), 90 + update: (_, authService, settingsService, previousLabelerManager) => 91 + previousLabelerManager ?? LabelerManager(authService, settingsService), 83 92 ), 84 93 ], 85 94 child: MaterialApp(
+7
lib/models/feed_post.dart
··· 13 13 final int commentCount; 14 14 final int shareCount; 15 15 final List<String> hashtags; 16 + List<String> labels; 16 17 final List<String> imageUrls; 17 18 final String uri; // Post URI for likes 18 19 final String cid; // Post CID for likes ··· 33 34 this.commentCount = 0, 34 35 this.shareCount = 0, 35 36 this.hashtags = const [], 37 + this.labels = const [], 36 38 this.imageUrls = const [], 37 39 required this.uri, 38 40 required this.cid, ··· 43 45 this.imageAlts = const [], 44 46 this.videoAlt, 45 47 }); 48 + 49 + /// Set new labels for this post 50 + void setLabels(List<String> newLabels) { 51 + labels = List<String>.from(newLabels); 52 + } 46 53 47 54 /// Create a FeedPost from a Bluesky feed item 48 55 static FeedPost fromBlueskyFeed(FeedView feedItem) {
+57 -9
lib/screens/feed_screen.dart
··· 10 10 import '../services/feed_manager.dart'; 11 11 import '../services/feed_settings_service.dart'; 12 12 import '../services/media_manager.dart'; 13 + import '../services/labeler_manager.dart'; 14 + import '../widgets/censorship/warn_builder.dart'; 13 15 import '../widgets/image/image_post_item.dart'; 14 16 import '../widgets/video/preloaded_video_item.dart'; 15 17 import '../widgets/video/video_item.dart'; ··· 109 111 }); 110 112 111 113 final authService = context.read<AuthService>(); 114 + final labelerManager = context.read<LabelerManager>(); 115 + _feedManager.setLabelerManager(labelerManager); 116 + 112 117 final posts = await _feedManager.fetchFeed(widget.feedType, authService); 113 118 114 119 if (!mounted) return; ··· 177 182 178 183 await _preloadMedia(0); 179 184 180 - for (int i = 1; i <= 5 && i < _feedPosts!.length; i++) { 185 + for (int i = 1; i <= 3 && i < _feedPosts!.length; i++) { 181 186 _preloadMedia(i); 182 187 } 183 188 } ··· 246 251 247 252 final totalPosts = _feedPosts?.length ?? 0; 248 253 249 - // Unload videos that are more than 10 positions away 254 + // Unload videos that are more than 6 positions away (reduced) 250 255 for (int i = 0; i < totalPosts; i++) { 251 - if (i < newIndex - 10 || i > newIndex + 10) { 256 + if (i < newIndex - 6 || i > newIndex + 6) { 252 257 _mediaManager.unloadVideo(i); 253 258 } 254 259 } 255 260 256 - // Preload videos within 5 positions 257 - for (int i = newIndex - 5; i <= newIndex + 5; i++) { 261 + // Preload videos within 3 positions (reduced) 262 + for (int i = newIndex - 3; i <= newIndex + 3; i++) { 258 263 if (i >= 0 && i < totalPosts) { 259 264 _preloadMedia(i); 260 265 } ··· 267 272 268 273 final bool isItemActuallyVisible = (index == _currentIndex) && widget.isParentFeedVisible; 269 274 275 + // Check if the content should show a warning 276 + final bool shouldWarn = _feedManager.shouldWarnContent(post); 277 + 278 + // Get warning message if needed 279 + String? warningMessage; 280 + if (shouldWarn) { 281 + final warningMessages = _feedManager.getWarningMessages(post); 282 + if (warningMessages.isNotEmpty) { 283 + warningMessage = warningMessages.join(", "); 284 + } 285 + } 286 + 287 + // Build the appropriate media widget based on the post type 288 + Widget contentWidget; 289 + 270 290 if (post.videoUrl != null) { 271 291 final isPreloaded = _mediaManager.isVideoPreloaded(index); 272 292 final preloadedVideo = _mediaManager.getPreloadedVideo(index); 273 293 274 294 if (isPreloaded && preloadedVideo != null) { 275 - return PreloadedVideoItem( 295 + contentWidget = PreloadedVideoItem( 276 296 key: ValueKey('video_$index'), 277 297 index: index, 278 298 controller: preloadedVideo.controller, ··· 323 343 }, 324 344 ); 325 345 } else { 326 - return VideoItem( 346 + contentWidget = VideoItem( 327 347 key: ValueKey('video_$index'), 328 348 index: index, 329 349 videoUrl: post.videoUrl, ··· 377 397 ); 378 398 } 379 399 } else if (post.imageUrls.isNotEmpty) { 380 - return ImagePostItem( 400 + contentWidget = ImagePostItem( 381 401 key: ValueKey('image_$index'), 382 402 index: index, 383 403 imageUrls: post.imageUrls, ··· 404 424 onHashtagTap: (String hashtag) {}, 405 425 ); 406 426 } else { 407 - return const Center(child: Text('Unsupported media type', style: TextStyle(color: Colors.white))); 427 + contentWidget = const Center(child: Text('Unsupported media type', style: TextStyle(color: Colors.white))); 428 + } 429 + 430 + // If content should show a warning, wrap it in a WarnBuilder 431 + if (shouldWarn && post.labels.isNotEmpty) { 432 + // Get the first label source (labelerDid) - in a more complete implementation, 433 + // we might want to show warnings from multiple labelers 434 + final labelerDid = Provider.of<LabelerManager>(context, listen: false).followedLabelers.firstOrNull ?? 'unknown'; 435 + 436 + // Get the first label value - same comment as above about multiple labels 437 + final labelValue = post.labels.first; 438 + 439 + // Get the blurType from the label definition 440 + final labelDefinitions = Provider.of<LabelerManager>(context, listen: false) 441 + .getLabelDefinitions(labelerDid); 442 + final labelDefinition = labelDefinitions[labelValue]; 443 + final String blurType = labelDefinition?['blurs'] as String? ?? 'content'; 444 + 445 + debugPrint("Content warning: $labelerDid, $labelValue, $warningMessage, blur: $blurType"); 446 + return WarnBuilder( 447 + labelerDid: labelerDid, 448 + labelValue: labelValue, 449 + warningMessage: warningMessage, 450 + blurType: blurType, 451 + child: contentWidget, 452 + ); 408 453 } 454 + 455 + // Otherwise, just return the content widget directly 456 + return contentWidget; 409 457 }, 410 458 ); 411 459 }
+9 -5
lib/screens/home_screen.dart
··· 82 82 _buildFeedScreens(); 83 83 _pageController.addListener(_onPageChanged); 84 84 85 - _selectedTabIndex = _currentFeedOptions.indexWhere((option) => option.value == _feedSettings.selectedFeedType); 85 + _selectedTabIndex = _currentFeedOptions.indexWhere((option) => option.value == _feedSettings.selectedFeedType.value); 86 86 if (_selectedTabIndex == -1 && _currentFeedOptions.isNotEmpty) { 87 87 _selectedTabIndex = 0; 88 88 } ··· 98 98 if (_pageController.page == null) return; 99 99 final currentPage = _pageController.page!.round(); 100 100 if (currentPage < _currentFeedOptions.length && currentPage != _selectedTabIndex) { 101 - _feedSettings.setSelectedFeedType(_currentFeedOptions[currentPage].value); 101 + _feedSettings.setSelectedFeedType(FeedType.fromValue(_currentFeedOptions[currentPage].value)); 102 102 } 103 103 } 104 104 ··· 120 120 }, 121 121 child: Scaffold( 122 122 backgroundColor: Colors.black, 123 + // TODO: why is the topbar not a topbar bro what the hell are we doing 123 124 body: Stack(children: [_buildMainContent(), _buildTopBar(topPadding, isDarkMode, _currentFeedOptions)]), 124 125 ), 125 126 ); ··· 185 186 _selectedTabIndex = index; 186 187 _buildFeedScreens(); 187 188 }); 188 - _feedSettings.setSelectedFeedType(_currentFeedOptions[index].value); 189 + _feedSettings.setSelectedFeedType(FeedType.fromValue(_currentFeedOptions[index].value)); 189 190 } 190 191 }, 191 192 ); ··· 241 242 if (_pageController.hasClients) { 242 243 await _pageController.animateToPage(index, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut); 243 244 } 244 - await _feedSettings.setSelectedFeedType(value); 245 + await _feedSettings.setSelectedFeedType(FeedType.fromValue(value)); 245 246 } 246 247 } 247 248 ··· 274 275 behavior: HitTestBehavior.opaque, 275 276 child: FeedSettingsSheet(feedSettings: feedSettings, onToggleChanged: _handleSettingToggle), 276 277 ), 277 - ); 278 + ).then((_) { 279 + // Rebuild feeds when the sheet is closed 280 + _onFeedSettingsChanged(); 281 + }); 278 282 } 279 283 280 284 Future<void> _handleSettingToggle(String settingType, bool isEnabled) async {
+1 -3
lib/services/actions_service.dart
··· 1 - import 'package:atproto/atproto.dart' as atp; 2 1 import 'package:atproto/core.dart'; 3 2 import 'package:flutter/foundation.dart'; 4 - import 'package:image/image.dart' as img; 5 3 import 'package:image/image.dart' as img; 6 4 import 'package:image_picker/image_picker.dart'; 7 5 ··· 162 160 163 161 return response.data; 164 162 } catch (e) { 165 - print('Error creating Spark image post record: $e'); 163 + debugPrint('Error creating Spark image post record: $e'); 166 164 rethrow; 167 165 } 168 166 }
+151 -4
lib/services/feed_manager.dart
··· 1 1 import 'package:atproto/core.dart'; 2 2 import 'package:bluesky/bluesky.dart'; 3 - 3 + import 'package:flutter/foundation.dart'; 4 + import 'package:sparksocial/services/label_service.dart'; 5 + import 'package:sparksocial/services/labeler_manager.dart'; 4 6 import '../models/feed_post.dart'; 5 7 import 'auth_service.dart'; 6 8 import 'sprk_client.dart'; ··· 9 11 static final FeedManager _instance = FeedManager._internal(); 10 12 factory FeedManager() => _instance; 11 13 FeedManager._internal(); 14 + 15 + // Referência ao LabelerManager 16 + LabelerManager? _labelerManager; 17 + 18 + /// Define o LabelerManager a ser usado para filtrar conteúdo 19 + void setLabelerManager(LabelerManager labelerManager) { 20 + _labelerManager = labelerManager; 21 + } 12 22 13 23 Future<List<FeedPost>> fetchFeed(int feedType, AuthService authService) async { 14 24 switch (feedType) { ··· 31 41 final allPosts = feed.data.feed.map((item) => FeedPost.fromBlueskyFeed(item)).toList(); 32 42 33 43 // Filter posts to only show those with media that aren't replies 34 - return allPosts.where((post) => post.hasMedia && !post.isReply).toList(); 44 + final filteredPosts = allPosts.where((post) => post.hasMedia && !post.isReply).toList(); 45 + 46 + // Fetch labels for posts 47 + await fetchLabelsForPosts(filteredPosts, authService); 48 + 49 + // Apply label preferences filtering if LabelerManager está disponível 50 + if (_labelerManager != null) { 51 + return _applyLabelPreferences(filteredPosts); 52 + } 53 + 54 + return filteredPosts; 35 55 } 36 56 37 57 Future<List<FeedPost>> _fetchForYouFeed(AuthService authService) async { ··· 45 65 final allPosts = feed.data.feed.map((item) => FeedPost.fromBlueskyFeed(item)).toList(); 46 66 47 67 // Filter posts to only show those with media that aren't replies 48 - return allPosts.where((post) => post.hasMedia && !post.isReply).toList(); 68 + final filteredPosts = allPosts.where((post) => post.hasMedia && !post.isReply).toList(); 69 + 70 + // Fetch labels for posts 71 + await fetchLabelsForPosts(filteredPosts, authService); 72 + 73 + // Apply label preferences filtering se LabelerManager estiver disponível 74 + if (_labelerManager != null) { 75 + return _applyLabelPreferences(filteredPosts); 76 + } 77 + 78 + return filteredPosts; 49 79 } 50 80 51 81 Future<List<FeedPost>> _fetchSparkNewFeed(AuthService authService) async { ··· 86 116 final feedItem = {'post': post}; 87 117 return FeedPost.fromSparkFeed(feedItem); 88 118 }).toList(); 119 + 120 + // Fetch labels for the retrieved posts 121 + await fetchLabelsForPosts(allFeedPosts, authService); 89 122 90 123 // Filter posts to only show those with media that aren't replies 91 - return allFeedPosts.where((post) => post.hasMedia && !post.isReply).toList(); 124 + final filteredPosts = allFeedPosts.where((post) => post.hasMedia && !post.isReply).toList(); 125 + 126 + // Apply label preferences filtering se LabelerManager estiver disponível 127 + if (_labelerManager != null) { 128 + return _applyLabelPreferences(filteredPosts); 129 + } 130 + 131 + return filteredPosts; 132 + } 133 + 134 + /// Update labels for a list of posts at once 135 + Future<void> fetchLabelsForPosts(List<FeedPost> posts, AuthService authService) async { 136 + if (posts.isEmpty) return; 137 + 138 + final labelService = LabelService(authService, serviceUrl: 'https://pds.sprk.so'); 139 + 140 + try { 141 + // Collect all URIs for a single query 142 + final uriPatterns = posts.map((post) => post.uri).toList(); 143 + 144 + // Obter a lista de labelers seguidos pelo usuário 145 + // Se _labelerManager for nulo, use o labeler padrão como fallback 146 + List<String> labelerSources = _labelerManager?.followedLabelers ?? [LabelerManager.defaultLabelerDid]; 147 + 148 + // Use the LabelService to get detailed label information grouped by URI 149 + final labelsByUri = await labelService.getLabelsWithDetails( 150 + uriPatterns: uriPatterns, 151 + sources: labelerSources, 152 + ); 153 + 154 + // Update each post with its specific labels 155 + for (final post in posts) { 156 + if (labelsByUri.containsKey(post.uri)) { 157 + // Create a new list for labels 158 + final labelValues = labelsByUri[post.uri]! 159 + .map((label) => label['val'] as String) 160 + .toList(); 161 + 162 + // Update the post's labels - use reflection to set the property directly 163 + // or create a copy of the post with updated labels 164 + try { 165 + post.setLabels(labelValues); 166 + debugPrint('Post ${post.uri} has labels: ${post.labels}'); 167 + } catch (e) { 168 + debugPrint('Could not update labels for post ${post.uri}: $e'); 169 + } 170 + } 171 + } 172 + } catch (e) { 173 + debugPrint('Error fetching labels for multiple posts: $e'); 174 + } 175 + } 176 + 177 + /// Apply label preferences to filter the posts 178 + List<FeedPost> _applyLabelPreferences(List<FeedPost> posts) { 179 + try { 180 + if (_labelerManager == null) { 181 + return posts; 182 + } 183 + 184 + // First remove any posts that should be hidden based on label preferences 185 + final visiblePosts = posts.where((post) { 186 + // If the post has no labels, it's always visible 187 + if (post.labels.isEmpty) return true; 188 + 189 + // Special label '!hide' always hides the post regardless of other settings 190 + if (post.labels.contains('!hide')) { 191 + debugPrint('Post ${post.uri} has !hide label and will be hidden'); 192 + return false; 193 + } 194 + 195 + // Check if any label should hide this post based on preferences 196 + bool shouldHide = _labelerManager!.shouldHideContent(post.labels); 197 + if (shouldHide) { 198 + debugPrint('Post ${post.uri} should be hidden based on label preferences'); 199 + } 200 + return !shouldHide; 201 + }).toList(); 202 + 203 + // Return the filtered list 204 + return visiblePosts; 205 + } catch (e) { 206 + // If there's any error, just return the original posts 207 + debugPrint('Error applying label preferences: $e'); 208 + return posts; 209 + } 210 + } 211 + 212 + /// Check if a post should show a warning based on its labels 213 + bool shouldWarnContent(FeedPost post) { 214 + if (post.labels.isEmpty || _labelerManager == null) return false; 215 + 216 + try { 217 + // Special label '!warn' always shows a warning regardless of other settings 218 + if (post.labels.contains('!warn')) { 219 + return true; 220 + } 221 + 222 + return _labelerManager!.shouldWarnContent(post.labels); 223 + } catch (e) { 224 + debugPrint('Error checking if content should be warned: $e'); 225 + return false; 226 + } 227 + } 228 + 229 + /// Get warning messages for a post 230 + List<String> getWarningMessages(FeedPost post) { 231 + if (post.labels.isEmpty || _labelerManager == null) return []; 232 + 233 + try { 234 + return _labelerManager!.getWarningMessages(post.labels); 235 + } catch (e) { 236 + debugPrint('Error getting warning messages: $e'); 237 + return []; 238 + } 92 239 } 93 240 }
+33 -27
lib/services/feed_settings_service.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:shared_preferences/shared_preferences.dart'; 3 3 4 - class FeedType { 5 - static const int following = 0; 6 - static const int forYou = 1; 7 - static const int latest = 2; 4 + // this whole file will need to be refactored when we add modular feed types 5 + // for now, I just transformed the gambiarra enum class into an actual enum 6 + // when we add modular feed types, this enum will be replaced by a class 7 + enum FeedType { 8 + following(0, 'Following'), 9 + forYou(1, 'For You'), 10 + latest(2, 'Latest'); 11 + 12 + final int value; 13 + final String name; 14 + 15 + const FeedType(this.value, this.name); 16 + 17 + static FeedType fromValue(int value) { 18 + return FeedType.values.firstWhere((feedType) => feedType.value == value, orElse: () => FeedType.forYou); 19 + } 8 20 } 9 21 10 22 class FeedSettingsService extends ChangeNotifier { ··· 19 31 static const String _keyLatestFeed = 'latest_feed_enabled'; 20 32 static const String _keyDisableBlur = 'disable_background_blur'; 21 33 static const String _keySelectedFeed = 'selected_feed_type'; 34 + static const String _keyDisableNsfwContent = 'disable_nsfw_content'; 35 + // there should be a key for each label of each labeler 36 + // for now, we'll just use the default labels 22 37 23 38 // Feed states 24 39 bool _followingFeedEnabled = true; 25 40 bool _forYouFeedEnabled = true; 26 41 bool _latestFeedEnabled = true; 27 42 bool _disableVideoBackgroundBlur = false; 28 - int _selectedFeedType = FeedType.forYou; 43 + FeedType _selectedFeedType = FeedType.forYou; 44 + bool _disableNsfwContent = true; 29 45 30 46 // Getters 31 47 bool get followingFeedEnabled => _followingFeedEnabled; 32 48 bool get forYouFeedEnabled => _forYouFeedEnabled; 33 49 bool get latestFeedEnabled => _latestFeedEnabled; 34 50 bool get disableVideoBackgroundBlur => _disableVideoBackgroundBlur; 35 - int get selectedFeedType => _selectedFeedType; 51 + FeedType get selectedFeedType => _selectedFeedType; 52 + bool get disableNsfwContent => _disableNsfwContent; 36 53 37 54 Future<void> loadPreferences() async { 38 55 try { ··· 42 59 _forYouFeedEnabled = prefs.getBool(_keyForYouFeed) ?? true; 43 60 _latestFeedEnabled = prefs.getBool(_keyLatestFeed) ?? true; 44 61 _disableVideoBackgroundBlur = prefs.getBool(_keyDisableBlur) ?? false; 45 - _selectedFeedType = prefs.getInt(_keySelectedFeed) ?? FeedType.forYou; 62 + _selectedFeedType = FeedType.values[prefs.getInt(_keySelectedFeed) ?? FeedType.forYou.value]; 63 + _disableNsfwContent = prefs.getBool(_keyDisableNsfwContent) ?? true; 46 64 47 65 // Make sure selected feed is enabled 48 66 if (!isSelectedFeedEnabled()) { ··· 62 80 await prefs.setBool(_keyForYouFeed, _forYouFeedEnabled); 63 81 await prefs.setBool(_keyLatestFeed, _latestFeedEnabled); 64 82 await prefs.setBool(_keyDisableBlur, _disableVideoBackgroundBlur); 65 - await prefs.setInt(_keySelectedFeed, _selectedFeedType); 83 + await prefs.setInt(_keySelectedFeed, _selectedFeedType.value); 84 + await prefs.setBool(_keyDisableNsfwContent, _disableNsfwContent); 66 85 notifyListeners(); 67 86 } catch (e) { 68 87 // Silently handle preference save errors ··· 110 129 111 130 // Don't allow disabling the currently selected feed 112 131 final feedTypeIndex = getFeedTypeFromSetting(settingType); 113 - return feedTypeIndex != _selectedFeedType; 132 + return feedTypeIndex != _selectedFeedType.value; 114 133 } 115 134 116 135 Future<void> toggleFeed(String settingType, bool isEnabled) async { ··· 140 159 notifyListeners(); 141 160 } 142 161 143 - Future<void> setSelectedFeedType(int feedType) async { 162 + Future<void> setSelectedFeedType(FeedType feedType) async { 144 163 _selectedFeedType = feedType; 145 164 await savePreferences(); 146 165 notifyListeners(); ··· 149 168 int getFeedTypeFromSetting(String settingType) { 150 169 switch (settingType) { 151 170 case 'following_feed': 152 - return FeedType.following; 171 + return FeedType.following.value; 153 172 case 'for_you_feed': 154 - return FeedType.forYou; 173 + return FeedType.forYou.value; 155 174 case 'latest_feed': 156 - return FeedType.latest; 157 - default: 158 - return FeedType.forYou; 159 - } 160 - } 161 - 162 - String getFeedNameFromType(int feedType) { 163 - switch (feedType) { 164 - case FeedType.following: 165 - return 'Following'; 166 - case FeedType.forYou: 167 - return 'For You'; 168 - case FeedType.latest: 169 - return 'Latest'; 175 + return FeedType.latest.value; 170 176 default: 171 - return 'For You'; 177 + return FeedType.forYou.value; 172 178 } 173 179 } 174 180 }
+1075
lib/services/label_service.dart
··· 1 + import 'dart:convert'; 2 + import 'package:atproto/atproto.dart'; 3 + import 'package:atproto/core.dart'; 4 + import 'auth_service.dart'; 5 + 6 + /// Service for handling label-related operations 7 + class LabelService { 8 + final AuthService _authService; 9 + // labeler did 10 + // if null, we use the sprk pds, which calls the sprk labeler in the backend 11 + final String? did; 12 + final String serviceUrl; 13 + List<String> labelValues = []; 14 + List<Map<String, dynamic>> labelValueDefinitions = []; 15 + 16 + // Instance cache for each labeler 17 + static final Map<String, LabelService> _instances = {}; 18 + 19 + /// Default constructor 20 + LabelService(this._authService, {this.did = 'did:plc:pbgyr67hftvpoqtvaurpsctc', this.serviceUrl = 'https://pds.sprk.so'}); 21 + 22 + /// Gets or creates a LabelService instance for a specific labeler 23 + /// 24 + /// [authService] The authentication service to be used 25 + /// [labelerDid] The DID of the labeler 26 + /// [serviceUrl] Optional service URL (PDS) that hosts the labeler 27 + static LabelService forLabeler( 28 + AuthService authService, 29 + String labelerDid, 30 + {String serviceUrl = 'https://pds.sprk.so'} 31 + ) { 32 + // If we already have an instance for this labeler, return it 33 + if (_instances.containsKey(labelerDid)) { 34 + return _instances[labelerDid]!; 35 + } 36 + 37 + // Otherwise, create a new instance 38 + final service = LabelService(authService, did: labelerDid, serviceUrl: serviceUrl); 39 + _instances[labelerDid] = service; 40 + return service; 41 + } 42 + 43 + /// Clears the instance cache 44 + static void clearCache() { 45 + _instances.clear(); 46 + } 47 + 48 + ATProto? get _atproto => _authService.atproto; 49 + 50 + /// Fetches all available label values from the labeler 51 + /// 52 + /// This uses the getLabelValues endpoint defined by the labeler 53 + Future<List<String>> fetchLabelValues() async { 54 + final client = _atproto; 55 + if (client == null) { 56 + throw Exception('ATProto client not available'); 57 + } 58 + 59 + try { 60 + // Configure header to use the proxy for the labeler 61 + final Map<String, String> headers = {}; 62 + if (did != null) { 63 + headers['atproto-proxy'] = '$did#atproto_labeler'; 64 + } 65 + 66 + final responseData = await client.get( 67 + NSID.parse('com.atproto.label.getLabelValues'), 68 + headers: headers, 69 + to: (json) => json as Map<String, dynamic>, 70 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 71 + ); 72 + 73 + // Update the local cache - create a new list instead of clearing the existing one 74 + final values = List<String>.from(responseData.data['values'] ?? []); 75 + labelValues = values; 76 + 77 + return values; 78 + } catch (e) { 79 + // Check if this is a 501 Method Not Implemented error 80 + if (e.toString().contains('501 Method Not Implemented')) { 81 + // For default labeler, return default values 82 + if (did == 'did:plc:pbgyr67hftvpoqtvaurpsctc') { 83 + labelValues = [ 84 + '!hide', 85 + '!warn', 86 + 'porn', 87 + 'sexual', 88 + 'nudity', 89 + 'sexual-figurative', 90 + 'graphic-media', 91 + 'self-harm', 92 + 'sensitive', 93 + 'extremist', 94 + 'intolerant', 95 + 'threat', 96 + 'rude', 97 + 'illicit', 98 + 'security', 99 + 'unsafe-link', 100 + 'impersonation', 101 + 'misinformation', 102 + 'scam', 103 + 'engagement-farming', 104 + 'spam', 105 + 'rumor', 106 + 'misleading', 107 + 'inauthentic', 108 + ]; 109 + return labelValues; 110 + } 111 + } 112 + throw Exception('Error fetching label values: $e'); 113 + } 114 + } 115 + 116 + /// Fetches detailed definitions for all label values 117 + /// 118 + /// This uses the getLabelValueDefinitions endpoint defined by the labeler 119 + Future<List<Map<String, dynamic>>> fetchLabelValueDefinitions() async { 120 + final client = _atproto; 121 + if (client == null) { 122 + throw Exception('ATProto client not available'); 123 + } 124 + 125 + try { 126 + // Configure header to use the proxy for the labeler 127 + final Map<String, String> headers = {}; 128 + if (did != null) { 129 + headers['atproto-proxy'] = '$did#atproto_labeler'; 130 + } 131 + 132 + final responseData = await client.get( 133 + NSID.parse('com.atproto.label.getLabelValueDefinitions'), 134 + headers: headers, 135 + to: (json) => json as Map<String, dynamic>, 136 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 137 + ); 138 + 139 + // Extract and convert the definitions 140 + final definitions = List<Map<String, dynamic>>.from(responseData.data['definitions'] ?? []); 141 + 142 + // Update the local cache - create a new list instead of clearing the existing one 143 + labelValueDefinitions = definitions; 144 + 145 + return definitions; 146 + } catch (e) { 147 + // Check if this is a 501 Method Not Implemented error 148 + if (e.toString().contains('501 Method Not Implemented')) { 149 + // For default labeler, return default definitions 150 + if (did == 'did:plc:pbgyr67hftvpoqtvaurpsctc') { 151 + final List<Map<String, dynamic>> definitions = [ 152 + { 153 + 'value': 'spam', 154 + 'identifier': 'spam', 155 + 'blurs': 'content', 156 + 'severity': 'inform', 157 + 'defaultSetting': 'hide', 158 + 'adultOnly': false, 159 + 'locales': [ 160 + { 161 + 'lang': 'en', 162 + 'name': 'Spam', 163 + 'description': 'Unwanted, repeated, or unrelated actions that bother users.', 164 + }, 165 + ], 166 + }, 167 + { 168 + 'value': 'impersonation', 169 + 'identifier': 'impersonation', 170 + 'blurs': 'none', 171 + 'severity': 'inform', 172 + 'defaultSetting': 'hide', 173 + 'adultOnly': false, 174 + 'locales': [ 175 + { 176 + 'lang': 'en', 177 + 'name': 'Impersonation', 178 + 'description': 'Pretending to be someone else without permission.', 179 + }, 180 + ], 181 + }, 182 + { 183 + 'value': 'scam', 184 + 'identifier': 'scam', 185 + 'blurs': 'content', 186 + 'severity': 'alert', 187 + 'defaultSetting': 'hide', 188 + 'adultOnly': false, 189 + 'locales': [ 190 + { 191 + 'lang': 'en', 192 + 'name': 'Scam', 193 + 'description': 'Scams, phishing & fraud.', 194 + }, 195 + ], 196 + }, 197 + { 198 + 'value': 'intolerant', 199 + 'identifier': 'intolerant', 200 + 'blurs': 'content', 201 + 'severity': 'alert', 202 + 'defaultSetting': 'warn', 203 + 'adultOnly': false, 204 + 'locales': [ 205 + { 206 + 'lang': 'en', 207 + 'name': 'Intolerance', 208 + 'description': 'Discrimination against protected groups.', 209 + }, 210 + ], 211 + }, 212 + { 213 + 'value': 'self-harm', 214 + 'identifier': 'self-harm', 215 + 'blurs': 'content', 216 + 'severity': 'alert', 217 + 'defaultSetting': 'warn', 218 + 'adultOnly': false, 219 + 'locales': [ 220 + { 221 + 'lang': 'en', 222 + 'name': 'Self-Harm', 223 + 'description': 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.', 224 + }, 225 + ], 226 + }, 227 + { 228 + 'value': 'security', 229 + 'identifier': 'security', 230 + 'blurs': 'content', 231 + 'severity': 'alert', 232 + 'defaultSetting': 'hide', 233 + 'adultOnly': false, 234 + 'locales': [ 235 + { 236 + 'lang': 'en', 237 + 'name': 'Security Concerns', 238 + 'description': 'May be unsafe and could harm your device, steal your info, or get your account hacked.', 239 + }, 240 + ], 241 + }, 242 + { 243 + 'value': 'misleading', 244 + 'identifier': 'misleading', 245 + 'blurs': 'content', 246 + 'severity': 'alert', 247 + 'defaultSetting': 'warn', 248 + 'adultOnly': false, 249 + 'locales': [ 250 + { 251 + 'lang': 'en', 252 + 'name': 'Misleading', 253 + 'description': 'Altered images/videos, deceptive links, or false statements.', 254 + }, 255 + ], 256 + }, 257 + { 258 + 'value': 'threat', 259 + 'identifier': 'threat', 260 + 'blurs': 'content', 261 + 'severity': 'inform', 262 + 'defaultSetting': 'hide', 263 + 'adultOnly': false, 264 + 'locales': [ 265 + { 266 + 'lang': 'en', 267 + 'name': 'Threats', 268 + 'description': 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.', 269 + }, 270 + ], 271 + }, 272 + { 273 + 'value': 'unsafe-link', 274 + 'identifier': 'unsafe-link', 275 + 'blurs': 'content', 276 + 'severity': 'alert', 277 + 'defaultSetting': 'hide', 278 + 'adultOnly': false, 279 + 'locales': [ 280 + { 281 + 'lang': 'en', 282 + 'name': 'Unsafe link', 283 + 'description': 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.', 284 + }, 285 + ], 286 + }, 287 + { 288 + 'value': 'illicit', 289 + 'identifier': 'illicit', 290 + 'blurs': 'content', 291 + 'severity': 'alert', 292 + 'defaultSetting': 'hide', 293 + 'adultOnly': false, 294 + 'locales': [ 295 + { 296 + 'lang': 'en', 297 + 'name': 'Illicit', 298 + 'description': 'Promoting or selling potentially illicit goods, services, or activities.', 299 + }, 300 + ], 301 + }, 302 + { 303 + 'value': 'misinformation', 304 + 'identifier': 'misinformation', 305 + 'blurs': 'content', 306 + 'severity': 'inform', 307 + 'defaultSetting': 'warn', 308 + 'adultOnly': false, 309 + 'locales': [ 310 + { 311 + 'lang': 'en', 312 + 'name': 'Misinformation', 313 + 'description': 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.', 314 + }, 315 + ], 316 + }, 317 + { 318 + 'value': 'rumor', 319 + 'identifier': 'rumor', 320 + 'blurs': 'content', 321 + 'severity': 'inform', 322 + 'defaultSetting': 'warn', 323 + 'adultOnly': false, 324 + 'locales': [ 325 + { 326 + 'lang': 'en', 327 + 'name': 'Rumor', 328 + 'description': 'Approach with caution, as these claims lack evidence from credible sources.', 329 + }, 330 + ], 331 + }, 332 + { 333 + 'value': 'rude', 334 + 'identifier': 'rude', 335 + 'blurs': 'content', 336 + 'severity': 'inform', 337 + 'defaultSetting': 'hide', 338 + 'adultOnly': false, 339 + 'locales': [ 340 + { 341 + 'lang': 'en', 342 + 'name': 'Rude', 343 + 'description': 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.', 344 + }, 345 + ], 346 + }, 347 + { 348 + 'value': 'extremist', 349 + 'identifier': 'extremist', 350 + 'blurs': 'content', 351 + 'severity': 'alert', 352 + 'defaultSetting': 'hide', 353 + 'adultOnly': false, 354 + 'locales': [ 355 + { 356 + 'lang': 'en', 357 + 'name': 'Extremist', 358 + 'description': 'Radical views advocating violence, hate, or discrimination against individuals or groups.', 359 + }, 360 + ], 361 + }, 362 + { 363 + 'value': 'sensitive', 364 + 'identifier': 'sensitive', 365 + 'blurs': 'content', 366 + 'severity': 'alert', 367 + 'defaultSetting': 'warn', 368 + 'adultOnly': false, 369 + 'locales': [ 370 + { 371 + 'lang': 'en', 372 + 'name': 'Sensitive', 373 + 'description': 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.', 374 + }, 375 + ], 376 + }, 377 + { 378 + 'value': 'engagement-farming', 379 + 'identifier': 'engagement-farming', 380 + 'blurs': 'content', 381 + 'severity': 'alert', 382 + 'defaultSetting': 'hide', 383 + 'adultOnly': false, 384 + 'locales': [ 385 + { 386 + 'lang': 'en', 387 + 'name': 'Engagement Farming', 388 + 'description': 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.', 389 + }, 390 + ], 391 + }, 392 + { 393 + 'value': 'inauthentic', 394 + 'identifier': 'inauthentic', 395 + 'blurs': 'content', 396 + 'severity': 'alert', 397 + 'defaultSetting': 'hide', 398 + 'adultOnly': false, 399 + 'locales': [ 400 + { 401 + 'lang': 'en', 402 + 'name': 'Inauthentic Account', 403 + 'description': 'Bot or a person pretending to be someone else.', 404 + }, 405 + ], 406 + }, 407 + { 408 + 'value': 'sexual-figurative', 409 + 'identifier': 'sexual-figurative', 410 + 'blurs': 'media', 411 + 'severity': 'none', 412 + 'defaultSetting': 'show', 413 + 'adultOnly': true, 414 + 'locales': [ 415 + { 416 + 'lang': 'en', 417 + 'name': 'Sexually Suggestive (Cartoon)', 418 + 'description': 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.', 419 + }, 420 + ], 421 + }, 422 + { 423 + 'value': 'porn', 424 + 'identifier': 'porn', 425 + 'blurs': 'content', 426 + 'severity': 'alert', 427 + 'defaultSetting': 'hide', 428 + 'adultOnly': true, 429 + 'locales': [ 430 + { 431 + 'lang': 'en', 432 + 'name': 'Explicit Content', 433 + 'description': 'Pornographic or sexually explicit material', 434 + }, 435 + ], 436 + }, 437 + { 438 + 'value': 'nudity', 439 + 'identifier': 'nudity', 440 + 'blurs': 'content', 441 + 'severity': 'alert', 442 + 'defaultSetting': 'warn', 443 + 'adultOnly': true, 444 + 'locales': [ 445 + { 446 + 'lang': 'en', 447 + 'name': 'Nudity', 448 + 'description': 'Content containing nudity', 449 + }, 450 + ], 451 + }, 452 + { 453 + 'value': 'sexual', 454 + 'identifier': 'sexual', 455 + 'blurs': 'content', 456 + 'severity': 'alert', 457 + 'defaultSetting': 'warn', 458 + 'adultOnly': true, 459 + 'locales': [ 460 + { 461 + 'lang': 'en', 462 + 'name': 'Sexual Content', 463 + 'description': 'Content of a sexual nature', 464 + }, 465 + ], 466 + }, 467 + { 468 + 'value': 'graphic-media', 469 + 'identifier': 'graphic-media', 470 + 'blurs': 'content', 471 + 'severity': 'alert', 472 + 'defaultSetting': 'warn', 473 + 'adultOnly': false, 474 + 'locales': [ 475 + { 476 + 'lang': 'en', 477 + 'name': 'Graphic Content', 478 + 'description': 'Disturbing or graphic imagery', 479 + }, 480 + ], 481 + }, 482 + ]; 483 + 484 + labelValueDefinitions = definitions; 485 + return definitions; 486 + } 487 + } 488 + throw Exception('Error fetching label definitions: $e'); 489 + } 490 + } 491 + 492 + /// Gets metadata about the labeler 493 + /// 494 + /// Returns information such as name, description, avatar, and associated URLs 495 + Future<Map<String, dynamic>> getLabelerInfo() async { 496 + final client = _atproto; 497 + if (client == null) { 498 + throw Exception('ATProto client not available'); 499 + } 500 + 501 + try { 502 + // Configure header to use the proxy for the labeler 503 + final Map<String, String> headers = {}; 504 + if (did != null) { 505 + headers['atproto-proxy'] = '$did#atproto_labeler'; 506 + } 507 + 508 + try { 509 + final responseData = await client.get( 510 + NSID.parse('com.atproto.label.getLabelerInfo'), 511 + headers: headers, 512 + to: (json) => json as Map<String, dynamic>, 513 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 514 + ); 515 + 516 + return responseData.data; 517 + } catch (apiError) { 518 + // Check if this is a 501 Method Not Implemented error 519 + if (apiError.toString().contains('501 Method Not Implemented')) { 520 + // Fallback for default labeler 521 + if (did == 'did:plc:pbgyr67hftvpoqtvaurpsctc') { 522 + return { 523 + 'did': did, 524 + 'displayName': 'Default Labeler', 525 + 'description': 'System default content labeler' 526 + }; 527 + } else { 528 + // Generic fallback for other labelers 529 + return { 530 + 'did': did, 531 + 'displayName': 'Labeler ${did?.substring(0, 10)}...', 532 + 'description': 'Content labeler' 533 + }; 534 + } 535 + } 536 + // For other API errors, rethrow 537 + rethrow; 538 + } 539 + } catch (e) { 540 + throw Exception('Error fetching labeler info: $e'); 541 + } 542 + } 543 + 544 + /// Get all available labels from this labeler with their definitions 545 + /// 546 + /// Returns a map of label values to their definitions 547 + Future<Map<String, Map<String, dynamic>>> getAllLabelsWithDefinitions() async { 548 + // Create a map of label values to their definitions 549 + final Map<String, Map<String, dynamic>> result = {}; 550 + 551 + try { 552 + // Try to fetch the latest values and definitions 553 + try { 554 + await fetchLabelValues(); 555 + await fetchLabelValueDefinitions(); 556 + 557 + for (final definition in labelValueDefinitions) { 558 + final String value = definition['value'] as String; 559 + result[value] = definition; 560 + } 561 + } catch (apiError) { 562 + // If we can't fetch (501 or other API errors), use fallbacks for default labeler 563 + if (did == 'did:plc:pbgyr67hftvpoqtvaurpsctc') { 564 + // Default fallback labels for the default labeler 565 + _addDefaultLabels(result); 566 + } 567 + } 568 + 569 + return result; 570 + } catch (e) { 571 + // Final fallback if everything fails for the default labeler 572 + if (did == 'did:plc:pbgyr67hftvpoqtvaurpsctc') { 573 + _addDefaultLabels(result); 574 + } 575 + 576 + return result; 577 + } 578 + } 579 + 580 + /// Adds default label definitions as a fallback 581 + void _addDefaultLabels(Map<String, Map<String, dynamic>> result) { 582 + result['spam'] = { 583 + 'value': 'spam', 584 + 'identifier': 'spam', 585 + 'blurs': 'content', 586 + 'severity': 'inform', 587 + 'defaultSetting': 'hide', 588 + 'adultOnly': false, 589 + 'locales': [ 590 + { 591 + 'lang': 'en', 592 + 'name': 'Spam', 593 + 'description': 'Unwanted, repeated, or unrelated actions that bother users.', 594 + }, 595 + ], 596 + 'displayName': 'Spam', 597 + 'description': 'Unwanted, repeated, or unrelated actions that bother users.' 598 + }; 599 + result['impersonation'] = { 600 + 'value': 'impersonation', 601 + 'identifier': 'impersonation', 602 + 'blurs': 'none', 603 + 'severity': 'inform', 604 + 'defaultSetting': 'hide', 605 + 'adultOnly': false, 606 + 'locales': [ 607 + { 608 + 'lang': 'en', 609 + 'name': 'Impersonation', 610 + 'description': 'Pretending to be someone else without permission.', 611 + }, 612 + ], 613 + 'displayName': 'Impersonation', 614 + 'description': 'Pretending to be someone else without permission.' 615 + }; 616 + result['scam'] = { 617 + 'value': 'scam', 618 + 'identifier': 'scam', 619 + 'blurs': 'content', 620 + 'severity': 'alert', 621 + 'defaultSetting': 'hide', 622 + 'adultOnly': false, 623 + 'locales': [ 624 + { 625 + 'lang': 'en', 626 + 'name': 'Scam', 627 + 'description': 'Scams, phishing & fraud.', 628 + }, 629 + ], 630 + 'displayName': 'Scam', 631 + 'description': 'Scams, phishing & fraud.' 632 + }; 633 + result['intolerant'] = { 634 + 'value': 'intolerant', 635 + 'identifier': 'intolerant', 636 + 'blurs': 'content', 637 + 'severity': 'alert', 638 + 'defaultSetting': 'warn', 639 + 'adultOnly': false, 640 + 'locales': [ 641 + { 642 + 'lang': 'en', 643 + 'name': 'Intolerance', 644 + 'description': 'Discrimination against protected groups.', 645 + }, 646 + ], 647 + 'displayName': 'Intolerance', 648 + 'description': 'Discrimination against protected groups.' 649 + }; 650 + result['self-harm'] = { 651 + 'value': 'self-harm', 652 + 'identifier': 'self-harm', 653 + 'blurs': 'content', 654 + 'severity': 'alert', 655 + 'defaultSetting': 'warn', 656 + 'adultOnly': false, 657 + 'locales': [ 658 + { 659 + 'lang': 'en', 660 + 'name': 'Self-Harm', 661 + 'description': 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.', 662 + }, 663 + ], 664 + 'displayName': 'Self-Harm', 665 + 'description': 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.' 666 + }; 667 + result['security'] = { 668 + 'value': 'security', 669 + 'identifier': 'security', 670 + 'blurs': 'content', 671 + 'severity': 'alert', 672 + 'defaultSetting': 'hide', 673 + 'adultOnly': false, 674 + 'locales': [ 675 + { 676 + 'lang': 'en', 677 + 'name': 'Security Concerns', 678 + 'description': 'May be unsafe and could harm your device, steal your info, or get your account hacked.', 679 + }, 680 + ], 681 + 'displayName': 'Security Concerns', 682 + 'description': 'May be unsafe and could harm your device, steal your info, or get your account hacked.' 683 + }; 684 + result['misleading'] = { 685 + 'value': 'misleading', 686 + 'identifier': 'misleading', 687 + 'blurs': 'content', 688 + 'severity': 'alert', 689 + 'defaultSetting': 'warn', 690 + 'adultOnly': false, 691 + 'locales': [ 692 + { 693 + 'lang': 'en', 694 + 'name': 'Misleading', 695 + 'description': 'Altered images/videos, deceptive links, or false statements.', 696 + }, 697 + ], 698 + 'displayName': 'Misleading', 699 + 'description': 'Altered images/videos, deceptive links, or false statements.' 700 + }; 701 + result['threat'] = { 702 + 'value': 'threat', 703 + 'identifier': 'threat', 704 + 'blurs': 'content', 705 + 'severity': 'inform', 706 + 'defaultSetting': 'hide', 707 + 'adultOnly': false, 708 + 'locales': [ 709 + { 710 + 'lang': 'en', 711 + 'name': 'Threats', 712 + 'description': 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.', 713 + }, 714 + ], 715 + 'displayName': 'Threats', 716 + 'description': 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.' 717 + }; 718 + result['unsafe-link'] = { 719 + 'value': 'unsafe-link', 720 + 'identifier': 'unsafe-link', 721 + 'blurs': 'content', 722 + 'severity': 'alert', 723 + 'defaultSetting': 'hide', 724 + 'adultOnly': false, 725 + 'locales': [ 726 + { 727 + 'lang': 'en', 728 + 'name': 'Unsafe link', 729 + 'description': 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.', 730 + }, 731 + ], 732 + 'displayName': 'Unsafe link', 733 + 'description': 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.' 734 + }; 735 + result['illicit'] = { 736 + 'value': 'illicit', 737 + 'identifier': 'illicit', 738 + 'blurs': 'content', 739 + 'severity': 'alert', 740 + 'defaultSetting': 'hide', 741 + 'adultOnly': false, 742 + 'locales': [ 743 + { 744 + 'lang': 'en', 745 + 'name': 'Illicit', 746 + 'description': 'Promoting or selling potentially illicit goods, services, or activities.', 747 + }, 748 + ], 749 + 'displayName': 'Illicit', 750 + 'description': 'Promoting or selling potentially illicit goods, services, or activities.' 751 + }; 752 + result['misinformation'] = { 753 + 'value': 'misinformation', 754 + 'identifier': 'misinformation', 755 + 'blurs': 'content', 756 + 'severity': 'inform', 757 + 'defaultSetting': 'warn', 758 + 'adultOnly': false, 759 + 'locales': [ 760 + { 761 + 'lang': 'en', 762 + 'name': 'Misinformation', 763 + 'description': 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.', 764 + }, 765 + ], 766 + 'displayName': 'Misinformation', 767 + 'description': 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.' 768 + }; 769 + result['rumor'] = { 770 + 'value': 'rumor', 771 + 'identifier': 'rumor', 772 + 'blurs': 'content', 773 + 'severity': 'inform', 774 + 'defaultSetting': 'warn', 775 + 'adultOnly': false, 776 + 'locales': [ 777 + { 778 + 'lang': 'en', 779 + 'name': 'Rumor', 780 + 'description': 'Approach with caution, as these claims lack evidence from credible sources.', 781 + }, 782 + ], 783 + 'displayName': 'Rumor', 784 + 'description': 'Approach with caution, as these claims lack evidence from credible sources.' 785 + }; 786 + result['rude'] = { 787 + 'value': 'rude', 788 + 'identifier': 'rude', 789 + 'blurs': 'content', 790 + 'severity': 'inform', 791 + 'defaultSetting': 'hide', 792 + 'adultOnly': false, 793 + 'locales': [ 794 + { 795 + 'lang': 'en', 796 + 'name': 'Rude', 797 + 'description': 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.', 798 + }, 799 + ], 800 + 'displayName': 'Rude', 801 + 'description': 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.' 802 + }; 803 + result['extremist'] = { 804 + 'value': 'extremist', 805 + 'identifier': 'extremist', 806 + 'blurs': 'content', 807 + 'severity': 'alert', 808 + 'defaultSetting': 'hide', 809 + 'adultOnly': false, 810 + 'locales': [ 811 + { 812 + 'lang': 'en', 813 + 'name': 'Extremist', 814 + 'description': 'Radical views advocating violence, hate, or discrimination against individuals or groups.', 815 + }, 816 + ], 817 + 'displayName': 'Extremist', 818 + 'description': 'Radical views advocating violence, hate, or discrimination against individuals or groups.' 819 + }; 820 + result['sensitive'] = { 821 + 'value': 'sensitive', 822 + 'identifier': 'sensitive', 823 + 'blurs': 'content', 824 + 'severity': 'alert', 825 + 'defaultSetting': 'warn', 826 + 'adultOnly': false, 827 + 'locales': [ 828 + { 829 + 'lang': 'en', 830 + 'name': 'Sensitive', 831 + 'description': 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.', 832 + }, 833 + ], 834 + 'displayName': 'Sensitive', 835 + 'description': 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.' 836 + }; 837 + result['engagement-farming'] = { 838 + 'value': 'engagement-farming', 839 + 'identifier': 'engagement-farming', 840 + 'blurs': 'content', 841 + 'severity': 'alert', 842 + 'defaultSetting': 'hide', 843 + 'adultOnly': false, 844 + 'locales': [ 845 + { 846 + 'lang': 'en', 847 + 'name': 'Engagement Farming', 848 + 'description': 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.', 849 + }, 850 + ], 851 + 'displayName': 'Engagement Farming', 852 + 'description': 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.' 853 + }; 854 + result['inauthentic'] = { 855 + 'value': 'inauthentic', 856 + 'identifier': 'inauthentic', 857 + 'blurs': 'content', 858 + 'severity': 'alert', 859 + 'defaultSetting': 'hide', 860 + 'adultOnly': false, 861 + 'locales': [ 862 + { 863 + 'lang': 'en', 864 + 'name': 'Inauthentic Account', 865 + 'description': 'Bot or a person pretending to be someone else.', 866 + }, 867 + ], 868 + 'displayName': 'Inauthentic Account', 869 + 'description': 'Bot or a person pretending to be someone else.' 870 + }; 871 + result['sexual-figurative'] = { 872 + 'value': 'sexual-figurative', 873 + 'identifier': 'sexual-figurative', 874 + 'blurs': 'media', 875 + 'severity': 'none', 876 + 'defaultSetting': 'show', 877 + 'adultOnly': true, 878 + 'locales': [ 879 + { 880 + 'lang': 'en', 881 + 'name': 'Sexually Suggestive (Cartoon)', 882 + 'description': 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.', 883 + }, 884 + ], 885 + 'displayName': 'Sexually Suggestive (Cartoon)', 886 + 'description': 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.' 887 + }; 888 + result['porn'] = { 889 + 'value': 'porn', 890 + 'identifier': 'porn', 891 + 'blurs': 'content', 892 + 'severity': 'alert', 893 + 'defaultSetting': 'hide', 894 + 'adultOnly': true, 895 + 'locales': [ 896 + { 897 + 'lang': 'en', 898 + 'name': 'Explicit Content', 899 + 'description': 'Pornographic or sexually explicit material', 900 + }, 901 + ], 902 + 'displayName': 'Explicit Content', 903 + 'description': 'Pornographic or sexually explicit material' 904 + }; 905 + result['nudity'] = { 906 + 'value': 'nudity', 907 + 'identifier': 'nudity', 908 + 'blurs': 'content', 909 + 'severity': 'alert', 910 + 'defaultSetting': 'warn', 911 + 'adultOnly': true, 912 + 'locales': [ 913 + { 914 + 'lang': 'en', 915 + 'name': 'Nudity', 916 + 'description': 'Content containing nudity', 917 + }, 918 + ], 919 + 'displayName': 'Nudity', 920 + 'description': 'Content containing nudity' 921 + }; 922 + result['sexual'] = { 923 + 'value': 'sexual', 924 + 'identifier': 'sexual', 925 + 'blurs': 'content', 926 + 'severity': 'alert', 927 + 'defaultSetting': 'warn', 928 + 'adultOnly': true, 929 + 'locales': [ 930 + { 931 + 'lang': 'en', 932 + 'name': 'Sexual Content', 933 + 'description': 'Content of a sexual nature', 934 + }, 935 + ], 936 + 'displayName': 'Sexual Content', 937 + 'description': 'Content of a sexual nature' 938 + }; 939 + result['graphic-media'] = { 940 + 'value': 'graphic-media', 941 + 'identifier': 'graphic-media', 942 + 'blurs': 'content', 943 + 'severity': 'alert', 944 + 'defaultSetting': 'warn', 945 + 'adultOnly': true, 946 + 'locales': [ 947 + { 948 + 'lang': 'en', 949 + 'name': 'Graphic Content', 950 + 'description': 'Disturbing or graphic imagery', 951 + }, 952 + ], 953 + 'displayName': 'Graphic Content', 954 + 'description': 'Disturbing or graphic imagery' 955 + }; 956 + } 957 + 958 + /// Find labels relevant to the provided AT-URI patterns 959 + /// 960 + /// [uriPatterns] List of AT URI patterns to match (boolean 'OR'). 961 + /// Each may be a prefix (ending with '*') or a full URI. 962 + /// [sources] Optional list of label sources (DIDs) to filter on. 963 + /// [limit] Results limit (1-250, default 50). 964 + /// [cursor] Optional cursor for pagination. 965 + Future<List<String>> queryLabels({ 966 + required List<String> uriPatterns, 967 + List<String>? sources, 968 + int limit = 50, 969 + String? cursor, 970 + }) async { 971 + final client = _atproto; 972 + if (client == null) { 973 + throw Exception('ATProto client not available'); 974 + } 975 + 976 + try { 977 + // Configure header to use the proxy for the labeler 978 + final Map<String, String> headers = {}; 979 + if (did != null) { 980 + headers['atproto-proxy'] = '$did#atproto_labeler'; 981 + } 982 + 983 + // Prepare parameters 984 + final Map<String, dynamic> parameters = { 985 + 'uriPatterns': uriPatterns, 986 + }; 987 + 988 + if (sources != null && sources.isNotEmpty) { 989 + parameters['sources'] = sources; 990 + } 991 + 992 + if (limit != 50) { 993 + parameters['limit'] = limit; 994 + } 995 + 996 + if (cursor != null) { 997 + parameters['cursor'] = cursor; 998 + } 999 + 1000 + final responseData = await client.get( 1001 + NSID.parse('com.atproto.label.queryLabels'), 1002 + parameters: parameters, 1003 + headers: headers, 1004 + to: (json) => json as Map<String, dynamic>, 1005 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 1006 + ); 1007 + 1008 + // Extract only the "val" values from labels 1009 + final labels = List<Map<String, dynamic>>.from(responseData.data['labels'] ?? []); 1010 + return labels.map((label) => label['val'] as String).toList(); 1011 + } catch (e) { 1012 + throw Exception('Error fetching labels: $e'); 1013 + } 1014 + } 1015 + 1016 + /// Get full label data for the provided AT-URI patterns 1017 + Future<Map<String, List<Map<String, dynamic>>>> getLabelsWithDetails({ 1018 + required List<String> uriPatterns, 1019 + List<String>? sources, 1020 + int limit = 50, 1021 + String? cursor, 1022 + }) async { 1023 + final client = _atproto; 1024 + if (client == null) { 1025 + throw Exception('ATProto client not available'); 1026 + } 1027 + 1028 + try { 1029 + // Configure header to use the proxy for the labeler 1030 + final Map<String, String> headers = {}; 1031 + if (did != null) { 1032 + headers['atproto-proxy'] = '$did#atproto_labeler'; 1033 + } 1034 + 1035 + // Prepare parameters 1036 + final Map<String, dynamic> parameters = { 1037 + 'uriPatterns': uriPatterns, 1038 + }; 1039 + 1040 + if (sources != null && sources.isNotEmpty) { 1041 + parameters['sources'] = sources; 1042 + } 1043 + 1044 + if (limit != 50) { 1045 + parameters['limit'] = limit; 1046 + } 1047 + 1048 + if (cursor != null) { 1049 + parameters['cursor'] = cursor; 1050 + } 1051 + 1052 + final responseData = await client.get( 1053 + NSID.parse('com.atproto.label.queryLabels'), 1054 + parameters: parameters, 1055 + headers: headers, 1056 + to: (json) => json as Map<String, dynamic>, 1057 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 1058 + ); 1059 + 1060 + // Group labels by URI 1061 + final labels = List<Map<String, dynamic>>.from(responseData.data['labels'] ?? []); 1062 + final Map<String, List<Map<String, dynamic>>> labelsByUri = {}; 1063 + 1064 + for (final label in labels) { 1065 + final postUri = label['uri'] as String; 1066 + labelsByUri[postUri] ??= []; 1067 + labelsByUri[postUri]!.add(label); 1068 + } 1069 + 1070 + return labelsByUri; 1071 + } catch (e) { 1072 + throw Exception('Error fetching label details: $e'); 1073 + } 1074 + } 1075 + }
+676
lib/services/labeler_manager.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'auth_service.dart'; 3 + import 'label_service.dart'; 4 + import 'settings_service.dart'; 5 + 6 + /// Manages labelers and their preferences 7 + class LabelerManager extends ChangeNotifier { 8 + final AuthService _authService; 9 + final SettingsService _settingsService; 10 + 11 + // Cache of labeler details: {labelerDid: {name, description, etc.}} 12 + final Map<String, Map<String, dynamic>> _labelerDetails = {}; 13 + 14 + // Cache of labels and definitions: {labelerDid: {labelValue: definitionMap}} 15 + final Map<String, Map<String, Map<String, dynamic>>> _labelDefinitions = {}; 16 + 17 + // Indicates if we're loading data 18 + bool _isLoading = false; 19 + 20 + // Default labeler DID - used when no other labelers are configured 21 + static const String defaultLabelerDid = "did:plc:pbgyr67hftvpoqtvaurpsctc"; 22 + 23 + LabelerManager(this._authService, this._settingsService); 24 + 25 + /// Indicates if we're loading data 26 + bool get isLoading => _isLoading; 27 + 28 + /// Returns a list of DIDs of followed labelers 29 + List<String> get followedLabelers { 30 + final labelers = _settingsService.followedLabelers; 31 + // If no labelers are configured, use the default labeler 32 + if (labelers.isEmpty) { 33 + return [defaultLabelerDid]; 34 + } 35 + return labelers; 36 + } 37 + 38 + /// Returns details of a specific labeler (null if not available) 39 + Map<String, dynamic>? getLabelerDetails(String labelerDid) { 40 + return _labelerDetails[labelerDid]; 41 + } 42 + 43 + /// Returns the label definitions for a specific labeler (empty if not available) 44 + Map<String, Map<String, dynamic>> getLabelDefinitions(String labelerDid) { 45 + return _labelDefinitions[labelerDid] ?? {}; 46 + } 47 + 48 + /// Gets a preference for a specific label 49 + LabelPreference? getLabelPreference(String labelerDid, String labelValue) { 50 + return _settingsService.getLabelPreference(labelerDid, labelValue); 51 + } 52 + 53 + /// Sets a preference for a specific label 54 + Future<void> setLabelPreference( 55 + String labelerDid, 56 + String labelValue, 57 + LabelPreference preference 58 + ) async { 59 + await _settingsService.setLabelPreference(labelerDid, labelValue, preference); 60 + notifyListeners(); 61 + } 62 + 63 + /// Follows a new labeler 64 + Future<void> followLabeler(String labelerDid) async { 65 + await _settingsService.addFollowedLabeler(labelerDid); 66 + await loadLabelerData(labelerDid); 67 + notifyListeners(); 68 + } 69 + 70 + /// Unfollows a labeler 71 + Future<void> unfollowLabeler(String labelerDid) async { 72 + // Don't allow unfollowing the default labeler 73 + if (labelerDid == defaultLabelerDid) { 74 + return; 75 + } 76 + 77 + await _settingsService.removeFollowedLabeler(labelerDid); 78 + // Remove from caches 79 + _labelerDetails.remove(labelerDid); 80 + _labelDefinitions.remove(labelerDid); 81 + notifyListeners(); 82 + } 83 + 84 + /// Loads data for a specific labeler (details and label definitions) 85 + Future<void> loadLabelerData(String labelerDid) async { 86 + // Store current loading state 87 + final wasLoading = _isLoading; 88 + 89 + // If we weren't already loading, update loading state and notify 90 + if (!wasLoading) { 91 + _isLoading = true; 92 + // Use Future.microtask to avoid calling setState during build 93 + Future.microtask(() => notifyListeners()); 94 + } 95 + 96 + try { 97 + // Get service for this labeler 98 + final labelService = LabelService.forLabeler(_authService, labelerDid); 99 + 100 + // Try to load label definitions even if labeler info fails 101 + try { 102 + // Load labeler information 103 + final labelerInfo = await labelService.getLabelerInfo(); 104 + _labelerDetails[labelerDid] = labelerInfo; 105 + } catch (e) { 106 + debugPrint('Error loading labeler info: $e'); 107 + // Fallback for labeler info 108 + _labelerDetails[labelerDid] = { 109 + 'displayName': 'Labeler $labelerDid', 110 + 'description': 'Content labeler' 111 + }; 112 + } 113 + 114 + try { 115 + // Load label definitions 116 + final labelDefs = await labelService.getAllLabelsWithDefinitions(); 117 + _labelDefinitions[labelerDid] = labelDefs; 118 + } catch (e) { 119 + debugPrint('Error loading label definitions: $e'); 120 + // For the default labeler, use a fallback if we can't load data 121 + if (labelerDid == defaultLabelerDid) { 122 + _createDefaultLabelerFallback(); 123 + } 124 + } 125 + } catch (e) { 126 + debugPrint('Error loading labeler data $labelerDid: $e'); 127 + 128 + // For the default labeler, use a fallback if we can't load data 129 + if (labelerDid == defaultLabelerDid) { 130 + _createDefaultLabelerFallback(); 131 + } 132 + } finally { 133 + _isLoading = false; 134 + 135 + // Use Future.microtask to avoid calling setState during build 136 + Future.microtask(() => notifyListeners()); 137 + } 138 + } 139 + 140 + /// Creates fallback data for the default labeler if we can't load the real data 141 + void _createDefaultLabelerFallback() { 142 + _labelerDetails[defaultLabelerDid] = { 143 + 'displayName': 'Default Labeler', 144 + 'description': 'System default content labeler' 145 + }; 146 + 147 + _labelDefinitions[defaultLabelerDid] = { 148 + 'spam': { 149 + 'value': 'spam', 150 + 'identifier': 'spam', 151 + 'blurs': 'content', 152 + 'severity': 'inform', 153 + 'defaultSetting': 'hide', 154 + 'adultOnly': false, 155 + 'locales': [ 156 + { 157 + 'lang': 'en', 158 + 'name': 'Spam', 159 + 'description': 'Unwanted, repeated, or unrelated actions that bother users.', 160 + }, 161 + ], 162 + 'displayName': 'Spam', 163 + 'description': 'Unwanted, repeated, or unrelated actions that bother users.' 164 + }, 165 + 'impersonation': { 166 + 'value': 'impersonation', 167 + 'identifier': 'impersonation', 168 + 'blurs': 'none', 169 + 'severity': 'inform', 170 + 'defaultSetting': 'hide', 171 + 'adultOnly': false, 172 + 'locales': [ 173 + { 174 + 'lang': 'en', 175 + 'name': 'Impersonation', 176 + 'description': 'Pretending to be someone else without permission.', 177 + }, 178 + ], 179 + 'displayName': 'Impersonation', 180 + 'description': 'Pretending to be someone else without permission.' 181 + }, 182 + 'scam': { 183 + 'value': 'scam', 184 + 'identifier': 'scam', 185 + 'blurs': 'content', 186 + 'severity': 'alert', 187 + 'defaultSetting': 'hide', 188 + 'adultOnly': false, 189 + 'locales': [ 190 + { 191 + 'lang': 'en', 192 + 'name': 'Scam', 193 + 'description': 'Scams, phishing & fraud.', 194 + }, 195 + ], 196 + 'displayName': 'Scam', 197 + 'description': 'Scams, phishing & fraud.' 198 + }, 199 + 'intolerant': { 200 + 'value': 'intolerant', 201 + 'identifier': 'intolerant', 202 + 'blurs': 'content', 203 + 'severity': 'alert', 204 + 'defaultSetting': 'warn', 205 + 'adultOnly': false, 206 + 'locales': [ 207 + { 208 + 'lang': 'en', 209 + 'name': 'Intolerance', 210 + 'description': 'Discrimination against protected groups.', 211 + }, 212 + ], 213 + 'displayName': 'Intolerance', 214 + 'description': 'Discrimination against protected groups.' 215 + }, 216 + 'self-harm': { 217 + 'value': 'self-harm', 218 + 'identifier': 'self-harm', 219 + 'blurs': 'content', 220 + 'severity': 'alert', 221 + 'defaultSetting': 'warn', 222 + 'adultOnly': false, 223 + 'locales': [ 224 + { 225 + 'lang': 'en', 226 + 'name': 'Self-Harm', 227 + 'description': 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.', 228 + }, 229 + ], 230 + 'displayName': 'Self-Harm', 231 + 'description': 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.' 232 + }, 233 + 'security': { 234 + 'value': 'security', 235 + 'identifier': 'security', 236 + 'blurs': 'content', 237 + 'severity': 'alert', 238 + 'defaultSetting': 'hide', 239 + 'adultOnly': false, 240 + 'locales': [ 241 + { 242 + 'lang': 'en', 243 + 'name': 'Security Concerns', 244 + 'description': 'May be unsafe and could harm your device, steal your info, or get your account hacked.', 245 + }, 246 + ], 247 + 'displayName': 'Security Concerns', 248 + 'description': 'May be unsafe and could harm your device, steal your info, or get your account hacked.' 249 + }, 250 + 'misleading': { 251 + 'value': 'misleading', 252 + 'identifier': 'misleading', 253 + 'blurs': 'content', 254 + 'severity': 'alert', 255 + 'defaultSetting': 'warn', 256 + 'adultOnly': false, 257 + 'locales': [ 258 + { 259 + 'lang': 'en', 260 + 'name': 'Misleading', 261 + 'description': 'Altered images/videos, deceptive links, or false statements.', 262 + }, 263 + ], 264 + 'displayName': 'Misleading', 265 + 'description': 'Altered images/videos, deceptive links, or false statements.' 266 + }, 267 + 'threat': { 268 + 'value': 'threat', 269 + 'identifier': 'threat', 270 + 'blurs': 'content', 271 + 'severity': 'inform', 272 + 'defaultSetting': 'hide', 273 + 'adultOnly': false, 274 + 'locales': [ 275 + { 276 + 'lang': 'en', 277 + 'name': 'Threats', 278 + 'description': 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.', 279 + }, 280 + ], 281 + 'displayName': 'Threats', 282 + 'description': 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.' 283 + }, 284 + 'unsafe-link': { 285 + 'value': 'unsafe-link', 286 + 'identifier': 'unsafe-link', 287 + 'blurs': 'content', 288 + 'severity': 'alert', 289 + 'defaultSetting': 'hide', 290 + 'adultOnly': false, 291 + 'locales': [ 292 + { 293 + 'lang': 'en', 294 + 'name': 'Unsafe link', 295 + 'description': 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.', 296 + }, 297 + ], 298 + 'displayName': 'Unsafe link', 299 + 'description': 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.' 300 + }, 301 + 'illicit': { 302 + 'value': 'illicit', 303 + 'identifier': 'illicit', 304 + 'blurs': 'content', 305 + 'severity': 'alert', 306 + 'defaultSetting': 'hide', 307 + 'adultOnly': false, 308 + 'locales': [ 309 + { 310 + 'lang': 'en', 311 + 'name': 'Illicit', 312 + 'description': 'Promoting or selling potentially illicit goods, services, or activities.', 313 + }, 314 + ], 315 + 'displayName': 'Illicit', 316 + 'description': 'Promoting or selling potentially illicit goods, services, or activities.' 317 + }, 318 + 'misinformation': { 319 + 'value': 'misinformation', 320 + 'identifier': 'misinformation', 321 + 'blurs': 'content', 322 + 'severity': 'inform', 323 + 'defaultSetting': 'warn', 324 + 'adultOnly': false, 325 + 'locales': [ 326 + { 327 + 'lang': 'en', 328 + 'name': 'Misinformation', 329 + 'description': 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.', 330 + }, 331 + ], 332 + 'displayName': 'Misinformation', 333 + 'description': 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.' 334 + }, 335 + 'rumor': { 336 + 'value': 'rumor', 337 + 'identifier': 'rumor', 338 + 'blurs': 'content', 339 + 'severity': 'inform', 340 + 'defaultSetting': 'warn', 341 + 'adultOnly': false, 342 + 'locales': [ 343 + { 344 + 'lang': 'en', 345 + 'name': 'Rumor', 346 + 'description': 'Approach with caution, as these claims lack evidence from credible sources.', 347 + }, 348 + ], 349 + 'displayName': 'Rumor', 350 + 'description': 'Approach with caution, as these claims lack evidence from credible sources.' 351 + }, 352 + 'rude': { 353 + 'value': 'rude', 354 + 'identifier': 'rude', 355 + 'blurs': 'content', 356 + 'severity': 'inform', 357 + 'defaultSetting': 'hide', 358 + 'adultOnly': false, 359 + 'locales': [ 360 + { 361 + 'lang': 'en', 362 + 'name': 'Rude', 363 + 'description': 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.', 364 + }, 365 + ], 366 + 'displayName': 'Rude', 367 + 'description': 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.' 368 + }, 369 + 'extremist': { 370 + 'value': 'extremist', 371 + 'identifier': 'extremist', 372 + 'blurs': 'content', 373 + 'severity': 'alert', 374 + 'defaultSetting': 'hide', 375 + 'adultOnly': false, 376 + 'locales': [ 377 + { 378 + 'lang': 'en', 379 + 'name': 'Extremist', 380 + 'description': 'Radical views advocating violence, hate, or discrimination against individuals or groups.', 381 + }, 382 + ], 383 + 'displayName': 'Extremist', 384 + 'description': 'Radical views advocating violence, hate, or discrimination against individuals or groups.' 385 + }, 386 + 'sensitive': { 387 + 'value': 'sensitive', 388 + 'identifier': 'sensitive', 389 + 'blurs': 'content', 390 + 'severity': 'alert', 391 + 'defaultSetting': 'warn', 392 + 'adultOnly': false, 393 + 'locales': [ 394 + { 395 + 'lang': 'en', 396 + 'name': 'Sensitive', 397 + 'description': 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.', 398 + }, 399 + ], 400 + 'displayName': 'Sensitive', 401 + 'description': 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.' 402 + }, 403 + 'engagement-farming': { 404 + 'value': 'engagement-farming', 405 + 'identifier': 'engagement-farming', 406 + 'blurs': 'content', 407 + 'severity': 'alert', 408 + 'defaultSetting': 'hide', 409 + 'adultOnly': false, 410 + 'locales': [ 411 + { 412 + 'lang': 'en', 413 + 'name': 'Engagement Farming', 414 + 'description': 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.', 415 + }, 416 + ], 417 + 'displayName': 'Engagement Farming', 418 + 'description': 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.' 419 + }, 420 + 'inauthentic': { 421 + 'value': 'inauthentic', 422 + 'identifier': 'inauthentic', 423 + 'blurs': 'content', 424 + 'severity': 'alert', 425 + 'defaultSetting': 'hide', 426 + 'adultOnly': false, 427 + 'locales': [ 428 + { 429 + 'lang': 'en', 430 + 'name': 'Inauthentic Account', 431 + 'description': 'Bot or a person pretending to be someone else.', 432 + }, 433 + ], 434 + 'displayName': 'Inauthentic Account', 435 + 'description': 'Bot or a person pretending to be someone else.' 436 + }, 437 + 'sexual-figurative': { 438 + 'value': 'sexual-figurative', 439 + 'identifier': 'sexual-figurative', 440 + 'blurs': 'media', 441 + 'severity': 'none', 442 + 'defaultSetting': 'show', 443 + 'adultOnly': true, 444 + 'locales': [ 445 + { 446 + 'lang': 'en', 447 + 'name': 'Sexually Suggestive (Cartoon)', 448 + 'description': 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.', 449 + }, 450 + ], 451 + 'displayName': 'Sexually Suggestive (Cartoon)', 452 + 'description': 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.' 453 + }, 454 + 'porn': { 455 + 'value': 'porn', 456 + 'identifier': 'porn', 457 + 'blurs': 'content', 458 + 'severity': 'alert', 459 + 'defaultSetting': 'hide', 460 + 'adultOnly': true, 461 + 'locales': [ 462 + { 463 + 'lang': 'en', 464 + 'name': 'Explicit Content', 465 + 'description': 'Pornographic or sexually explicit material', 466 + }, 467 + ], 468 + 'displayName': 'Explicit Content', 469 + 'description': 'Pornographic or sexually explicit material' 470 + }, 471 + 'nudity': { 472 + 'value': 'nudity', 473 + 'identifier': 'nudity', 474 + 'blurs': 'content', 475 + 'severity': 'alert', 476 + 'defaultSetting': 'warn', 477 + 'adultOnly': true, 478 + 'locales': [ 479 + { 480 + 'lang': 'en', 481 + 'name': 'Nudity', 482 + 'description': 'Content containing nudity', 483 + }, 484 + ], 485 + 'displayName': 'Nudity', 486 + 'description': 'Content containing nudity' 487 + }, 488 + 'sexual': { 489 + 'value': 'sexual', 490 + 'identifier': 'sexual', 491 + 'blurs': 'content', 492 + 'severity': 'alert', 493 + 'defaultSetting': 'warn', 494 + 'adultOnly': true, 495 + 'locales': [ 496 + { 497 + 'lang': 'en', 498 + 'name': 'Sexual Content', 499 + 'description': 'Content of a sexual nature', 500 + }, 501 + ], 502 + 'displayName': 'Sexual Content', 503 + 'description': 'Content of a sexual nature' 504 + }, 505 + 'graphic-media': { 506 + 'value': 'graphic-media', 507 + 'identifier': 'graphic-media', 508 + 'blurs': 'content', 509 + 'severity': 'alert', 510 + 'defaultSetting': 'warn', 511 + 'adultOnly': false, 512 + 'locales': [ 513 + { 514 + 'lang': 'en', 515 + 'name': 'Graphic Content', 516 + 'description': 'Disturbing or graphic imagery', 517 + }, 518 + ], 519 + 'displayName': 'Graphic Content', 520 + 'description': 'Disturbing or graphic imagery' 521 + }, 522 + }; 523 + } 524 + 525 + /// Loads data for all followed labelers 526 + Future<void> loadAllFollowedLabelers() async { 527 + _isLoading = true; 528 + // Use Future.microtask to avoid calling setState during build 529 + Future.microtask(() => notifyListeners()); 530 + 531 + try { 532 + final labelers = List<String>.from(followedLabelers); 533 + 534 + for (final labelerDid in labelers) { 535 + await loadLabelerData(labelerDid); 536 + } 537 + } finally { 538 + _isLoading = false; 539 + // Use Future.microtask to avoid calling setState during build 540 + Future.microtask(() => notifyListeners()); 541 + } 542 + } 543 + 544 + /// Checks if content should be hidden based on its labels 545 + bool shouldHideContent(List<String> contentLabels) { 546 + if (contentLabels.isEmpty) return false; 547 + 548 + // First check for special '!hide' label which always hides content 549 + if (contentLabels.contains('!hide')) { 550 + return true; 551 + } 552 + 553 + final settingsService = _settingsService; 554 + 555 + // For each label in the content 556 + for (final labelValue in contentLabels) { 557 + // Check in each followed labeler 558 + for (final labelerDid in followedLabelers) { 559 + // Get the label definition to check defaultSetting 560 + final labelDefinition = getLabelDefinitions(labelerDid)[labelValue]; 561 + 562 + // Get preference with defaultSetting consideration 563 + final preference = settingsService.getLabelPreferenceOrDefault( 564 + labelerDid, 565 + labelValue, 566 + labelDefinition 567 + ); 568 + 569 + // If any labeler says to hide, hide 570 + if (preference == LabelPreference.hide) { 571 + return true; 572 + } 573 + } 574 + } 575 + 576 + return false; 577 + } 578 + 579 + /// Checks if content should display a warning based on its labels 580 + bool shouldWarnContent(List<String> contentLabels) { 581 + if (contentLabels.isEmpty) return false; 582 + 583 + // First check for special '!warn' label which always warns for content 584 + if (contentLabels.contains('!warn')) { 585 + return true; 586 + } 587 + 588 + final settingsService = _settingsService; 589 + 590 + // For each label in the content 591 + for (final labelValue in contentLabels) { 592 + // Check in each followed labeler 593 + for (final labelerDid in followedLabelers) { 594 + // Get the label definition to check defaultSetting 595 + final labelDefinition = getLabelDefinitions(labelerDid)[labelValue]; 596 + 597 + // Get preference with defaultSetting consideration 598 + final preference = settingsService.getLabelPreferenceOrDefault( 599 + labelerDid, 600 + labelValue, 601 + labelDefinition 602 + ); 603 + 604 + // If any labeler says to warn (and none say to hide), warn 605 + if (preference == LabelPreference.warn) { 606 + return true; 607 + } 608 + } 609 + } 610 + 611 + return false; 612 + } 613 + 614 + /// Gets warning messages for content based on its labels 615 + List<String> getWarningMessages(List<String> contentLabels) { 616 + final Set<String> warnings = {}; 617 + 618 + // Check for special '!warn' label which has a dedicated warning message 619 + if (contentLabels.contains('!warn')) { 620 + warnings.add("This content has been flagged by the publisher as requiring a warning"); 621 + } 622 + 623 + final settingsService = _settingsService; 624 + 625 + // For each label in the content 626 + for (final labelValue in contentLabels) { 627 + // Skip processing the special labels 628 + if (labelValue == '!warn' || labelValue == '!hide') continue; 629 + 630 + // Check in each followed labeler 631 + for (final labelerDid in followedLabelers) { 632 + // Get the label definition to check defaultSetting 633 + final labelDefinition = getLabelDefinitions(labelerDid)[labelValue]; 634 + 635 + // Get preference with defaultSetting consideration 636 + final preference = settingsService.getLabelPreferenceOrDefault( 637 + labelerDid, 638 + labelValue, 639 + labelDefinition 640 + ); 641 + 642 + // If the labeler says to warn about this label 643 + if (preference == LabelPreference.warn) { 644 + // Get the definition of this label 645 + final labelDef = _labelDefinitions[labelerDid]?[labelValue]; 646 + if (labelDef != null) { 647 + // Add the warning message (or the label value if no message) 648 + String? displayName; 649 + 650 + // Try to get display name from locales first 651 + if (labelDef['locales'] != null) { 652 + final locales = labelDef['locales'] as List; 653 + if (locales.isNotEmpty) { 654 + final enLocale = locales.first; 655 + displayName = enLocale['name'] as String?; 656 + } 657 + } 658 + 659 + // Fallback to legacy displayName 660 + displayName ??= labelDef['displayName'] as String?; 661 + 662 + // Fallback to label value 663 + displayName ??= labelValue; 664 + 665 + warnings.add(displayName); 666 + } else { 667 + // If we don't have the definition, use the raw value 668 + warnings.add("This post contains content that was labeled as $labelValue"); 669 + } 670 + } 671 + } 672 + } 673 + 674 + return warnings.toList(); 675 + } 676 + }
+41 -39
lib/services/mod_service.dart
··· 22 22 final authAtProto = _atproto; 23 23 if (authAtProto == null || authAtProto.session == null) { 24 24 throw Exception('AtProto not initialized'); 25 - } 26 - if (service != null) { 25 + } else if (service != null) { 27 26 final report = await service.createReport(subject: subject, reasonType: reasonType, reason: reason); 28 27 return report.status.code == 200; 29 - } 28 + } else { 29 + final endpoint = NSID.parse('com.atproto.moderation.createReport'); 30 30 31 - final endpoint = NSID.parse('com.atproto.moderation.createReport'); 31 + final subjectData = subject.data; 32 32 33 - final subjectData = subject.data; 33 + Map<String, dynamic> body; 34 34 35 - Map<String, dynamic> body; 35 + if (subjectData is StrongRef) { 36 + final strongRef = subjectData.toJson(); 37 + body = { 38 + 'subject': {'\$type': 'com.atproto.repo.strongRef', 'uri': strongRef['uri'], 'cid': strongRef['cid']}, 39 + 'reasonType': reasonType.value, 40 + }; 41 + } else if (subjectData is RepoRef) { 42 + body = { 43 + 'subject': {'\$type': 'com.atproto.admin.defs.repoRef', 'did': subjectData.did}, 44 + 'reasonType': reasonType.value, 45 + }; 46 + } else { 47 + throw Exception('Invalid subject data'); 48 + } 36 49 37 - if (subjectData is StrongRef) { 38 - final strongRef = subjectData.toJson(); 39 - body = { 40 - 'subject': {'\$type': 'com.atproto.repo.strongRef', 'uri': strongRef['uri'], 'cid': strongRef['cid']}, 41 - 'reasonType': reasonType.value, 42 - }; 43 - } else if (subjectData is RepoRef) { 44 - body = { 45 - 'subject': {'\$type': 'com.atproto.admin.defs.repoRef', 'did': subjectData.did}, 46 - 'reasonType': reasonType.value, 47 - }; 48 - } else { 49 - throw Exception('Invalid subject data'); 50 - } 50 + if (reason != null) { 51 + body['reason'] = reason; 52 + } 51 53 52 - if (reason != null) { 53 - body['reason'] = reason; 54 - } 54 + // Make XRPC call 55 + // Ensure the service URL has a scheme (https://) 56 + String serviceUrl = authAtProto.service; 57 + if (!serviceUrl.startsWith('http://') && !serviceUrl.startsWith('https://')) { 58 + serviceUrl = 'https://$serviceUrl'; 59 + } 55 60 56 - // Make XRPC call 57 - // Ensure the service URL has a scheme (https://) 58 - String serviceUrl = authAtProto.service; 59 - if (!serviceUrl.startsWith('http://') && !serviceUrl.startsWith('https://')) { 60 - serviceUrl = 'https://$serviceUrl'; 61 - } 61 + // final uri = Uri.parse('$serviceUrl/xrpc/$endpoint'); 62 + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ if the user account was in another PDS other than sprk.so this would send to the wrong place 63 + // by default, send to our PDS 64 + final uri = Uri.parse('https://pds.sprk.so/xrpc/$endpoint'); 65 + final headers = {'Authorization': 'Bearer ${authAtProto.session!.accessJwt}', 'Content-Type': 'application/json'}; 62 66 63 - final uri = Uri.parse('$serviceUrl/xrpc/$endpoint'); 64 - final headers = {'Authorization': 'Bearer ${authAtProto.session!.accessJwt}', 'Content-Type': 'application/json'}; 67 + debugPrint('Report endpoint URI: $uri'); 68 + debugPrint('Report headers: $headers'); 69 + debugPrint('Report body: $body'); 65 70 66 - debugPrint('Report endpoint URI: $uri'); 67 - debugPrint('Report headers: $headers'); 68 - debugPrint('Report body: $body'); 71 + final response = await http.post(uri, headers: headers, body: jsonEncode(body)); 69 72 70 - final response = await http.post(uri, headers: headers, body: jsonEncode(body)); 73 + if (response.statusCode != 200) { 74 + throw Exception('Failed to create report: ${response.body}'); 75 + } 71 76 72 - if (response.statusCode != 200) { 73 - throw Exception('Failed to create report: ${response.body}'); 77 + return true; 74 78 } 75 - 76 - return true; 77 79 } 78 80 }
+193
lib/services/settings_service.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:shared_preferences/shared_preferences.dart'; 3 + import 'dart:convert'; 4 + 5 + /// Enum to represent the user's preference for a specific label 6 + enum LabelPreference { 7 + show, 8 + warn, 9 + hide, 10 + } 3 11 4 12 class SettingsService extends ChangeNotifier { 5 13 static const String _feedBlurKey = 'feed_blur_enabled'; 14 + static const String _followedLabelersKey = 'followed_labelers'; 15 + static const String _labelerPreferencesKey = 'labeler_preferences'; 16 + static const String _hideAdultContentKey = 'hide_adult_content'; 6 17 7 18 SharedPreferences? _prefs; 8 19 bool _isLoading = true; 9 20 bool _feedBlurEnabled = false; 21 + bool _hideAdultContent = true; // On by default 22 + List<String> _followedLabelers = []; 23 + 24 + /// Stores label preferences for each labeler 25 + /// Format: {labelerDid: {labelValue: preferenceValue}} 26 + Map<String, Map<String, String>> _labelPreferences = {}; 10 27 11 28 SettingsService() { 12 29 _loadSettings(); ··· 14 31 15 32 bool get isLoading => _isLoading; 16 33 bool get feedBlurEnabled => _feedBlurEnabled; 34 + bool get hideAdultContent => _hideAdultContent; 35 + List<String> get followedLabelers => List.unmodifiable(_followedLabelers); 36 + 37 + /// Returns an immutable copy of all labeler preferences 38 + Map<String, Map<String, String>> get labelPreferences => 39 + Map.unmodifiable(_labelPreferences); 17 40 18 41 Future<void> _loadSettings() async { 19 42 _prefs = await SharedPreferences.getInstance(); 20 43 _feedBlurEnabled = _prefs?.getBool(_feedBlurKey) ?? false; 44 + _hideAdultContent = _prefs?.getBool(_hideAdultContentKey) ?? true; // Default to true if not set 45 + _followedLabelers = _prefs?.getStringList(_followedLabelersKey) ?? []; 46 + 47 + // Load label preferences 48 + final prefsJson = _prefs?.getString(_labelerPreferencesKey); 49 + if (prefsJson != null) { 50 + final Map<String, dynamic> decoded = jsonDecode(prefsJson); 51 + _labelPreferences = decoded.map((key, value) => MapEntry( 52 + key, 53 + (value as Map<String, dynamic>).map((k, v) => MapEntry(k, v.toString())), 54 + )); 55 + } 56 + 21 57 _isLoading = false; 22 58 notifyListeners(); 23 59 } ··· 26 62 if (_isLoading) await _loadSettings(); 27 63 _feedBlurEnabled = value; 28 64 await _prefs?.setBool(_feedBlurKey, value); 65 + notifyListeners(); 66 + } 67 + 68 + Future<void> setHideAdultContent(bool value) async { 69 + if (_isLoading) await _loadSettings(); 70 + _hideAdultContent = value; 71 + await _prefs?.setBool(_hideAdultContentKey, value); 72 + notifyListeners(); 73 + } 74 + 75 + Future<void> setFollowedLabelers(List<String> labelerDids) async { 76 + if (_isLoading) await _loadSettings(); 77 + _followedLabelers = List<String>.from(labelerDids); 78 + await _prefs?.setStringList(_followedLabelersKey, _followedLabelers); 79 + notifyListeners(); 80 + } 81 + 82 + Future<void> addFollowedLabeler(String labelerDid) async { 83 + if (_isLoading) await _loadSettings(); 84 + if (!_followedLabelers.contains(labelerDid)) { 85 + _followedLabelers.add(labelerDid); 86 + await _prefs?.setStringList(_followedLabelersKey, _followedLabelers); 87 + notifyListeners(); 88 + } 89 + } 90 + 91 + Future<void> removeFollowedLabeler(String labelerDid) async { 92 + if (_isLoading) await _loadSettings(); 93 + if (_followedLabelers.contains(labelerDid)) { 94 + _followedLabelers.remove(labelerDid); 95 + await _prefs?.setStringList(_followedLabelersKey, _followedLabelers); 96 + 97 + // Also remove preferences for this labeler 98 + _labelPreferences.remove(labelerDid); 99 + await _saveLabelPreferences(); 100 + 101 + notifyListeners(); 102 + } 103 + } 104 + 105 + /// Saves label preferences to SharedPreferences 106 + Future<void> _saveLabelPreferences() async { 107 + if (_prefs == null) return; 108 + 109 + final jsonStr = jsonEncode(_labelPreferences); 110 + await _prefs!.setString(_labelerPreferencesKey, jsonStr); 111 + } 112 + 113 + /// Sets a preference for a specific label from a labeler 114 + Future<void> setLabelPreference( 115 + String labelerDid, 116 + String labelValue, 117 + LabelPreference preference 118 + ) async { 119 + if (_isLoading) await _loadSettings(); 120 + 121 + // Ensure the labeler is initialized in the map 122 + _labelPreferences[labelerDid] ??= {}; 123 + 124 + // Set the preference 125 + _labelPreferences[labelerDid]![labelValue] = preference.name; 126 + 127 + // Save to SharedPreferences 128 + await _saveLabelPreferences(); 129 + notifyListeners(); 130 + } 131 + 132 + /// Removes a preference for a specific label, reverting to the default 133 + Future<void> removeLabelPreference( 134 + String labelerDid, 135 + String labelValue 136 + ) async { 137 + if (_isLoading) await _loadSettings(); 138 + 139 + // Check if the labeler and preference exist 140 + if (_labelPreferences.containsKey(labelerDid)) { 141 + // Remove the specific preference 142 + _labelPreferences[labelerDid]?.remove(labelValue); 143 + 144 + // Save to SharedPreferences 145 + await _saveLabelPreferences(); 146 + notifyListeners(); 147 + } 148 + } 149 + 150 + /// Gets the preference for a specific label from a labeler 151 + /// Returns null if no preference is defined 152 + LabelPreference? getLabelPreference(String labelerDid, String labelValue) { 153 + if (_isLoading || !_labelPreferences.containsKey(labelerDid)) { 154 + return null; 155 + } 156 + 157 + final prefValue = _labelPreferences[labelerDid]?[labelValue]; 158 + if (prefValue == null) return null; 159 + 160 + return LabelPreference.values.firstWhere( 161 + (e) => e.name == prefValue, 162 + orElse: () => LabelPreference.warn // default 163 + ); 164 + } 165 + 166 + /// Gets the preference for a specific label, or returns the default setting from the label definition 167 + LabelPreference getLabelPreferenceOrDefault( 168 + String labelerDid, 169 + String labelValue, 170 + Map<String, dynamic>? labelDefinition 171 + ) { 172 + // First try to get user's explicit preference 173 + final userPreference = getLabelPreference(labelerDid, labelValue); 174 + if (userPreference != null) { 175 + return userPreference; 176 + } 177 + 178 + // If no user preference and we have a label definition with defaultSetting 179 + if (labelDefinition != null && labelDefinition.containsKey('defaultSetting')) { 180 + final defaultSetting = labelDefinition['defaultSetting'] as String; 181 + 182 + // Map the defaultSetting string to LabelPreference 183 + switch (defaultSetting) { 184 + case 'show': 185 + return LabelPreference.show; 186 + case 'hide': 187 + return LabelPreference.hide; 188 + case 'warn': 189 + return LabelPreference.warn; 190 + default: 191 + return LabelPreference.warn; // Fallback default 192 + } 193 + } 194 + 195 + // Final fallback 196 + return LabelPreference.warn; 197 + } 198 + 199 + /// Sets preferences in bulk for all labels from a labeler 200 + Future<void> setLabelerPreferences( 201 + String labelerDid, 202 + Map<String, LabelPreference> preferences 203 + ) async { 204 + if (_isLoading) await _loadSettings(); 205 + 206 + // Convert the map of enums to strings 207 + final stringPrefs = preferences.map( 208 + (key, value) => MapEntry(key, value.name) 209 + ); 210 + 211 + _labelPreferences[labelerDid] = stringPrefs; 212 + await _saveLabelPreferences(); 213 + notifyListeners(); 214 + } 215 + 216 + /// Removes all preferences for a specific labeler 217 + Future<void> clearLabelerPreferences(String labelerDid) async { 218 + if (_isLoading) await _loadSettings(); 219 + 220 + _labelPreferences.remove(labelerDid); 221 + await _saveLabelPreferences(); 29 222 notifyListeners(); 30 223 } 31 224 }
+172
lib/widgets/censorship/warn_builder.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'dart:ui'; 3 + 4 + import 'package:sparksocial/utils/app_colors.dart'; 5 + 6 + class WarnBuilder extends StatefulWidget { 7 + final Widget child; 8 + final String labelerDid; 9 + final String labelValue; 10 + final String? warningMessage; 11 + final String blurType; 12 + final String severity; 13 + 14 + const WarnBuilder({ 15 + super.key, 16 + required this.child, 17 + required this.labelerDid, 18 + required this.labelValue, 19 + this.warningMessage, 20 + this.blurType = 'content', 21 + this.severity = 'alert', 22 + }); 23 + 24 + @override 25 + State<WarnBuilder> createState() => _WarnBuilderState(); 26 + } 27 + 28 + class _WarnBuilderState extends State<WarnBuilder> { 29 + bool _showWarning = true; 30 + 31 + @override 32 + Widget build(BuildContext context) { 33 + if (!_showWarning) { 34 + return widget.child; 35 + } 36 + 37 + final bool shouldApplyBlur = widget.blurType != 'none'; 38 + 39 + // Define colors and styles based on severity 40 + final Color borderColor = _getBorderColor(); 41 + final Color iconColor = _getIconColor(); 42 + final IconData warningIcon = _getWarningIcon(); 43 + final String headerText = _getHeaderText(); 44 + final double borderWidth = widget.severity == 'alert' ? 2.0 : 1.0; 45 + final Color backgroundColor = widget.severity == 'alert' 46 + ? Colors.black.withAlpha(100) 47 + : Colors.black.withAlpha(80); 48 + 49 + return Stack( 50 + children: [ 51 + if (shouldApplyBlur) 52 + ClipRect( 53 + child: BackdropFilter( 54 + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), 55 + child: Container( 56 + color: Colors.black.withAlpha(25), 57 + child: widget.child, 58 + ), 59 + ), 60 + ) 61 + else 62 + Opacity( 63 + opacity: 0.3, 64 + child: widget.child, 65 + ), 66 + 67 + // Warning overlay 68 + Center( 69 + child: Container( 70 + width: MediaQuery.of(context).size.width * 0.85, 71 + padding: const EdgeInsets.all(20), 72 + decoration: BoxDecoration( 73 + color: backgroundColor, 74 + borderRadius: BorderRadius.circular(12), 75 + border: Border.all(color: borderColor, width: borderWidth), 76 + ), 77 + child: Column( 78 + mainAxisSize: MainAxisSize.min, 79 + children: [ 80 + Icon(warningIcon, size: 48, color: iconColor), 81 + const SizedBox(height: 16), 82 + Text( 83 + headerText, 84 + style: TextStyle( 85 + fontSize: 20, 86 + fontWeight: FontWeight.bold, 87 + color: Colors.white, 88 + ), 89 + ), 90 + const SizedBox(height: 8), 91 + Text( 92 + widget.warningMessage ?? 93 + 'This content has been marked as ${widget.labelValue}', 94 + textAlign: TextAlign.center, 95 + style: const TextStyle( 96 + fontSize: 16, 97 + color: Colors.white, 98 + ), 99 + ), 100 + const SizedBox(height: 16), 101 + ElevatedButton( 102 + onPressed: () { 103 + setState(() { 104 + _showWarning = false; 105 + }); 106 + }, 107 + style: ElevatedButton.styleFrom( 108 + backgroundColor: borderColor, 109 + foregroundColor: Colors.white, 110 + ), 111 + child: const Text('Show content'), 112 + ), 113 + ], 114 + ), 115 + ), 116 + ), 117 + ], 118 + ); 119 + } 120 + 121 + Color _getBorderColor() { 122 + switch (widget.severity) { 123 + case 'alert': 124 + return AppColors.red; 125 + case 'inform': 126 + return AppColors.orange; 127 + case 'none': 128 + return AppColors.blue; 129 + default: 130 + return AppColors.red; 131 + } 132 + } 133 + 134 + Color _getIconColor() { 135 + switch (widget.severity) { 136 + case 'alert': 137 + return AppColors.red; 138 + case 'inform': 139 + return AppColors.orange; 140 + case 'none': 141 + return AppColors.blue; 142 + default: 143 + return AppColors.red; 144 + } 145 + } 146 + 147 + IconData _getWarningIcon() { 148 + switch (widget.severity) { 149 + case 'alert': 150 + return Icons.warning_amber_rounded; 151 + case 'inform': 152 + return Icons.info_outline; 153 + case 'none': 154 + return Icons.visibility_off; 155 + default: 156 + return Icons.warning_amber_rounded; 157 + } 158 + } 159 + 160 + String _getHeaderText() { 161 + switch (widget.severity) { 162 + case 'alert': 163 + return 'Sensitive content'; 164 + case 'inform': 165 + return 'Content notice'; 166 + case 'none': 167 + return 'Hidden content'; 168 + default: 169 + return 'Sensitive content'; 170 + } 171 + } 172 + }
+4 -2
lib/widgets/dialogs/report_dialog.dart
··· 49 49 50 50 try { 51 51 if (widget.onSubmit != null) { 52 + // eventually this will be a ModerationService 53 + // by default, ModService sends it to the user's PDS 52 54 widget.onSubmit!(subject, _selectedReason, reason, null); 53 55 if (mounted) { 54 56 Navigator.of(context).pop(); ··· 118 120 child: Container( 119 121 padding: const EdgeInsets.all(8), 120 122 decoration: BoxDecoration( 121 - color: theme.colorScheme.error.withOpacity(0.1), 123 + color: theme.colorScheme.error.withAlpha(25), 122 124 border: Border.all(color: theme.colorScheme.error), 123 125 borderRadius: BorderRadius.circular(4), 124 126 ), ··· 157 159 friendlyName, 158 160 style: theme.textTheme.bodyMedium?.copyWith(color: textColor, fontWeight: FontWeight.w500, fontSize: 13), 159 161 ), 160 - subtitle: Text(description, style: theme.textTheme.bodySmall?.copyWith(color: textColor.withOpacity(0.7), fontSize: 10)), 162 + subtitle: Text(description, style: theme.textTheme.bodySmall?.copyWith(color: textColor.withAlpha(179), fontSize: 10)), 161 163 value: reason, 162 164 groupValue: _selectedReason, 163 165 activeColor: theme.colorScheme.primary,
+436 -3
lib/widgets/feed_settings/feed_settings_sheet.dart
··· 2 2 import 'package:provider/provider.dart'; 3 3 4 4 import '../../services/settings_service.dart'; 5 + import '../../services/labeler_manager.dart'; 5 6 import '../../utils/app_colors.dart'; 6 7 7 8 class FeedSettingsSheet extends StatefulWidget { ··· 14 15 State<FeedSettingsSheet> createState() => _FeedSettingsSheetState(); 15 16 } 16 17 17 - class _FeedSettingsSheetState extends State<FeedSettingsSheet> { 18 + class _FeedSettingsSheetState extends State<FeedSettingsSheet> with SingleTickerProviderStateMixin { 18 19 late List<FeedSetting> _feedSettings; 20 + late TabController _tabController; 21 + Map<String, Map<String, dynamic>> _labelDefinitions = {}; 22 + bool _isLoadingLabels = false; 23 + String? _labelsError; 19 24 20 25 @override 21 26 void initState() { 22 27 super.initState(); 23 28 _feedSettings = List.from(widget.feedSettings); 29 + _tabController = TabController(length: 2, vsync: this); 30 + _loadLabelDefinitions(); 31 + } 32 + 33 + @override 34 + void dispose() { 35 + _tabController.dispose(); 36 + super.dispose(); 37 + } 38 + 39 + // Load label definitions from the default labeler 40 + Future<void> _loadLabelDefinitions() async { 41 + // Use Future.microtask para o primeiro setState para evitar chamar durante o build 42 + Future.microtask(() { 43 + if (mounted) { 44 + setState(() { 45 + _isLoadingLabels = true; 46 + _labelsError = null; 47 + }); 48 + } 49 + }); 50 + 51 + try { 52 + final labelerManager = Provider.of<LabelerManager>(context, listen: false); 53 + 54 + // O LabelerManager agora trata erros internamente com fallbacks 55 + await labelerManager.loadLabelerData(LabelerManager.defaultLabelerDid); 56 + 57 + // Get label definitions from the labeler manager 58 + final definitions = labelerManager.getLabelDefinitions(LabelerManager.defaultLabelerDid); 59 + 60 + // Verificar se o widget ainda está na árvore antes de chamar setState 61 + if (mounted) { 62 + setState(() { 63 + _labelDefinitions = definitions; 64 + _isLoadingLabels = false; 65 + }); 66 + } 67 + } catch (e) { 68 + // Verificar se o widget ainda está na árvore antes de chamar setState 69 + if (mounted) { 70 + setState(() { 71 + _labelsError = 'Failed to load content labels: $e'; 72 + _isLoadingLabels = false; 73 + }); 74 + } 75 + } 76 + } 77 + 78 + // Update adult content label preferences based on hideAdultContent setting 79 + Future<void> _updateAdultContentPreferences(bool hideAdultContent) async { 80 + final settingsService = Provider.of<SettingsService>(context, listen: false); 81 + 82 + // For each label definition that has adultOnly: true 83 + for (final entry in _labelDefinitions.entries) { 84 + final labelValue = entry.key; 85 + final definition = entry.value; 86 + 87 + // Check if this is an adult-only label 88 + final bool isAdultOnly = definition['adultOnly'] as bool? ?? false; 89 + 90 + if (isAdultOnly) { 91 + // Set the preference based on the hideAdultContent setting 92 + final newPreference = hideAdultContent 93 + ? LabelPreference.hide 94 + : LabelPreference.show; 95 + 96 + await settingsService.setLabelPreference( 97 + LabelerManager.defaultLabelerDid, 98 + labelValue, 99 + newPreference 100 + ); 101 + } 102 + } 103 + 104 + // Force rebuild 105 + setState(() {}); 24 106 } 25 107 26 108 @override ··· 47 129 // Add extra padding at the top for the notch/camera hole 48 130 SizedBox(height: topPadding), 49 131 _buildHeader(context, textColor), 50 - Expanded(child: _buildFeedList(isDark)), 132 + 133 + // Tab bar 134 + TabBar( 135 + controller: _tabController, 136 + labelColor: textColor, 137 + unselectedLabelColor: textColor.withAlpha(127), 138 + tabs: const [ 139 + Tab(text: "Feed"), 140 + Tab(text: "Content"), 141 + ], 142 + ), 143 + 144 + // Tab view 145 + Expanded( 146 + child: TabBarView( 147 + controller: _tabController, 148 + children: [ 149 + _buildFeedList(isDark), 150 + _buildContentSettings(isDark, textColor), 151 + ], 152 + ), 153 + ), 154 + 51 155 // Bottom safe area 52 156 SizedBox(height: MediaQuery.of(context).padding.bottom), 53 157 ], ··· 126 230 }, 127 231 ); 128 232 } 233 + 234 + Widget _buildContentSettings(bool isDark, Color textColor) { 235 + final itemColor = isDark ? Colors.grey.shade800 : Colors.grey.shade200; 236 + 237 + if (_isLoadingLabels) { 238 + return const Center(child: CircularProgressIndicator()); 239 + } 240 + 241 + if (_labelsError != null) { 242 + return Center( 243 + child: Padding( 244 + padding: const EdgeInsets.all(24.0), 245 + child: Column( 246 + mainAxisSize: MainAxisSize.min, 247 + children: [ 248 + // TODO: normalized textstyle 249 + Text(_labelsError!, style: TextStyle(color: AppColors.red, fontSize: 16)), 250 + const SizedBox(height: 24), 251 + // TODO: normalized button 252 + ElevatedButton( 253 + onPressed: _loadLabelDefinitions, 254 + style: ElevatedButton.styleFrom( 255 + backgroundColor: AppColors.pink, 256 + foregroundColor: Colors.white, 257 + ), 258 + child: const Text('Retry'), 259 + ), 260 + ], 261 + ), 262 + ), 263 + ); 264 + } 265 + 266 + if (_labelDefinitions.isEmpty) { 267 + return Center( 268 + child: Text( 269 + 'No content labels available', 270 + style: TextStyle(color: textColor), 271 + ), 272 + ); 273 + } 274 + 275 + // Sort labels: adult content first, then regular content 276 + List<String> sortedLabels = _labelDefinitions.keys.toList(); 277 + sortedLabels.sort((a, b) { 278 + bool isAdultA = _labelDefinitions[a]?['adultOnly'] as bool? ?? false; 279 + bool isAdultB = _labelDefinitions[b]?['adultOnly'] as bool? ?? false; 280 + 281 + // Adult labels first (true before false) 282 + if (isAdultA && !isAdultB) return -1; 283 + if (!isAdultA && isAdultB) return 1; 284 + 285 + // If both are adult or both are not adult, sort alphabetically 286 + return a.compareTo(b); 287 + }); 288 + 289 + return ListView.builder( 290 + itemCount: _labelDefinitions.length + 1, // +1 for the Adult Content switch 291 + padding: const EdgeInsets.symmetric(horizontal: 16), 292 + itemBuilder: (context, index) { 293 + // Add the Adult Content switch at the top 294 + if (index == 0) { 295 + return Consumer<SettingsService>( 296 + builder: (context, settingsService, _) { 297 + final hideAdultContent = settingsService.hideAdultContent; 298 + 299 + return FeedSettingItem( 300 + feedName: 'Hide Adult Content', 301 + description: 'Hide all posts with adult content labels', 302 + isEnabled: hideAdultContent, 303 + itemColor: itemColor, 304 + textColor: textColor, 305 + onToggleChanged: (value) async { 306 + // Update the setting 307 + await settingsService.setHideAdultContent(value); 308 + 309 + // Update all adult-only label preferences 310 + await _updateAdultContentPreferences(value); 311 + }, 312 + ); 313 + }, 314 + ); 315 + } 316 + 317 + // Adjust index for label definitions using our sorted list 318 + final labelsIndex = index - 1; 319 + final labelValue = sortedLabels[labelsIndex]; 320 + final definition = _labelDefinitions[labelValue]; 321 + 322 + // Extract info from the updated label definition format 323 + String displayName = labelValue; 324 + String description = ''; 325 + 326 + if (definition != null) { 327 + // Check for the new format with locales 328 + if (definition['locales'] != null) { 329 + final locales = definition['locales'] as List<dynamic>; 330 + if (locales.isNotEmpty) { 331 + // Get the first locale (assumed to be English) 332 + final enLocale = locales.first; 333 + displayName = enLocale['name'] as String? ?? definition['displayName'] as String? ?? labelValue; 334 + description = enLocale['description'] as String? ?? definition['description'] as String? ?? ''; 335 + } 336 + } else { 337 + // Fall back to the old format 338 + displayName = definition['displayName'] as String? ?? labelValue; 339 + description = definition['description'] as String? ?? ''; 340 + } 341 + } 342 + 343 + return ContentLabelPreference( 344 + labelValue: labelValue, 345 + displayName: displayName, 346 + description: description, 347 + itemColor: itemColor, 348 + textColor: textColor, 349 + ); 350 + }, 351 + ); 352 + } 353 + } 354 + 355 + class ContentLabelPreference extends StatefulWidget { 356 + final String labelValue; 357 + final String displayName; 358 + final String description; 359 + final Color itemColor; 360 + final Color textColor; 361 + 362 + const ContentLabelPreference({ 363 + super.key, 364 + required this.labelValue, 365 + required this.displayName, 366 + required this.description, 367 + required this.itemColor, 368 + required this.textColor, 369 + }); 370 + 371 + @override 372 + State<ContentLabelPreference> createState() => _ContentLabelPreferenceState(); 373 + } 374 + 375 + class _ContentLabelPreferenceState extends State<ContentLabelPreference> { 376 + @override 377 + Widget build(BuildContext context) { 378 + return Consumer<SettingsService>( 379 + builder: (context, settingsService, _) { 380 + // Get the label definition to check if it's adult-only 381 + final labelerManager = Provider.of<LabelerManager>(context); 382 + final definitions = labelerManager.getLabelDefinitions(LabelerManager.defaultLabelerDid); 383 + final definition = definitions[widget.labelValue]; 384 + 385 + // Use defaultSetting from the label definition if no user preference is set 386 + final preference = settingsService.getLabelPreferenceOrDefault( 387 + LabelerManager.defaultLabelerDid, 388 + widget.labelValue, 389 + definition 390 + ); 391 + final selectedValue = preference.name; 392 + 393 + // Get default setting to display in UI 394 + String defaultSetting = 'warn'; // Fallback default 395 + if (definition != null && definition.containsKey('defaultSetting')) { 396 + defaultSetting = definition['defaultSetting'] as String; 397 + } 398 + 399 + final bool isAdultOnly = definition?['adultOnly'] as bool? ?? false; 400 + 401 + // If adult content is hidden, disable adult-only labels 402 + final hideAdultContent = settingsService.hideAdultContent; 403 + final bool isDisabled = isAdultOnly && hideAdultContent; 404 + 405 + return Padding( 406 + padding: const EdgeInsets.symmetric(vertical: 12), 407 + child: Material( 408 + color: Colors.transparent, 409 + child: Container( 410 + decoration: BoxDecoration( 411 + color: widget.itemColor, 412 + borderRadius: BorderRadius.circular(12), 413 + ), 414 + padding: const EdgeInsets.all(16.0), 415 + child: Column( 416 + crossAxisAlignment: CrossAxisAlignment.start, 417 + children: [ 418 + Row( 419 + children: [ 420 + Expanded( 421 + child: Text( 422 + widget.displayName, 423 + style: TextStyle( 424 + color: widget.textColor, 425 + fontSize: 16, 426 + fontWeight: FontWeight.bold, 427 + ), 428 + ), 429 + ), 430 + if (isAdultOnly) 431 + Container( 432 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 433 + decoration: BoxDecoration( 434 + color: Colors.red.withAlpha(51), 435 + borderRadius: BorderRadius.circular(4), 436 + ), 437 + child: Text( 438 + 'Adult', 439 + style: TextStyle( 440 + color: Colors.red, 441 + fontSize: 12, 442 + fontWeight: FontWeight.bold, 443 + ), 444 + ), 445 + ), 446 + ], 447 + ), 448 + if (widget.description.isNotEmpty) ...[ 449 + const SizedBox(height: 4), 450 + Text( 451 + widget.description, 452 + style: TextStyle( 453 + color: widget.textColor.withAlpha(179), 454 + fontSize: 12, 455 + ), 456 + ), 457 + ], 458 + 459 + // Show default setting info 460 + Row( 461 + children: [ 462 + Text( 463 + 'Default: ', 464 + style: TextStyle( 465 + color: widget.textColor.withAlpha(179), 466 + fontSize: 12, 467 + ), 468 + ), 469 + Container( 470 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 471 + decoration: BoxDecoration( 472 + color: _getColorForSetting(defaultSetting).withAlpha(51), 473 + borderRadius: BorderRadius.circular(4), 474 + ), 475 + child: Text( 476 + defaultSetting.capitalize(), 477 + style: TextStyle( 478 + color: _getColorForSetting(defaultSetting), 479 + fontSize: 12, 480 + fontWeight: FontWeight.bold, 481 + ), 482 + ), 483 + ), 484 + const Spacer(), 485 + ], 486 + ), 487 + 488 + const SizedBox(height: 12), 489 + 490 + // SegmentedButton for content preference 491 + SegmentedButton<String>( 492 + segments: [ 493 + ButtonSegment<String>( 494 + value: 'show', 495 + label: const Text('Show'), 496 + icon: const Icon(Icons.visibility), 497 + ), 498 + ButtonSegment<String>( 499 + value: 'warn', 500 + label: const Text('Warn'), 501 + icon: const Icon(Icons.warning), 502 + ), 503 + ButtonSegment<String>( 504 + value: 'hide', 505 + label: const Text('Hide'), 506 + icon: const Icon(Icons.visibility_off), 507 + ), 508 + ], 509 + selected: {isDisabled ? 'hide' : selectedValue}, 510 + onSelectionChanged: isDisabled 511 + ? null // Disable selection change if adult content is hidden 512 + : (selection) async { 513 + final newPreference = LabelPreference.values.firstWhere( 514 + (pref) => pref.name == selection.first, 515 + ); 516 + 517 + await settingsService.setLabelPreference( 518 + LabelerManager.defaultLabelerDid, 519 + widget.labelValue, 520 + newPreference, 521 + ); 522 + 523 + // Force rebuild to reflect the new selection 524 + setState(() {}); 525 + }, 526 + style: SegmentedButton.styleFrom( 527 + backgroundColor: widget.textColor.withAlpha(26), 528 + selectedBackgroundColor: AppColors.pink, 529 + selectedForegroundColor: Colors.white, 530 + foregroundColor: widget.textColor, 531 + ), 532 + ), 533 + ], 534 + ), 535 + ), 536 + ), 537 + ); 538 + }, 539 + ); 540 + } 541 + 542 + // Helper to get color based on setting 543 + Color _getColorForSetting(String setting) { 544 + switch (setting) { 545 + case 'show': 546 + return Colors.green; 547 + case 'warn': 548 + return Colors.orange; 549 + case 'hide': 550 + return Colors.red; 551 + default: 552 + return Colors.grey; 553 + } 554 + } 555 + } 556 + 557 + // Extension to capitalize strings 558 + extension StringExtension on String { 559 + String capitalize() { 560 + return "${this[0].toUpperCase()}${substring(1)}"; 561 + } 129 562 } 130 563 131 564 class FeedSetting { ··· 167 600 title: Text(feedName, style: TextStyle(color: textColor, fontSize: 16)), 168 601 subtitle: 169 602 description != null 170 - ? Text(description!, style: TextStyle(color: textColor.withOpacity(0.7), fontSize: 12)) 603 + ? Text(description!, style: TextStyle(color: textColor.withAlpha(179), fontSize: 12)) 171 604 : null, 172 605 trailing: Switch( 173 606 value: isEnabled,