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

Configure Feed

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

feat: moderation UI for profiles, posts, and search results

+1970 -296
+13 -13
docs/tasks/phase-4.md
··· 67 67 ## M17 — Labelers & Content Moderation 68 68 69 69 - [x] Fetch user's labeler subscriptions from preferences via `app.bsky.actor.getPreferences` (`labelersPref`) 70 - - [ ] Include subscribed labeler DIDs in `atproto-accept-labelers` header on all XRPC requests 70 + - [x] Include subscribed labeler DIDs in `atproto-accept-labelers` header on all XRPC requests 71 71 - [x] `ModerationService` — wraps the `bluesky` package's `moderatePost`, `moderateProfile`, `moderateNotification` functions 72 - - [ ] Run moderation decisions on all displayed posts and profiles 73 - - [ ] Apply `ModerationUI` results: filter, blur, alert, inform per display context (contentList, contentView, contentMedia, avatar, profileList, profileView) 74 - - [ ] Blur overlay on posts/media with click-through "Show content" button 75 - - [ ] Warning badges on profiles and posts for alert/inform labels 76 - - [ ] Content filtering — remove posts with `filter` decisions from feed and notification lists 77 - - [ ] Labeler management screen: list subscribed labelers via `app.bsky.labeler.getServices` 78 - - [ ] Subscribe / unsubscribe to labelers by updating `labelersPref` via `putPreferences` 79 - - [ ] Per-label preference configuration: ignore / warn / hide per label value per labeler 80 - - [ ] Store label preferences as `contentLabelPref` entries via `putPreferences` 81 - - [ ] Adult content toggle (requires `adultContentEnabled` preference) 82 - - [ ] Self-label support — render self-labels embedded in posts and profiles 83 - - [ ] Labeler detail screen: show labeler creator, policies, and custom label definitions with localised names 72 + - [x] Run moderation decisions on all displayed posts and profiles 73 + - [x] Apply `ModerationUI` results: filter, blur, alert, inform per display context (contentList, contentView, contentMedia, avatar, profileList, profileView) 74 + - [x] Blur overlay on posts/media with click-through "Show content" button 75 + - [x] Warning badges on profiles and posts for alert/inform labels 76 + - [x] Content filtering — remove posts with `filter` decisions from feed and notification lists 77 + - [x] Labeler management screen: list subscribed labelers via `app.bsky.labeler.getServices` 78 + - [x] Subscribe / unsubscribe to labelers by updating `labelersPref` via `putPreferences` 79 + - [x] Per-label preference configuration: ignore / warn / hide per label value per labeler 80 + - [x] Store label preferences as `contentLabelPref` entries via `putPreferences` 81 + - [x] Adult content toggle (requires `adultContentEnabled` preference) 82 + - [x] Self-label support — render self-labels embedded in posts and profiles 83 + - [x] Labeler detail screen: show labeler creator, policies, and custom label definitions with localised names 84 84 - [x] Drift table: `labeler_cache` (labeler_did, policies_json, fetched_at) for offline label definition lookup 85 85 86 86 ## M18 — Lists
+13
lib/core/router/app_router.dart
··· 33 33 import 'package:lazurite/features/messages/presentation/convo_list_screen.dart'; 34 34 import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 35 35 import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; 36 + import 'package:lazurite/features/moderation/presentation/screens/labeler_detail_screen.dart'; 37 + import 'package:lazurite/features/moderation/presentation/screens/moderation_settings_screen.dart'; 36 38 import 'package:lazurite/features/settings/presentation/about_screen.dart'; 37 39 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 38 40 ··· 180 182 path: 'settings', 181 183 builder: (context, state) => const SettingsScreen(), 182 184 routes: [ 185 + GoRoute( 186 + path: 'moderation', 187 + builder: (context, state) => const ModerationSettingsScreen(), 188 + routes: [ 189 + GoRoute( 190 + path: 'detail', 191 + builder: (context, state) => 192 + LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? ''), 193 + ), 194 + ], 195 + ), 183 196 GoRoute(path: 'about', builder: (context, state) => const AboutScreen()), 184 197 GoRoute(path: 'logs', builder: (context, state) => const LogsScreen()), 185 198 GoRoute(path: 'devtools', builder: (context, state) => const DevToolsScreen()),
+17 -15
lib/features/feed/presentation/post_thread_screen.dart
··· 3 3 4 4 import 'package:bluesky/app_bsky_feed_defs.dart'; 5 5 import 'package:bluesky/app_bsky_feed_post.dart'; 6 + import 'package:bluesky/moderation.dart' as bsky_moderation; 6 7 import 'package:flutter/material.dart'; 7 8 import 'package:flutter/services.dart'; 8 9 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 17 18 import 'package:lazurite/features/feed/presentation/widgets/post_action_bar.dart'; 18 19 import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 19 20 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 21 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 22 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 20 23 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 21 24 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 22 25 import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; ··· 170 173 PostCardWithActions( 171 174 feedViewPost: FeedViewPost(post: parents[i].post), 172 175 accountDid: accountDid, 176 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, 173 177 ), 174 178 _buildThreadConnector(context), 175 179 ], ··· 338 342 PostCardWithActions( 339 343 feedViewPost: FeedViewPost(post: thread.post), 340 344 accountDid: accountDid, 345 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, 341 346 ), 342 347 for (final reply in replies) 343 348 ThreadReplyNode( ··· 408 413 Widget build(BuildContext context) { 409 414 final colorScheme = Theme.of(context).colorScheme; 410 415 final timestamp = _parsePostRecord(post.record)?.createdAt ?? post.indexedAt; 416 + final moderationService = maybeModerationService(context); 417 + final avatarUi = 418 + moderationService?.profileBasicUi(post.author, bsky_moderation.ModerationBehaviorContext.avatar) ?? 419 + const bsky_moderation.ModerationUI(); 411 420 412 421 return Row( 413 422 crossAxisAlignment: CrossAxisAlignment.start, 414 423 children: [ 415 - Container( 416 - width: 40, 417 - height: 40, 418 - decoration: BoxDecoration( 419 - color: colorScheme.surfaceContainerHighest, 420 - border: Border.all(color: colorScheme.outlineVariant), 421 - ), 422 - child: post.author.avatar != null 423 - ? Image.network(post.author.avatar!, fit: BoxFit.cover) 424 - : Center( 425 - child: Text( 426 - _initials(post.author.displayName ?? post.author.handle), 427 - style: Theme.of(context).textTheme.labelLarge, 428 - ), 429 - ), 424 + ModeratedAvatar( 425 + size: 40, 426 + ui: avatarUi, 427 + imageUrl: post.author.avatar, 428 + initials: _initials(post.author.displayName ?? post.author.handle), 429 + shape: BoxShape.rectangle, 430 + border: Border.all(color: colorScheme.outlineVariant), 430 431 ), 431 432 const SizedBox(width: 12), 432 433 Expanded( ··· 653 654 654 655 return PostCard( 655 656 feedViewPost: FeedViewPost(post: post), 657 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, 656 658 actionBar: Padding( 657 659 padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), 658 660 child: Column(
+89 -67
lib/features/feed/presentation/widgets/grid_post_card.dart
··· 2 2 import 'package:bluesky/app_bsky_embed_recordwithmedia.dart'; 3 3 import 'package:bluesky/app_bsky_feed_defs.dart'; 4 4 import 'package:bluesky/app_bsky_feed_post.dart'; 5 + import 'package:bluesky/moderation.dart' as bsky_moderation; 5 6 import 'package:flutter/material.dart'; 6 7 import 'package:go_router/go_router.dart'; 7 8 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 8 9 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 9 10 import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; 10 11 import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; 12 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 13 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 14 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 15 + import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 11 16 12 17 const _greyscale = ColorFilter.matrix(<double>[ 13 18 0.2126, ··· 41 46 /// 42 47 /// Text-only posts (no images) use expanded [titleMedium] body text. 43 48 class GridPostCard extends StatelessWidget { 44 - const GridPostCard({super.key, required this.feedViewPost, this.footer, this.onTap}); 49 + const GridPostCard({ 50 + super.key, 51 + required this.feedViewPost, 52 + this.footer, 53 + this.onTap, 54 + this.moderationContext = bsky_moderation.ModerationBehaviorContext.contentList, 55 + }); 45 56 46 57 final FeedViewPost feedViewPost; 47 58 48 59 /// Optional footer widget. Defaults to a read-only [PostCardFooter] when null. 49 60 final Widget? footer; 50 61 final VoidCallback? onTap; 62 + final bsky_moderation.ModerationBehaviorContext moderationContext; 51 63 52 64 @override 53 65 Widget build(BuildContext context) { ··· 57 69 final bodyText = record?.text ?? ''; 58 70 final colorScheme = Theme.of(context).colorScheme; 59 71 final isCompactGrid = MediaQuery.of(context).size.width >= 600; 72 + final moderationService = maybeModerationService(context); 73 + final postUi = moderationService?.postUi(post, moderationContext) ?? const bsky_moderation.ModerationUI(); 74 + final mediaUi = 75 + moderationService?.postUi(post, bsky_moderation.ModerationBehaviorContext.contentMedia) ?? 76 + const bsky_moderation.ModerationUI(); 60 77 61 78 final contentEmbed = primaryImageUrl == null && post.embed != null 62 79 ? PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!, compact: isCompactGrid) ··· 69 86 border: Border.all(color: colorScheme.outlineVariant), 70 87 color: colorScheme.surfaceContainerLowest, 71 88 ), 72 - child: InkWell( 73 - onTap: onTap, 74 - child: Column( 75 - crossAxisAlignment: CrossAxisAlignment.start, 76 - children: [ 77 - if (primaryImageUrl != null) 78 - AspectRatio( 79 - aspectRatio: 1.0, 80 - child: ColorFiltered( 81 - colorFilter: _greyscale, 82 - child: Image.network( 83 - primaryImageUrl, 84 - fit: BoxFit.cover, 85 - width: double.infinity, 86 - errorBuilder: (_, _, _) => 87 - ColoredBox(color: colorScheme.surfaceContainerHigh, child: const SizedBox.expand()), 89 + child: ModeratedBlurOverlay( 90 + ui: postUi, 91 + child: InkWell( 92 + onTap: onTap, 93 + child: Column( 94 + crossAxisAlignment: CrossAxisAlignment.start, 95 + children: [ 96 + if (primaryImageUrl != null) 97 + ModeratedBlurOverlay( 98 + ui: mediaUi, 99 + fillWidth: false, 100 + child: AspectRatio( 101 + aspectRatio: 1.0, 102 + child: ColorFiltered( 103 + colorFilter: _greyscale, 104 + child: Image.network( 105 + primaryImageUrl, 106 + fit: BoxFit.cover, 107 + width: double.infinity, 108 + errorBuilder: (_, _, _) => 109 + ColoredBox(color: colorScheme.surfaceContainerHigh, child: const SizedBox.expand()), 110 + ), 111 + ), 88 112 ), 89 113 ), 90 - ), 91 - Padding( 92 - padding: const EdgeInsets.all(16), 93 - child: Column( 94 - crossAxisAlignment: CrossAxisAlignment.start, 95 - children: [ 96 - _buildAuthorRow(context, post.author), 97 - if (bodyText.isNotEmpty) ...[ 98 - const SizedBox(height: 8), 99 - if (primaryImageUrl == null && contentEmbed == null) 100 - FacetText( 101 - text: bodyText, 102 - facets: record?.facets, 103 - style: feedPostBodyTextStyle(context), 104 - maxLines: 6, 105 - overflow: TextOverflow.ellipsis, 106 - ) 107 - else if (!isCompactGrid) 108 - FacetText(text: bodyText, facets: record?.facets, style: feedPostBodyTextStyle(context)) 109 - else 110 - FacetText( 111 - text: bodyText, 112 - facets: record?.facets, 113 - style: feedPostBodyTextStyle(context, compact: true), 114 - maxLines: 2, 115 - overflow: TextOverflow.ellipsis, 116 - ), 117 - ], 118 - if (contentEmbed != null) ...[ 119 - const SizedBox(height: 8), 120 - _buildEmbedPreview(contentEmbed, compact: isCompactGrid), 114 + Padding( 115 + padding: const EdgeInsets.all(16), 116 + child: Column( 117 + crossAxisAlignment: CrossAxisAlignment.start, 118 + children: [ 119 + _buildAuthorRow(context, post.author), 120 + if (postUi.alert || postUi.inform) ...[const SizedBox(height: 10), ModerationBadgeRow(ui: postUi)], 121 + if (bodyText.isNotEmpty) ...[ 122 + const SizedBox(height: 8), 123 + if (primaryImageUrl == null && contentEmbed == null) 124 + FacetText( 125 + text: bodyText, 126 + facets: record?.facets, 127 + style: feedPostBodyTextStyle(context), 128 + maxLines: 6, 129 + overflow: TextOverflow.ellipsis, 130 + ) 131 + else if (!isCompactGrid) 132 + FacetText(text: bodyText, facets: record?.facets, style: feedPostBodyTextStyle(context)) 133 + else 134 + FacetText( 135 + text: bodyText, 136 + facets: record?.facets, 137 + style: feedPostBodyTextStyle(context, compact: true), 138 + maxLines: 2, 139 + overflow: TextOverflow.ellipsis, 140 + ), 141 + ], 142 + if (contentEmbed != null) ...[ 143 + const SizedBox(height: 8), 144 + _buildEmbedPreview(contentEmbed, compact: isCompactGrid), 145 + ], 121 146 ], 122 - ], 147 + ), 123 148 ), 124 - ), 125 - resolvedFooter, 126 - ], 149 + resolvedFooter, 150 + ], 151 + ), 127 152 ), 128 153 ), 129 154 ); ··· 131 156 132 157 Widget _buildAuthorRow(BuildContext context, ProfileViewBasic author) { 133 158 final colorScheme = Theme.of(context).colorScheme; 159 + final moderationService = maybeModerationService(context); 160 + final avatarUi = 161 + moderationService?.profileBasicUi(author, bsky_moderation.ModerationBehaviorContext.avatar) ?? 162 + const bsky_moderation.ModerationUI(); 134 163 return Row( 135 164 children: [ 136 165 GestureDetector( 137 166 key: const ValueKey('grid_post_card_avatar'), 138 167 onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), 139 - child: Container( 140 - width: 40, 141 - height: 40, 142 - decoration: BoxDecoration( 143 - color: colorScheme.surfaceContainerHighest, 144 - border: Border.all(color: colorScheme.outlineVariant), 145 - ), 146 - child: author.avatar != null 147 - ? Image.network(author.avatar!, fit: BoxFit.cover) 148 - : Center( 149 - child: Text( 150 - _initials(author.displayName ?? author.handle), 151 - style: Theme.of(context).textTheme.labelMedium, 152 - ), 153 - ), 168 + child: ModeratedAvatar( 169 + size: 40, 170 + ui: avatarUi, 171 + imageUrl: author.avatar, 172 + initials: _initials(author.displayName ?? author.handle), 173 + shape: BoxShape.rectangle, 174 + border: Border.all(color: colorScheme.outlineVariant), 175 + placeholderTextStyle: Theme.of(context).textTheme.labelMedium, 154 176 ), 155 177 ), 156 178 const SizedBox(width: 8),
+47 -33
lib/features/feed/presentation/widgets/post_card.dart
··· 1 1 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 2 import 'package:bluesky/app_bsky_feed_defs.dart'; 3 3 import 'package:bluesky/app_bsky_feed_post.dart'; 4 + import 'package:bluesky/moderation.dart' as bsky_moderation; 4 5 import 'package:flutter/material.dart'; 5 6 import 'package:go_router/go_router.dart'; 6 7 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 7 8 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 8 9 import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; 9 10 import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; 11 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 12 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 13 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 14 + import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 10 15 11 16 class PostCard extends StatelessWidget { 12 - const PostCard({super.key, required this.feedViewPost, this.actionBar, this.onTap}); 17 + const PostCard({ 18 + super.key, 19 + required this.feedViewPost, 20 + this.actionBar, 21 + this.onTap, 22 + this.moderationContext = bsky_moderation.ModerationBehaviorContext.contentList, 23 + }); 13 24 14 25 final FeedViewPost feedViewPost; 15 26 ··· 21 32 /// should do the same. 22 33 final Widget? actionBar; 23 34 final VoidCallback? onTap; 35 + final bsky_moderation.ModerationBehaviorContext moderationContext; 24 36 25 37 @override 26 38 Widget build(BuildContext context) { 27 39 final post = feedViewPost.post; 28 40 final record = _tryParseRecord(post.record); 29 41 final colorScheme = Theme.of(context).colorScheme; 42 + final moderationService = maybeModerationService(context); 43 + final postUi = moderationService?.postUi(post, moderationContext) ?? const bsky_moderation.ModerationUI(); 30 44 31 45 final resolvedFooter = actionBar ?? PostCardFooter(timestamp: formatPostTime(record?.createdAt ?? post.indexedAt)); 32 46 ··· 39 53 child: Column( 40 54 crossAxisAlignment: CrossAxisAlignment.start, 41 55 children: [ 42 - InkWell( 43 - onTap: onTap, 44 - child: Padding( 45 - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), 46 - child: Column( 47 - crossAxisAlignment: CrossAxisAlignment.start, 48 - children: [ 49 - _buildHeader(context, post.author), 50 - if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)], 51 - if (record != null && record.text.isNotEmpty) ...[ 52 - const SizedBox(height: 12), 53 - FacetText(text: record.text, facets: record.facets, style: feedPostBodyTextStyle(context)), 54 - ], 55 - if (post.embed != null) ...[ 56 + ModeratedBlurOverlay( 57 + ui: postUi, 58 + child: InkWell( 59 + onTap: onTap, 60 + child: Padding( 61 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), 62 + child: Column( 63 + crossAxisAlignment: CrossAxisAlignment.start, 64 + children: [ 65 + _buildHeader(context, post.author), 66 + if (postUi.alert || postUi.inform) ...[const SizedBox(height: 10), ModerationBadgeRow(ui: postUi)], 67 + if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)], 68 + if (record != null && record.text.isNotEmpty) ...[ 69 + const SizedBox(height: 12), 70 + FacetText(text: record.text, facets: record.facets, style: feedPostBodyTextStyle(context)), 71 + ], 72 + if (post.embed != null) ...[ 73 + const SizedBox(height: 12), 74 + PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!), 75 + ], 56 76 const SizedBox(height: 12), 57 - PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!), 58 77 ], 59 - const SizedBox(height: 12), 60 - ], 78 + ), 61 79 ), 62 80 ), 63 81 ), ··· 69 87 70 88 Widget _buildHeader(BuildContext context, ProfileViewBasic author) { 71 89 final colorScheme = Theme.of(context).colorScheme; 90 + final moderationService = maybeModerationService(context); 91 + final avatarUi = 92 + moderationService?.profileBasicUi(author, bsky_moderation.ModerationBehaviorContext.avatar) ?? 93 + const bsky_moderation.ModerationUI(); 72 94 return Row( 73 95 crossAxisAlignment: CrossAxisAlignment.start, 74 96 children: [ 75 97 GestureDetector( 76 98 key: const ValueKey('post_card_avatar'), 77 99 onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), 78 - child: Container( 79 - width: 40, 80 - height: 40, 81 - decoration: BoxDecoration( 82 - color: colorScheme.surfaceContainerHighest, 83 - border: Border.all(color: colorScheme.outlineVariant), 84 - ), 85 - child: author.avatar != null 86 - ? Image.network(author.avatar!, fit: BoxFit.cover) 87 - : Center( 88 - child: Text( 89 - _initials(author.displayName ?? author.handle), 90 - style: Theme.of(context).textTheme.labelLarge, 91 - ), 92 - ), 100 + child: ModeratedAvatar( 101 + size: 40, 102 + ui: avatarUi, 103 + imageUrl: author.avatar, 104 + initials: _initials(author.displayName ?? author.handle), 105 + shape: BoxShape.rectangle, 106 + border: Border.all(color: colorScheme.outlineVariant), 93 107 ), 94 108 ), 95 109 const SizedBox(width: 12),
+18 -2
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 2 2 import 'dart:convert'; 3 3 4 4 import 'package:bluesky/app_bsky_feed_defs.dart'; 5 + import 'package:bluesky/moderation.dart' as bsky_moderation; 5 6 import 'package:flutter/material.dart'; 6 7 import 'package:flutter/services.dart'; 7 8 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 24 25 required this.accountDid, 25 26 this.variant = PostCardVariant.linear, 26 27 this.onDeleted, 28 + this.moderationContext = bsky_moderation.ModerationBehaviorContext.contentList, 27 29 }); 28 30 29 31 final FeedViewPost feedViewPost; 30 32 final String accountDid; 31 33 final PostCardVariant variant; 32 34 final VoidCallback? onDeleted; 35 + final bsky_moderation.ModerationBehaviorContext moderationContext; 33 36 34 37 @override 35 38 Widget build(BuildContext context) { ··· 54 57 accountDid: accountDid, 55 58 variant: variant, 56 59 onDeleted: onDeleted, 60 + moderationContext: moderationContext, 57 61 ), 58 62 ); 59 63 } ··· 65 69 required this.accountDid, 66 70 required this.variant, 67 71 this.onDeleted, 72 + required this.moderationContext, 68 73 }); 69 74 70 75 final FeedViewPost feedViewPost; 71 76 final String accountDid; 72 77 final PostCardVariant variant; 73 78 final VoidCallback? onDeleted; 79 + final bsky_moderation.ModerationBehaviorContext moderationContext; 74 80 75 81 @override 76 82 Widget build(BuildContext context) { ··· 116 122 Widget _buildCard(BuildContext context) { 117 123 Future<Object?> onTap() => context.push('/post?uri=${Uri.encodeQueryComponent(feedViewPost.post.uri.toString())}'); 118 124 if (variant == PostCardVariant.grid) { 119 - return GridPostCard(feedViewPost: feedViewPost, footer: _buildFooter(context), onTap: onTap); 125 + return GridPostCard( 126 + feedViewPost: feedViewPost, 127 + footer: _buildFooter(context), 128 + onTap: onTap, 129 + moderationContext: moderationContext, 130 + ); 120 131 } 121 - return PostCard(feedViewPost: feedViewPost, actionBar: _buildFooter(context), onTap: onTap); 132 + return PostCard( 133 + feedViewPost: feedViewPost, 134 + actionBar: _buildFooter(context), 135 + onTap: onTap, 136 + moderationContext: moderationContext, 137 + ); 122 138 } 123 139 124 140 Widget _buildFooter(BuildContext context) {
+84 -64
lib/features/feed/presentation/widgets/post_embed_view.dart
··· 5 5 import 'package:bluesky/app_bsky_embed_video.dart'; 6 6 import 'package:bluesky/app_bsky_feed_defs.dart'; 7 7 import 'package:bluesky/app_bsky_feed_post.dart'; 8 + import 'package:bluesky/moderation.dart' as bsky_moderation; 8 9 import 'package:flutter/material.dart'; 9 10 import 'package:go_router/go_router.dart'; 10 11 import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; ··· 12 13 import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; 13 14 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 14 15 import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; 16 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 17 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 15 18 import 'package:url_launcher/url_launcher.dart'; 16 19 17 20 /// Renders the appropriate embed widget for a post embed. ··· 79 82 final crossAxisCount = images.length == 1 ? 1 : 2; 80 83 final childAspectRatio = images.length == 1 ? 16 / 9 : 1.0; 81 84 final postUri = feedViewPost.post.uri.toString(); 85 + final moderationService = maybeModerationService(context); 86 + final mediaUi = 87 + moderationService?.postUi(feedViewPost.post, bsky_moderation.ModerationBehaviorContext.contentMedia) ?? 88 + const bsky_moderation.ModerationUI(); 82 89 83 - return GridView.builder( 84 - shrinkWrap: true, 85 - physics: const NeverScrollableScrollPhysics(), 86 - itemCount: images.length, 87 - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 88 - crossAxisCount: crossAxisCount, 89 - crossAxisSpacing: 2, 90 - mainAxisSpacing: 2, 91 - childAspectRatio: childAspectRatio, 92 - ), 93 - itemBuilder: (context, index) { 94 - final image = images[index]; 95 - final heroTag = _imageHeroTag(postUri, index); 90 + return ModeratedBlurOverlay( 91 + ui: mediaUi, 92 + borderRadius: BorderRadius.circular(12), 93 + child: GridView.builder( 94 + shrinkWrap: true, 95 + physics: const NeverScrollableScrollPhysics(), 96 + itemCount: images.length, 97 + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 98 + crossAxisCount: crossAxisCount, 99 + crossAxisSpacing: 2, 100 + mainAxisSpacing: 2, 101 + childAspectRatio: childAspectRatio, 102 + ), 103 + itemBuilder: (context, index) { 104 + final image = images[index]; 105 + final heroTag = _imageHeroTag(postUri, index); 96 106 97 - return GestureDetector( 98 - onLongPressStart: (details) => _showImageContextMenu(context, details.globalPosition, image: image), 99 - child: InkWell( 100 - onTap: () => _openImageViewer(context, images, initialIndex: index), 101 - child: Hero( 102 - tag: heroTag, 103 - child: Image.network( 104 - image.thumb, 105 - fit: BoxFit.cover, 106 - errorBuilder: (_, _, _) => ColoredBox( 107 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 108 - child: const Center(child: Icon(Icons.image_not_supported_outlined)), 107 + return GestureDetector( 108 + onLongPressStart: (details) => _showImageContextMenu(context, details.globalPosition, image: image), 109 + child: InkWell( 110 + onTap: () => _openImageViewer(context, images, initialIndex: index), 111 + child: Hero( 112 + tag: heroTag, 113 + child: Image.network( 114 + image.thumb, 115 + fit: BoxFit.cover, 116 + errorBuilder: (_, _, _) => ColoredBox( 117 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 118 + child: const Center(child: Icon(Icons.image_not_supported_outlined)), 119 + ), 109 120 ), 110 121 ), 111 122 ), 112 - ), 113 - ); 114 - }, 123 + ); 124 + }, 125 + ), 115 126 ); 116 127 } 117 128 ··· 175 186 } 176 187 177 188 Widget _buildVideoEmbed(BuildContext context, EmbedVideoView video) { 178 - return InkWell( 179 - onTap: () => _openVideoViewer(context, video), 180 - child: Stack( 181 - alignment: Alignment.center, 182 - children: [ 183 - AspectRatio( 184 - aspectRatio: video.aspectRatio == null ? 16 / 9 : video.aspectRatio!.width / video.aspectRatio!.height, 185 - child: video.thumbnail == null 186 - ? ColoredBox( 187 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 188 - child: const SizedBox.expand(), 189 - ) 190 - : Image.network( 191 - video.thumbnail!, 192 - fit: BoxFit.cover, 193 - errorBuilder: (_, _, _) => ColoredBox( 189 + final moderationService = maybeModerationService(context); 190 + final mediaUi = 191 + moderationService?.postUi(feedViewPost.post, bsky_moderation.ModerationBehaviorContext.contentMedia) ?? 192 + const bsky_moderation.ModerationUI(); 193 + 194 + return ModeratedBlurOverlay( 195 + ui: mediaUi, 196 + borderRadius: BorderRadius.circular(12), 197 + child: InkWell( 198 + onTap: () => _openVideoViewer(context, video), 199 + child: Stack( 200 + alignment: Alignment.center, 201 + children: [ 202 + AspectRatio( 203 + aspectRatio: video.aspectRatio == null ? 16 / 9 : video.aspectRatio!.width / video.aspectRatio!.height, 204 + child: video.thumbnail == null 205 + ? ColoredBox( 194 206 color: Theme.of(context).colorScheme.surfaceContainerHighest, 195 207 child: const SizedBox.expand(), 208 + ) 209 + : Image.network( 210 + video.thumbnail!, 211 + fit: BoxFit.cover, 212 + errorBuilder: (_, _, _) => ColoredBox( 213 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 214 + child: const SizedBox.expand(), 215 + ), 196 216 ), 197 - ), 198 - ), 199 - Container( 200 - width: 56, 201 - height: 56, 202 - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), shape: BoxShape.circle), 203 - child: const Icon(Icons.play_arrow, color: Colors.white, size: 28), 204 - ), 205 - if (video.alt?.isNotEmpty ?? false) 206 - Positioned( 207 - left: 12, 208 - right: 12, 209 - bottom: 12, 210 - child: Text( 211 - video.alt!, 212 - maxLines: 2, 213 - overflow: TextOverflow.ellipsis, 214 - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white), 217 + ), 218 + Container( 219 + width: 56, 220 + height: 56, 221 + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), shape: BoxShape.circle), 222 + child: const Icon(Icons.play_arrow, color: Colors.white, size: 28), 223 + ), 224 + if (video.alt?.isNotEmpty ?? false) 225 + Positioned( 226 + left: 12, 227 + right: 12, 228 + bottom: 12, 229 + child: Text( 230 + video.alt!, 231 + maxLines: 2, 232 + overflow: TextOverflow.ellipsis, 233 + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white), 234 + ), 215 235 ), 216 - ), 217 - ], 236 + ], 237 + ), 218 238 ), 219 239 ); 220 240 }
+212
lib/features/moderation/presentation/moderation_ui_helpers.dart
··· 1 + import 'package:atproto/com_atproto_label_defs.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/moderation.dart' as bsky_moderation; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 7 + 8 + const officialBlueskyLabelerDid = 'did:plc:ar7c4by46qjdydhdevvrndac'; 9 + 10 + enum ModerationBadgeTone { alert, inform } 11 + 12 + class ModerationBadgeDescriptor { 13 + const ModerationBadgeDescriptor({required this.label, required this.description, required this.tone}); 14 + 15 + final String label; 16 + final String description; 17 + final ModerationBadgeTone tone; 18 + } 19 + 20 + ModerationService? maybeModerationService(BuildContext context) { 21 + try { 22 + return context.read<ModerationService>(); 23 + } catch (_) { 24 + return null; 25 + } 26 + } 27 + 28 + String formatLocalizedLabelName(List<LabelValueDefinitionStrings> locales, Locale locale, {required String fallback}) { 29 + if (locales.isEmpty) { 30 + return humanizeModerationLabel(fallback); 31 + } 32 + 33 + final exact = locales.where((entry) => entry.lang.toLowerCase() == locale.languageCode.toLowerCase()).firstOrNull; 34 + if (exact != null) { 35 + return exact.name; 36 + } 37 + 38 + final languageMatch = locales 39 + .where((entry) => entry.lang.toLowerCase().startsWith(locale.languageCode.toLowerCase())) 40 + .firstOrNull; 41 + if (languageMatch != null) { 42 + return languageMatch.name; 43 + } 44 + 45 + return locales.first.name; 46 + } 47 + 48 + String formatLocalizedLabelDescription( 49 + List<LabelValueDefinitionStrings> locales, 50 + Locale locale, { 51 + String fallback = '', 52 + }) { 53 + if (locales.isEmpty) { 54 + return fallback; 55 + } 56 + 57 + final exact = locales.where((entry) => entry.lang.toLowerCase() == locale.languageCode.toLowerCase()).firstOrNull; 58 + if (exact != null) { 59 + return exact.description; 60 + } 61 + 62 + final languageMatch = locales 63 + .where((entry) => entry.lang.toLowerCase().startsWith(locale.languageCode.toLowerCase())) 64 + .firstOrNull; 65 + if (languageMatch != null) { 66 + return languageMatch.description; 67 + } 68 + 69 + return locales.first.description; 70 + } 71 + 72 + List<ModerationBadgeDescriptor> moderationBadgesForUi(bsky_moderation.ModerationUI ui) { 73 + final badges = <ModerationBadgeDescriptor>[]; 74 + final seen = <String>{}; 75 + 76 + void addDescriptors(List<bsky_moderation.ModerationCause> causes, ModerationBadgeTone tone) { 77 + for (final cause in causes) { 78 + final descriptor = moderationDescriptorForCause(cause, tone: tone); 79 + final key = '${tone.name}:${descriptor.label}:${descriptor.description}'; 80 + if (seen.add(key)) { 81 + badges.add(descriptor); 82 + } 83 + } 84 + } 85 + 86 + addDescriptors(ui.alerts, ModerationBadgeTone.alert); 87 + addDescriptors(ui.informs, ModerationBadgeTone.inform); 88 + return badges; 89 + } 90 + 91 + List<String> moderationBlurLabels(bsky_moderation.ModerationUI ui) { 92 + final labels = <String>[]; 93 + final seen = <String>{}; 94 + 95 + for (final cause in ui.blurs) { 96 + final descriptor = moderationDescriptorForCause(cause, tone: ModerationBadgeTone.alert); 97 + if (seen.add(descriptor.label)) { 98 + labels.add(descriptor.label); 99 + } 100 + } 101 + 102 + return labels; 103 + } 104 + 105 + ModerationBadgeDescriptor moderationDescriptorForCause( 106 + bsky_moderation.ModerationCause cause, { 107 + required ModerationBadgeTone tone, 108 + }) { 109 + return cause.maybeWhen( 110 + label: (data) { 111 + final label = humanizeModerationLabel(data.labelDef.identifier); 112 + final source = data.labelDef.definedBy == officialBlueskyLabelerDid ? 'Bluesky' : 'Subscribed labeler'; 113 + return ModerationBadgeDescriptor(label: label, description: '$source label', tone: tone); 114 + }, 115 + muted: (_) => ModerationBadgeDescriptor( 116 + label: 'Muted account', 117 + description: 'Muted content is being downranked here', 118 + tone: tone, 119 + ), 120 + muteWord: (_) => ModerationBadgeDescriptor( 121 + label: 'Muted phrase', 122 + description: 'A muted phrase matched this content', 123 + tone: tone, 124 + ), 125 + blocking: (_) => 126 + ModerationBadgeDescriptor(label: 'Blocked account', description: 'This account is blocked', tone: tone), 127 + blockedBy: (_) => 128 + ModerationBadgeDescriptor(label: 'Blocked by account', description: 'This account has blocked you', tone: tone), 129 + blockOther: (_) => ModerationBadgeDescriptor( 130 + label: 'Blocked relationship', 131 + description: 'This content is limited by a block relationship', 132 + tone: tone, 133 + ), 134 + hidden: (_) => ModerationBadgeDescriptor( 135 + label: 'Hidden content', 136 + description: 'This content is hidden by moderation rules', 137 + tone: tone, 138 + ), 139 + orElse: () => ModerationBadgeDescriptor( 140 + label: tone == ModerationBadgeTone.alert ? 'Sensitive content' : 'Moderation note', 141 + description: 'Moderation guidance applies here', 142 + tone: tone, 143 + ), 144 + ); 145 + } 146 + 147 + String moderationOverlayTitle(bsky_moderation.ModerationUI ui, {String fallback = 'Sensitive content'}) { 148 + final labels = moderationBlurLabels(ui); 149 + if (labels.isEmpty) { 150 + return fallback; 151 + } 152 + if (labels.length == 1) { 153 + return labels.first; 154 + } 155 + return '${labels.first} +${labels.length - 1}'; 156 + } 157 + 158 + String humanizeModerationLabel(String value) { 159 + if (value.isEmpty) { 160 + return 'Sensitive content'; 161 + } 162 + 163 + final cleaned = value.replaceAll('!', '').replaceAll('-', ' ').trim(); 164 + final words = cleaned.split(RegExp(r'\s+')).where((word) => word.isNotEmpty); 165 + return words.map((word) => '${word[0].toUpperCase()}${word.substring(1)}').join(' '); 166 + } 167 + 168 + KnownContentLabelPrefVisibility resolveLabelPreference( 169 + List<UPreferences> preferences, { 170 + required String label, 171 + String? labelerDid, 172 + required KnownContentLabelPrefVisibility fallback, 173 + }) { 174 + ContentLabelPref? globalMatch; 175 + 176 + for (final preference in preferences) { 177 + if (!preference.isContentLabelPref) { 178 + continue; 179 + } 180 + 181 + final contentPref = preference.contentLabelPref!; 182 + if (contentPref.label != label) { 183 + continue; 184 + } 185 + if (contentPref.labelerDid == labelerDid) { 186 + return contentPref.visibility.knownValue ?? fallback; 187 + } 188 + if (contentPref.labelerDid == null) { 189 + globalMatch = contentPref; 190 + } 191 + } 192 + 193 + return globalMatch?.visibility.knownValue ?? fallback; 194 + } 195 + 196 + bool adultContentEnabledFromPreferences(List<UPreferences> preferences) { 197 + for (final preference in preferences) { 198 + if (preference.isAdultContentPref) { 199 + return preference.adultContentPref!.enabled; 200 + } 201 + } 202 + return false; 203 + } 204 + 205 + KnownContentLabelPrefVisibility visibilityFromDefaultSetting(LabelValueDefinitionDefaultSetting? defaultSetting) { 206 + final raw = defaultSetting?.knownValue; 207 + return switch (raw) { 208 + KnownLabelValueDefinitionDefaultSetting.ignore => KnownContentLabelPrefVisibility.ignore, 209 + KnownLabelValueDefinitionDefaultSetting.hide => KnownContentLabelPrefVisibility.hide, 210 + KnownLabelValueDefinitionDefaultSetting.warn || null => KnownContentLabelPrefVisibility.warn, 211 + }; 212 + }
+364
lib/features/moderation/presentation/screens/labeler_detail_screen.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:bluesky/app_bsky_labeler_defs.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 6 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 7 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 8 + 9 + class LabelerDetailScreen extends StatefulWidget { 10 + const LabelerDetailScreen({super.key, required this.did}); 11 + 12 + final String did; 13 + 14 + @override 15 + State<LabelerDetailScreen> createState() => _LabelerDetailScreenState(); 16 + } 17 + 18 + class _LabelerDetailScreenState extends State<LabelerDetailScreen> { 19 + Future<_LabelerDetailData>? _loadFuture; 20 + bool _isUpdatingSubscription = false; 21 + 22 + ModerationService get _service => context.read<ModerationService>(); 23 + 24 + @override 25 + void initState() { 26 + super.initState(); 27 + _reload(); 28 + } 29 + 30 + void _reload() { 31 + setState(() { 32 + _loadFuture = _loadData(); 33 + }); 34 + } 35 + 36 + Future<_LabelerDetailData> _loadData() async { 37 + await _service.ensureInitialized(); 38 + final details = await _service.getLabelerDetails(widget.did); 39 + if (details == null) { 40 + throw Exception('Labeler not found.'); 41 + } 42 + 43 + final currentLabelers = _service.currentPrefs?.labelers.map((labeler) => labeler.did).toSet() ?? const <String>{}; 44 + 45 + return _LabelerDetailData( 46 + labeler: details, 47 + adultContentEnabled: adultContentEnabledFromPreferences(_service.currentPreferences), 48 + currentPreferences: _service.currentPreferences, 49 + isSubscribed: currentLabelers.contains(widget.did), 50 + ); 51 + } 52 + 53 + Future<void> _toggleSubscription(bool value) async { 54 + setState(() => _isUpdatingSubscription = true); 55 + try { 56 + if (value) { 57 + await _service.subscribeToLabeler(widget.did); 58 + } else { 59 + await _service.unsubscribeFromLabeler(widget.did); 60 + } 61 + _reload(); 62 + } catch (error) { 63 + if (mounted) { 64 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$error'))); 65 + } 66 + } finally { 67 + if (mounted) { 68 + setState(() => _isUpdatingSubscription = false); 69 + } 70 + } 71 + } 72 + 73 + Future<void> _updatePreference({required String label, required KnownContentLabelPrefVisibility visibility}) async { 74 + try { 75 + await _service.setLabelPreference(label: label, labelerDid: widget.did, visibility: visibility); 76 + _reload(); 77 + } catch (error) { 78 + if (mounted) { 79 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update preference: $error'))); 80 + } 81 + } 82 + } 83 + 84 + @override 85 + Widget build(BuildContext context) { 86 + return Scaffold( 87 + appBar: AppBar(title: const Text('Labeler')), 88 + body: FutureBuilder<_LabelerDetailData>( 89 + future: _loadFuture, 90 + builder: (context, snapshot) { 91 + if (snapshot.connectionState != ConnectionState.done) { 92 + return const Center(child: CircularProgressIndicator()); 93 + } 94 + 95 + if (snapshot.hasError) { 96 + return Center( 97 + child: Padding( 98 + padding: const EdgeInsets.all(24), 99 + child: Column( 100 + mainAxisSize: MainAxisSize.min, 101 + children: [ 102 + Text('Unable to load labeler', style: Theme.of(context).textTheme.titleMedium), 103 + const SizedBox(height: 8), 104 + Text('${snapshot.error}', textAlign: TextAlign.center), 105 + const SizedBox(height: 16), 106 + FilledButton(onPressed: _reload, child: const Text('Retry')), 107 + ], 108 + ), 109 + ), 110 + ); 111 + } 112 + 113 + final data = snapshot.data!; 114 + final labeler = data.labeler; 115 + final creator = labeler.creator; 116 + final definitions = labeler.policies.labelValueDefinitions ?? const []; 117 + final locale = Localizations.localeOf(context); 118 + final isOfficial = widget.did == officialBlueskyLabelerDid; 119 + 120 + return ListView( 121 + padding: const EdgeInsets.all(16), 122 + children: [ 123 + Container( 124 + padding: const EdgeInsets.all(20), 125 + decoration: BoxDecoration( 126 + color: Theme.of(context).colorScheme.surfaceContainerLow, 127 + borderRadius: BorderRadius.circular(24), 128 + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), 129 + ), 130 + child: Column( 131 + crossAxisAlignment: CrossAxisAlignment.start, 132 + children: [ 133 + Row( 134 + crossAxisAlignment: CrossAxisAlignment.start, 135 + children: [ 136 + ModeratedAvatar( 137 + size: 64, 138 + imageUrl: creator.avatar, 139 + initials: _initials(creator.displayName ?? creator.handle), 140 + shape: BoxShape.circle, 141 + ), 142 + const SizedBox(width: 16), 143 + Expanded( 144 + child: Column( 145 + crossAxisAlignment: CrossAxisAlignment.start, 146 + children: [ 147 + Text( 148 + creator.displayName ?? creator.handle, 149 + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), 150 + ), 151 + const SizedBox(height: 4), 152 + Text( 153 + '@${creator.handle}', 154 + style: Theme.of( 155 + context, 156 + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 157 + ), 158 + ], 159 + ), 160 + ), 161 + ], 162 + ), 163 + if (creator.description?.isNotEmpty ?? false) ...[ 164 + const SizedBox(height: 16), 165 + Text(creator.description!, style: Theme.of(context).textTheme.bodyMedium), 166 + ], 167 + const SizedBox(height: 16), 168 + Wrap( 169 + spacing: 8, 170 + runSpacing: 8, 171 + children: [ 172 + _PolicyChip(label: '${definitions.length} custom labels'), 173 + _PolicyChip(label: '${labeler.policies.labelValues.length} published values'), 174 + if (isOfficial) const _PolicyChip(label: 'Built-in moderation'), 175 + ], 176 + ), 177 + const SizedBox(height: 16), 178 + SwitchListTile.adaptive( 179 + value: data.isSubscribed || isOfficial, 180 + onChanged: isOfficial || _isUpdatingSubscription ? null : _toggleSubscription, 181 + title: Text(isOfficial ? 'Built-in moderation' : 'Subscribed'), 182 + subtitle: Text( 183 + isOfficial 184 + ? 'This labeler is always active.' 185 + : 'Subscribed labelers are added to your moderation headers and preferences.', 186 + ), 187 + contentPadding: EdgeInsets.zero, 188 + ), 189 + ], 190 + ), 191 + ), 192 + const SizedBox(height: 24), 193 + Text( 194 + 'Published policies'.toUpperCase(), 195 + style: Theme.of( 196 + context, 197 + ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.8), 198 + ), 199 + const SizedBox(height: 8), 200 + Container( 201 + padding: const EdgeInsets.all(16), 202 + decoration: BoxDecoration( 203 + color: Theme.of(context).colorScheme.surfaceContainerLowest, 204 + borderRadius: BorderRadius.circular(20), 205 + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), 206 + ), 207 + child: Wrap( 208 + spacing: 8, 209 + runSpacing: 8, 210 + children: [for (final value in labeler.policies.labelValues) _PolicyChip(label: value.toJson())], 211 + ), 212 + ), 213 + const SizedBox(height: 24), 214 + Text( 215 + 'Label preferences'.toUpperCase(), 216 + style: Theme.of( 217 + context, 218 + ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.8), 219 + ), 220 + const SizedBox(height: 8), 221 + if (definitions.isEmpty) 222 + const _PreferenceCard( 223 + child: ListTile( 224 + title: Text('No custom label definitions'), 225 + subtitle: Text('This labeler publishes values, but not localized custom definitions.'), 226 + ), 227 + ) 228 + else 229 + for (final definition in definitions) ...[ 230 + _PreferenceCard( 231 + child: Padding( 232 + padding: const EdgeInsets.all(16), 233 + child: Column( 234 + crossAxisAlignment: CrossAxisAlignment.start, 235 + children: [ 236 + Text( 237 + formatLocalizedLabelName(definition.locales, locale, fallback: definition.identifier), 238 + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 239 + ), 240 + const SizedBox(height: 6), 241 + Text( 242 + formatLocalizedLabelDescription( 243 + definition.locales, 244 + locale, 245 + fallback: 'No description available for this label.', 246 + ), 247 + style: Theme.of( 248 + context, 249 + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 250 + ), 251 + const SizedBox(height: 12), 252 + Wrap( 253 + spacing: 8, 254 + runSpacing: 8, 255 + children: [ 256 + _PolicyChip(label: 'ID ${definition.identifier}'), 257 + _PolicyChip(label: 'Blur ${definition.blurs.toJson()}'), 258 + _PolicyChip(label: 'Severity ${definition.severity.toJson()}'), 259 + _PolicyChip( 260 + label: 'Default ${visibilityFromDefaultSetting(definition.defaultSetting).name}', 261 + ), 262 + if (definition.adultOnly ?? false) const _PolicyChip(label: '18+'), 263 + ], 264 + ), 265 + const SizedBox(height: 16), 266 + SegmentedButton<KnownContentLabelPrefVisibility>( 267 + segments: const [ 268 + ButtonSegment(value: KnownContentLabelPrefVisibility.ignore, label: Text('Ignore')), 269 + ButtonSegment(value: KnownContentLabelPrefVisibility.warn, label: Text('Warn')), 270 + ButtonSegment(value: KnownContentLabelPrefVisibility.hide, label: Text('Hide')), 271 + ], 272 + selected: { 273 + resolveLabelPreference( 274 + data.currentPreferences, 275 + label: definition.identifier, 276 + labelerDid: widget.did, 277 + fallback: visibilityFromDefaultSetting(definition.defaultSetting), 278 + ), 279 + }, 280 + onSelectionChanged: (definition.adultOnly ?? false) && !data.adultContentEnabled 281 + ? null 282 + : (selection) => 283 + _updatePreference(label: definition.identifier, visibility: selection.first), 284 + ), 285 + if ((definition.adultOnly ?? false) && !data.adultContentEnabled) ...[ 286 + const SizedBox(height: 10), 287 + Text( 288 + 'Enable adult content to change this 18+ label.', 289 + style: Theme.of( 290 + context, 291 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 292 + ), 293 + ], 294 + ], 295 + ), 296 + ), 297 + ), 298 + const SizedBox(height: 12), 299 + ], 300 + ], 301 + ); 302 + }, 303 + ), 304 + ); 305 + } 306 + 307 + String _initials(String value) { 308 + final parts = value.trim().split(RegExp(r'\s+')).where((part) => part.isNotEmpty).take(2).toList(); 309 + if (parts.isEmpty) { 310 + return '?'; 311 + } 312 + return parts.map((part) => part[0].toUpperCase()).join(); 313 + } 314 + } 315 + 316 + class _LabelerDetailData { 317 + const _LabelerDetailData({ 318 + required this.labeler, 319 + required this.adultContentEnabled, 320 + required this.currentPreferences, 321 + required this.isSubscribed, 322 + }); 323 + 324 + final LabelerViewDetailed labeler; 325 + final bool adultContentEnabled; 326 + final List<UPreferences> currentPreferences; 327 + final bool isSubscribed; 328 + } 329 + 330 + class _PolicyChip extends StatelessWidget { 331 + const _PolicyChip({required this.label}); 332 + 333 + final String label; 334 + 335 + @override 336 + Widget build(BuildContext context) { 337 + return Container( 338 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), 339 + decoration: BoxDecoration( 340 + color: Theme.of(context).colorScheme.surfaceContainerHigh, 341 + borderRadius: BorderRadius.circular(999), 342 + ), 343 + child: Text(label, style: Theme.of(context).textTheme.labelSmall), 344 + ); 345 + } 346 + } 347 + 348 + class _PreferenceCard extends StatelessWidget { 349 + const _PreferenceCard({required this.child}); 350 + 351 + final Widget child; 352 + 353 + @override 354 + Widget build(BuildContext context) { 355 + return Container( 356 + decoration: BoxDecoration( 357 + color: Theme.of(context).colorScheme.surfaceContainerLowest, 358 + borderRadius: BorderRadius.circular(20), 359 + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), 360 + ), 361 + child: child, 362 + ); 363 + } 364 + }
+509
lib/features/moderation/presentation/screens/moderation_settings_screen.dart
··· 1 + import 'package:bluesky/app_bsky_labeler_defs.dart'; 2 + import 'package:bluesky/app_bsky_labeler_getservices.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:go_router/go_router.dart'; 6 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 7 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 8 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 9 + 10 + class ModerationSettingsScreen extends StatefulWidget { 11 + const ModerationSettingsScreen({super.key}); 12 + 13 + @override 14 + State<ModerationSettingsScreen> createState() => _ModerationSettingsScreenState(); 15 + } 16 + 17 + class _ModerationSettingsScreenState extends State<ModerationSettingsScreen> { 18 + Future<_ModerationSettingsData>? _loadFuture; 19 + bool _isUpdatingAdultContent = false; 20 + 21 + ModerationService get _service => context.read<ModerationService>(); 22 + 23 + @override 24 + void initState() { 25 + super.initState(); 26 + _reload(); 27 + } 28 + 29 + void _reload() { 30 + setState(() { 31 + _loadFuture = _loadData(); 32 + }); 33 + } 34 + 35 + Future<_ModerationSettingsData> _loadData() async { 36 + await _service.ensureInitialized(); 37 + 38 + final labelers = await _service.getSubscribedLabelers(); 39 + LabelerViewDetailed? officialLabeler; 40 + try { 41 + officialLabeler = await _service.getLabelerDetails(officialBlueskyLabelerDid); 42 + } catch (_) { 43 + officialLabeler = null; 44 + } 45 + 46 + return _ModerationSettingsData( 47 + adultContentEnabled: adultContentEnabledFromPreferences(_service.currentPreferences), 48 + subscribedLabelers: labelers 49 + .where((view) => view.isLabelerViewDetailed) 50 + .map((view) => view.labelerViewDetailed!) 51 + .toList(), 52 + officialLabeler: officialLabeler, 53 + ); 54 + } 55 + 56 + Future<void> _toggleAdultContent(bool enabled) async { 57 + setState(() => _isUpdatingAdultContent = true); 58 + try { 59 + await _service.setAdultContentEnabled(enabled); 60 + _reload(); 61 + } catch (error) { 62 + if (mounted) { 63 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update adult content: $error'))); 64 + } 65 + } finally { 66 + if (mounted) { 67 + setState(() => _isUpdatingAdultContent = false); 68 + } 69 + } 70 + } 71 + 72 + Future<void> _unsubscribe(String did) async { 73 + try { 74 + await _service.unsubscribeFromLabeler(did); 75 + _reload(); 76 + } catch (error) { 77 + if (mounted) { 78 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to unsubscribe: $error'))); 79 + } 80 + } 81 + } 82 + 83 + Future<void> _showAddLabelerDialog() async { 84 + final controller = TextEditingController(); 85 + String? errorText; 86 + 87 + await showDialog<void>( 88 + context: context, 89 + builder: (dialogContext) { 90 + var isSubmitting = false; 91 + 92 + return StatefulBuilder( 93 + builder: (context, setDialogState) { 94 + Future<void> submit() async { 95 + final did = controller.text.trim(); 96 + if (did.isEmpty) { 97 + setDialogState(() => errorText = 'Enter a labeler DID.'); 98 + return; 99 + } 100 + 101 + setDialogState(() { 102 + isSubmitting = true; 103 + errorText = null; 104 + }); 105 + 106 + try { 107 + final details = await _service.getLabelerDetails(did); 108 + if (details == null) { 109 + setDialogState(() => errorText = 'No labeler found for that DID.'); 110 + return; 111 + } 112 + 113 + await _service.subscribeToLabeler(details.creator.did); 114 + 115 + if (dialogContext.mounted) { 116 + Navigator.of(dialogContext).pop(); 117 + _reload(); 118 + 119 + if (context.mounted) { 120 + final name = details.creator.displayName ?? details.creator.handle; 121 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Subscribed to $name'))); 122 + } 123 + } 124 + } catch (error) { 125 + setDialogState(() => errorText = '$error'); 126 + } finally { 127 + setDialogState(() => isSubmitting = false); 128 + } 129 + } 130 + 131 + return AlertDialog( 132 + title: const Text('Add labeler'), 133 + content: SizedBox( 134 + width: 420, 135 + child: Column( 136 + mainAxisSize: MainAxisSize.min, 137 + children: [ 138 + TextField( 139 + controller: controller, 140 + autofocus: true, 141 + decoration: InputDecoration( 142 + labelText: 'Labeler DID', 143 + hintText: 'did:plc:examplelabeler', 144 + errorText: errorText, 145 + border: const OutlineInputBorder(), 146 + ), 147 + onSubmitted: (_) => isSubmitting ? null : submit(), 148 + ), 149 + const SizedBox(height: 12), 150 + Text( 151 + 'Paste a labeler DID to review and subscribe to its labels.', 152 + style: Theme.of(context).textTheme.bodySmall, 153 + ), 154 + ], 155 + ), 156 + ), 157 + actions: [ 158 + TextButton( 159 + onPressed: isSubmitting ? null : () => Navigator.of(dialogContext).pop(), 160 + child: const Text('Cancel'), 161 + ), 162 + FilledButton(onPressed: isSubmitting ? null : submit, child: Text(isSubmitting ? 'Adding...' : 'Add')), 163 + ], 164 + ); 165 + }, 166 + ); 167 + }, 168 + ); 169 + } 170 + 171 + @override 172 + Widget build(BuildContext context) { 173 + return Scaffold( 174 + appBar: AppBar( 175 + title: const Text('Moderation'), 176 + actions: [IconButton(tooltip: 'Refresh', onPressed: _reload, icon: const Icon(Icons.refresh))], 177 + ), 178 + body: FutureBuilder<_ModerationSettingsData>( 179 + future: _loadFuture, 180 + builder: (context, snapshot) { 181 + if (snapshot.connectionState != ConnectionState.done) { 182 + return const Center(child: CircularProgressIndicator()); 183 + } 184 + 185 + if (snapshot.hasError) { 186 + return Center( 187 + child: Padding( 188 + padding: const EdgeInsets.all(24), 189 + child: Column( 190 + mainAxisSize: MainAxisSize.min, 191 + children: [ 192 + Text('Failed to load moderation settings', style: Theme.of(context).textTheme.titleMedium), 193 + const SizedBox(height: 8), 194 + Text('${snapshot.error}', textAlign: TextAlign.center), 195 + const SizedBox(height: 16), 196 + FilledButton(onPressed: _reload, child: const Text('Retry')), 197 + ], 198 + ), 199 + ), 200 + ); 201 + } 202 + 203 + final data = snapshot.data!; 204 + final labelers = data.subscribedLabelers; 205 + 206 + return RefreshIndicator( 207 + onRefresh: () async => _reload(), 208 + child: ListView( 209 + padding: const EdgeInsets.all(16), 210 + children: [ 211 + const _SettingsHero( 212 + title: 'Labelers and content moderation', 213 + subtitle: 214 + 'Manage adult-content visibility, subscribed labelers, and the rules each labeler applies to posts and profiles.', 215 + ), 216 + const SizedBox(height: 16), 217 + _SettingsCard( 218 + child: SwitchListTile.adaptive( 219 + value: data.adultContentEnabled, 220 + onChanged: _isUpdatingAdultContent ? null : _toggleAdultContent, 221 + title: const Text('Adult content'), 222 + subtitle: const Text('Required before 18+ label preferences can be changed.'), 223 + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), 224 + ), 225 + ), 226 + const SizedBox(height: 24), 227 + _SectionHeader( 228 + title: 'Built-in labeler', 229 + trailing: Text( 230 + 'Always on', 231 + style: Theme.of( 232 + context, 233 + ).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.primary), 234 + ), 235 + ), 236 + const SizedBox(height: 8), 237 + if (data.officialLabeler != null) 238 + _LabelerCard( 239 + labeler: data.officialLabeler!, 240 + isSubscribed: true, 241 + isOfficial: true, 242 + onTap: () => context.push( 243 + '/settings/moderation/detail?did=${Uri.encodeQueryComponent(data.officialLabeler!.creator.did)}', 244 + ), 245 + ) 246 + else 247 + const _SettingsCard( 248 + child: ListTile( 249 + title: Text('Bluesky moderation'), 250 + subtitle: Text('The built-in labeler is active even if its details cannot be loaded right now.'), 251 + ), 252 + ), 253 + const SizedBox(height: 24), 254 + _SectionHeader( 255 + title: 'Custom labelers', 256 + trailing: FilledButton.tonalIcon( 257 + onPressed: _showAddLabelerDialog, 258 + icon: const Icon(Icons.add), 259 + label: Text('Add (${labelers.length}/20)'), 260 + ), 261 + ), 262 + const SizedBox(height: 8), 263 + if (labelers.isEmpty) 264 + const _SettingsCard( 265 + child: ListTile( 266 + title: Text('No custom labelers'), 267 + subtitle: Text('Add a labeler DID to subscribe and configure its custom labels.'), 268 + ), 269 + ) 270 + else 271 + for (final labeler in labelers) ...[ 272 + _LabelerCard( 273 + labeler: labeler, 274 + isSubscribed: true, 275 + onTap: () => context.push( 276 + '/settings/moderation/detail?did=${Uri.encodeQueryComponent(labeler.creator.did)}', 277 + ), 278 + onUnsubscribe: () => _unsubscribe(labeler.creator.did), 279 + ), 280 + const SizedBox(height: 12), 281 + ], 282 + ], 283 + ), 284 + ); 285 + }, 286 + ), 287 + ); 288 + } 289 + } 290 + 291 + class _ModerationSettingsData { 292 + const _ModerationSettingsData({ 293 + required this.adultContentEnabled, 294 + required this.subscribedLabelers, 295 + required this.officialLabeler, 296 + }); 297 + 298 + final bool adultContentEnabled; 299 + final List<LabelerViewDetailed> subscribedLabelers; 300 + final LabelerViewDetailed? officialLabeler; 301 + } 302 + 303 + class _SettingsHero extends StatelessWidget { 304 + const _SettingsHero({required this.title, required this.subtitle}); 305 + 306 + final String title; 307 + final String subtitle; 308 + 309 + @override 310 + Widget build(BuildContext context) { 311 + final colorScheme = Theme.of(context).colorScheme; 312 + 313 + return Container( 314 + padding: const EdgeInsets.all(20), 315 + decoration: BoxDecoration( 316 + color: colorScheme.surfaceContainerHigh, 317 + borderRadius: BorderRadius.circular(24), 318 + border: Border.all(color: colorScheme.outlineVariant), 319 + ), 320 + child: Column( 321 + crossAxisAlignment: CrossAxisAlignment.start, 322 + children: [ 323 + Text( 324 + title.toUpperCase(), 325 + style: Theme.of(context).textTheme.labelLarge?.copyWith(letterSpacing: 1.1, fontWeight: FontWeight.w700), 326 + ), 327 + const SizedBox(height: 10), 328 + Text(title, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700)), 329 + const SizedBox(height: 8), 330 + Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), 331 + ], 332 + ), 333 + ); 334 + } 335 + } 336 + 337 + class _SectionHeader extends StatelessWidget { 338 + const _SectionHeader({required this.title, this.trailing}); 339 + 340 + final String title; 341 + final Widget? trailing; 342 + 343 + @override 344 + Widget build(BuildContext context) { 345 + return Row( 346 + children: [ 347 + Expanded( 348 + child: Text( 349 + title.toUpperCase(), 350 + style: Theme.of(context).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.8), 351 + ), 352 + ), 353 + ?trailing, 354 + ], 355 + ); 356 + } 357 + } 358 + 359 + class _SettingsCard extends StatelessWidget { 360 + const _SettingsCard({required this.child}); 361 + 362 + final Widget child; 363 + 364 + @override 365 + Widget build(BuildContext context) { 366 + return Container( 367 + decoration: BoxDecoration( 368 + color: Theme.of(context).colorScheme.surfaceContainerLowest, 369 + borderRadius: BorderRadius.circular(20), 370 + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), 371 + ), 372 + child: child, 373 + ); 374 + } 375 + } 376 + 377 + class _LabelerCard extends StatelessWidget { 378 + const _LabelerCard({ 379 + required this.labeler, 380 + required this.isSubscribed, 381 + required this.onTap, 382 + this.isOfficial = false, 383 + this.onUnsubscribe, 384 + }); 385 + 386 + final LabelerViewDetailed labeler; 387 + final bool isSubscribed; 388 + final bool isOfficial; 389 + final VoidCallback onTap; 390 + final VoidCallback? onUnsubscribe; 391 + 392 + @override 393 + Widget build(BuildContext context) { 394 + final creator = labeler.creator; 395 + final definitions = labeler.policies.labelValueDefinitions ?? const []; 396 + 397 + return _SettingsCard( 398 + child: InkWell( 399 + borderRadius: BorderRadius.circular(20), 400 + onTap: onTap, 401 + child: Padding( 402 + padding: const EdgeInsets.all(16), 403 + child: Row( 404 + crossAxisAlignment: CrossAxisAlignment.start, 405 + children: [ 406 + ModeratedAvatar( 407 + size: 52, 408 + imageUrl: creator.avatar, 409 + initials: _initials(creator.displayName ?? creator.handle), 410 + shape: BoxShape.circle, 411 + ), 412 + const SizedBox(width: 14), 413 + Expanded( 414 + child: Column( 415 + crossAxisAlignment: CrossAxisAlignment.start, 416 + children: [ 417 + Row( 418 + children: [ 419 + Expanded( 420 + child: Text( 421 + creator.displayName ?? creator.handle, 422 + maxLines: 1, 423 + overflow: TextOverflow.ellipsis, 424 + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 425 + ), 426 + ), 427 + if (isOfficial) 428 + Container( 429 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 430 + decoration: BoxDecoration( 431 + color: Theme.of(context).colorScheme.primaryContainer, 432 + borderRadius: BorderRadius.circular(999), 433 + ), 434 + child: Text( 435 + 'Built-in', 436 + style: Theme.of(context).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700), 437 + ), 438 + ), 439 + ], 440 + ), 441 + const SizedBox(height: 2), 442 + Text( 443 + '@${creator.handle}', 444 + style: Theme.of( 445 + context, 446 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 447 + ), 448 + if (creator.description?.isNotEmpty ?? false) ...[ 449 + const SizedBox(height: 8), 450 + Text( 451 + creator.description!, 452 + maxLines: 2, 453 + overflow: TextOverflow.ellipsis, 454 + style: Theme.of(context).textTheme.bodyMedium, 455 + ), 456 + ], 457 + const SizedBox(height: 10), 458 + Wrap( 459 + spacing: 8, 460 + runSpacing: 8, 461 + children: [ 462 + _MetaChip(label: '${definitions.length} definitions'), 463 + _MetaChip(label: '${labeler.policies.labelValues.length} published values'), 464 + ], 465 + ), 466 + ], 467 + ), 468 + ), 469 + const SizedBox(width: 12), 470 + Column( 471 + children: [ 472 + Icon(isSubscribed ? Icons.chevron_right : Icons.add_circle_outline), 473 + if (!isOfficial && onUnsubscribe != null) 474 + TextButton(onPressed: onUnsubscribe, child: const Text('Unsubscribe')), 475 + ], 476 + ), 477 + ], 478 + ), 479 + ), 480 + ), 481 + ); 482 + } 483 + 484 + String _initials(String value) { 485 + final parts = value.trim().split(RegExp(r'\s+')).where((part) => part.isNotEmpty).take(2).toList(); 486 + if (parts.isEmpty) { 487 + return '?'; 488 + } 489 + return parts.map((part) => part[0].toUpperCase()).join(); 490 + } 491 + } 492 + 493 + class _MetaChip extends StatelessWidget { 494 + const _MetaChip({required this.label}); 495 + 496 + final String label; 497 + 498 + @override 499 + Widget build(BuildContext context) { 500 + return Container( 501 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), 502 + decoration: BoxDecoration( 503 + color: Theme.of(context).colorScheme.surfaceContainerHigh, 504 + borderRadius: BorderRadius.circular(999), 505 + ), 506 + child: Text(label, style: Theme.of(context).textTheme.labelSmall), 507 + ); 508 + } 509 + }
+56
lib/features/moderation/presentation/widgets/moderated_avatar.dart
··· 1 + import 'package:bluesky/moderation.dart' as bsky_moderation; 2 + import 'package:flutter/material.dart'; 3 + 4 + class ModeratedAvatar extends StatelessWidget { 5 + const ModeratedAvatar({ 6 + super.key, 7 + required this.size, 8 + required this.initials, 9 + this.imageUrl, 10 + this.ui, 11 + this.shape = BoxShape.circle, 12 + this.borderRadius, 13 + this.border, 14 + this.placeholderTextStyle, 15 + }); 16 + 17 + final double size; 18 + final String initials; 19 + final String? imageUrl; 20 + final bsky_moderation.ModerationUI? ui; 21 + final BoxShape shape; 22 + final BorderRadius? borderRadius; 23 + final Border? border; 24 + final TextStyle? placeholderTextStyle; 25 + 26 + @override 27 + Widget build(BuildContext context) { 28 + final colorScheme = Theme.of(context).colorScheme; 29 + final shouldMask = ui?.blur ?? false; 30 + 31 + return Container( 32 + width: size, 33 + height: size, 34 + decoration: BoxDecoration( 35 + color: colorScheme.surfaceContainerHighest, 36 + shape: shape, 37 + borderRadius: shape == BoxShape.rectangle ? borderRadius : null, 38 + border: border, 39 + ), 40 + clipBehavior: Clip.antiAlias, 41 + child: shouldMask 42 + ? Icon(Icons.shield_outlined, color: colorScheme.onSurfaceVariant, size: size * 0.44) 43 + : imageUrl != null 44 + ? Image.network(imageUrl!, fit: BoxFit.cover, errorBuilder: (_, _, _) => _buildFallback(context)) 45 + : _buildFallback(context), 46 + ); 47 + } 48 + 49 + Widget _buildFallback(BuildContext context) { 50 + final theme = Theme.of(context); 51 + return ColoredBox( 52 + color: theme.colorScheme.surfaceContainerHighest, 53 + child: Center(child: Text(initials, style: placeholderTextStyle ?? theme.textTheme.labelLarge)), 54 + ); 55 + } 56 + }
+107
lib/features/moderation/presentation/widgets/moderated_blur_overlay.dart
··· 1 + import 'dart:ui'; 2 + 3 + import 'package:bluesky/moderation.dart' as bsky_moderation; 4 + import 'package:flutter/material.dart'; 5 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 6 + 7 + class ModeratedBlurOverlay extends StatefulWidget { 8 + const ModeratedBlurOverlay({ 9 + super.key, 10 + required this.ui, 11 + required this.child, 12 + this.borderRadius, 13 + this.fallbackLabel = 'Sensitive content', 14 + this.fillWidth = true, 15 + }); 16 + 17 + final bsky_moderation.ModerationUI ui; 18 + final Widget child; 19 + final BorderRadius? borderRadius; 20 + final String fallbackLabel; 21 + final bool fillWidth; 22 + 23 + @override 24 + State<ModeratedBlurOverlay> createState() => _ModeratedBlurOverlayState(); 25 + } 26 + 27 + class _ModeratedBlurOverlayState extends State<ModeratedBlurOverlay> { 28 + bool _revealed = false; 29 + 30 + @override 31 + Widget build(BuildContext context) { 32 + if (!widget.ui.blur || _revealed) { 33 + return widget.child; 34 + } 35 + 36 + final colorScheme = Theme.of(context).colorScheme; 37 + final canReveal = !widget.ui.noOverride; 38 + 39 + Widget content = Stack( 40 + fit: StackFit.passthrough, 41 + children: [ 42 + ImageFiltered( 43 + imageFilter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), 44 + child: ColorFiltered( 45 + colorFilter: ColorFilter.mode(colorScheme.surface.withValues(alpha: 0.22), BlendMode.srcATop), 46 + child: widget.child, 47 + ), 48 + ), 49 + Positioned.fill( 50 + child: DecoratedBox( 51 + decoration: BoxDecoration( 52 + color: colorScheme.surface.withValues(alpha: 0.72), 53 + borderRadius: widget.borderRadius, 54 + ), 55 + child: Padding( 56 + padding: const EdgeInsets.all(16), 57 + child: Center( 58 + child: ConstrainedBox( 59 + constraints: const BoxConstraints(maxWidth: 320), 60 + child: Column( 61 + mainAxisSize: MainAxisSize.min, 62 + children: [ 63 + Icon(Icons.visibility_off_outlined, color: colorScheme.onSurface, size: 24), 64 + const SizedBox(height: 10), 65 + Text( 66 + moderationOverlayTitle(widget.ui, fallback: widget.fallbackLabel), 67 + textAlign: TextAlign.center, 68 + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), 69 + ), 70 + const SizedBox(height: 6), 71 + Text( 72 + canReveal 73 + ? 'Hidden by your moderation settings. You can reveal it for this view.' 74 + : 'Hidden by your moderation settings and cannot be revealed here.', 75 + textAlign: TextAlign.center, 76 + style: Theme.of( 77 + context, 78 + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, height: 1.35), 79 + ), 80 + if (canReveal) ...[ 81 + const SizedBox(height: 14), 82 + FilledButton.tonal( 83 + onPressed: () => setState(() => _revealed = true), 84 + child: const Text('Show content'), 85 + ), 86 + ], 87 + ], 88 + ), 89 + ), 90 + ), 91 + ), 92 + ), 93 + ), 94 + ], 95 + ); 96 + 97 + if (widget.borderRadius != null) { 98 + content = ClipRRect(borderRadius: widget.borderRadius!, child: content); 99 + } 100 + 101 + if (widget.fillWidth) { 102 + return SizedBox(width: double.infinity, child: content); 103 + } 104 + 105 + return content; 106 + } 107 + }
+58
lib/features/moderation/presentation/widgets/moderation_badge_row.dart
··· 1 + import 'package:bluesky/moderation.dart' as bsky_moderation; 2 + import 'package:flutter/material.dart'; 3 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 4 + 5 + class ModerationBadgeRow extends StatelessWidget { 6 + const ModerationBadgeRow({super.key, required this.ui, this.padding = EdgeInsets.zero}); 7 + 8 + final bsky_moderation.ModerationUI ui; 9 + final EdgeInsetsGeometry padding; 10 + 11 + @override 12 + Widget build(BuildContext context) { 13 + final badges = moderationBadgesForUi(ui); 14 + if (badges.isEmpty) { 15 + return const SizedBox.shrink(); 16 + } 17 + 18 + final colorScheme = Theme.of(context).colorScheme; 19 + 20 + Widget chipFor(ModerationBadgeDescriptor descriptor) { 21 + final isAlert = descriptor.tone == ModerationBadgeTone.alert; 22 + final background = isAlert 23 + ? colorScheme.errorContainer.withValues(alpha: 0.7) 24 + : colorScheme.secondaryContainer.withValues(alpha: 0.85); 25 + final foreground = isAlert ? colorScheme.onErrorContainer : colorScheme.onSecondaryContainer; 26 + 27 + return Tooltip( 28 + message: descriptor.description, 29 + child: Container( 30 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), 31 + decoration: BoxDecoration( 32 + color: background, 33 + borderRadius: BorderRadius.circular(999), 34 + border: Border.all(color: foreground.withValues(alpha: 0.15)), 35 + ), 36 + child: Row( 37 + mainAxisSize: MainAxisSize.min, 38 + children: [ 39 + Icon(isAlert ? Icons.warning_amber_rounded : Icons.info_outline, size: 14, color: foreground), 40 + const SizedBox(width: 6), 41 + Text( 42 + descriptor.label, 43 + style: Theme.of( 44 + context, 45 + ).textTheme.labelSmall?.copyWith(color: foreground, fontWeight: FontWeight.w700, letterSpacing: 0.2), 46 + ), 47 + ], 48 + ), 49 + ), 50 + ); 51 + } 52 + 53 + return Padding( 54 + padding: padding, 55 + child: Wrap(spacing: 8, runSpacing: 8, children: [for (final badge in badges) chipFor(badge)]), 56 + ); 57 + } 58 + }
+48 -46
lib/features/notifications/presentation/widgets/notification_list_item.dart
··· 1 1 import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 2 + import 'package:bluesky/moderation.dart' as bsky_moderation; 2 3 import 'package:flutter/material.dart' hide Notification; 3 4 import 'package:go_router/go_router.dart'; 4 5 import 'package:intl/intl.dart'; 6 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 7 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 8 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 9 + import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 5 10 6 11 class NotificationListItem extends StatelessWidget { 7 12 const NotificationListItem({super.key, required this.notification}); ··· 12 17 Widget build(BuildContext context) { 13 18 final theme = Theme.of(context); 14 19 final isUnread = !notification.isRead; 20 + final moderationService = maybeModerationService(context); 21 + final notificationUi = 22 + moderationService?.notificationUi(notification, bsky_moderation.ModerationBehaviorContext.contentList) ?? 23 + const bsky_moderation.ModerationUI(); 15 24 16 25 return InkWell( 17 26 onTap: () => _onTap(context), ··· 31 40 child: Column( 32 41 crossAxisAlignment: CrossAxisAlignment.start, 33 42 children: [ 34 - _buildActorRow(), 43 + _buildActorRow(context), 35 44 const SizedBox(height: 4), 36 45 _buildSummary(theme), 37 46 const SizedBox(height: 2), 38 47 _buildTime(theme), 39 - if (_shouldShowPreview) ...[const SizedBox(height: 8), _buildPreview(theme)], 48 + if (notificationUi.alert || notificationUi.inform) ...[ 49 + const SizedBox(height: 8), 50 + ModerationBadgeRow(ui: notificationUi), 51 + ], 52 + if (_shouldShowPreview) ...[const SizedBox(height: 8), _buildPreview(context, theme)], 40 53 ], 41 54 ), 42 55 ), ··· 100 113 ); 101 114 } 102 115 103 - Widget _buildActorRow() { 116 + Widget _buildActorRow(BuildContext context) { 104 117 final author = notification.author; 105 - final avatarUrl = author.avatar; 118 + final moderationService = maybeModerationService(context); 119 + final avatarUi = 120 + moderationService?.profileUi(author, bsky_moderation.ModerationBehaviorContext.avatar) ?? 121 + const bsky_moderation.ModerationUI(); 106 122 107 123 return Row( 108 124 children: [ 109 - Container( 110 - width: 28, 111 - height: 28, 112 - decoration: BoxDecoration(color: Colors.grey.shade300, shape: BoxShape.circle), 113 - child: avatarUrl != null 114 - ? ClipOval( 115 - child: Image.network( 116 - avatarUrl, 117 - width: 28, 118 - height: 28, 119 - fit: BoxFit.cover, 120 - errorBuilder: (_, _, _) => _buildAvatarPlaceholder(), 121 - ), 122 - ) 123 - : _buildAvatarPlaceholder(), 125 + ModeratedAvatar( 126 + size: 28, 127 + ui: avatarUi, 128 + imageUrl: author.avatar, 129 + initials: _getInitials(author.displayName ?? author.handle), 130 + shape: BoxShape.circle, 131 + placeholderTextStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.black54), 124 132 ), 125 133 ], 126 134 ); 127 135 } 128 136 129 - Widget _buildAvatarPlaceholder() { 130 - final author = notification.author; 131 - final displayName = author.displayName; 132 - final handle = author.handle; 133 - final initials = _getInitials(displayName ?? handle); 134 - 135 - return Center( 136 - child: Text( 137 - initials, 138 - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.black54), 139 - ), 140 - ); 141 - } 142 - 143 137 String _getInitials(String text) { 144 138 final parts = text.split(' '); 145 139 if (parts.length >= 2) { ··· 229 223 return false; 230 224 } 231 225 232 - Widget _buildPreview(ThemeData theme) { 226 + Widget _buildPreview(BuildContext context, ThemeData theme) { 233 227 final record = notification.record; 234 228 final text = record['text'] as String?; 229 + final moderationService = maybeModerationService(context); 230 + final notificationUi = 231 + moderationService?.notificationUi(notification, bsky_moderation.ModerationBehaviorContext.contentList) ?? 232 + const bsky_moderation.ModerationUI(); 235 233 236 234 if (text == null || text.isEmpty) { 237 235 return const SizedBox.shrink(); 238 236 } 239 237 240 - return Container( 241 - padding: const EdgeInsets.all(10), 242 - decoration: BoxDecoration( 243 - color: theme.colorScheme.surfaceContainerHighest, 244 - borderRadius: BorderRadius.circular(8), 245 - border: Border.all(color: theme.dividerColor), 246 - ), 247 - child: Text( 248 - text, 249 - maxLines: 2, 250 - overflow: TextOverflow.ellipsis, 251 - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 238 + return ModeratedBlurOverlay( 239 + ui: notificationUi, 240 + borderRadius: BorderRadius.circular(8), 241 + child: Container( 242 + padding: const EdgeInsets.all(10), 243 + decoration: BoxDecoration( 244 + color: theme.colorScheme.surfaceContainerHighest, 245 + borderRadius: BorderRadius.circular(8), 246 + border: Border.all(color: theme.dividerColor), 247 + ), 248 + child: Text( 249 + text, 250 + maxLines: 2, 251 + overflow: TextOverflow.ellipsis, 252 + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 253 + ), 252 254 ), 253 255 ); 254 256 }
+28 -24
lib/features/profile/presentation/profile_screen.dart
··· 1 1 import 'dart:ui'; 2 2 3 3 import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:bluesky/moderation.dart' as bsky_moderation; 4 5 import 'package:flutter/material.dart'; 5 6 import 'package:flutter/services.dart'; 6 7 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 12 13 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 13 14 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 14 15 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 16 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 17 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 18 + import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 15 19 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 16 20 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 17 21 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; ··· 237 241 238 242 Widget _buildSquareAvatar(BuildContext context, ProfileViewDetailed? profile, double size) { 239 243 final colorScheme = Theme.of(context).colorScheme; 240 - final avatarUrl = profile?.avatar; 244 + final moderationService = maybeModerationService(context); 245 + final avatarUi = profile == null 246 + ? const bsky_moderation.ModerationUI() 247 + : moderationService?.profileDetailedUi(profile, bsky_moderation.ModerationBehaviorContext.avatar) ?? 248 + const bsky_moderation.ModerationUI(); 241 249 242 - return Container( 250 + return SizedBox( 243 251 key: const ValueKey('profile_square_avatar'), 244 252 width: size, 245 253 height: size, 246 - decoration: BoxDecoration( 247 - color: colorScheme.surfaceContainerHighest, 254 + child: ModeratedAvatar( 255 + size: size, 256 + ui: avatarUi, 257 + imageUrl: profile?.avatar, 258 + initials: _initials(profile?.displayName ?? profile?.handle ?? '?'), 259 + shape: BoxShape.rectangle, 248 260 border: Border.all(color: colorScheme.surfaceContainerLowest, width: 4), 249 - ), 250 - child: avatarUrl != null 251 - ? Image.network( 252 - avatarUrl, 253 - fit: BoxFit.cover, 254 - errorBuilder: (_, _, _) => _buildAvatarInitials(context, profile), 255 - ) 256 - : _buildAvatarInitials(context, profile), 257 - ); 258 - } 259 - 260 - Widget _buildAvatarInitials(BuildContext context, ProfileViewDetailed? profile) { 261 - return ColoredBox( 262 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 263 - child: Center( 264 - child: Text( 265 - _initials(profile?.displayName ?? profile?.handle ?? '?'), 266 - style: Theme.of(context).textTheme.headlineSmall, 267 - ), 261 + placeholderTextStyle: Theme.of(context).textTheme.headlineSmall, 268 262 ), 269 263 ); 270 264 } ··· 290 284 291 285 final colorScheme = Theme.of(context).colorScheme; 292 286 final textTheme = Theme.of(context).textTheme; 287 + final moderationService = maybeModerationService(context); 288 + final profileUi = 289 + moderationService?.profileDetailedUi(profile, bsky_moderation.ModerationBehaviorContext.profileView) ?? 290 + const bsky_moderation.ModerationUI(); 293 291 294 292 final metaChildren = <Widget>[ 295 293 if (profile.pronouns?.isNotEmpty ?? false) ··· 316 314 const SizedBox(height: 4), 317 315 318 316 Text('@${profile.handle}', style: textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)), 317 + if (profileUi.alert || profileUi.inform) ...[const SizedBox(height: 10), ModerationBadgeRow(ui: profileUi)], 319 318 if (profile.description?.isNotEmpty ?? false) ...[ 320 319 const SizedBox(height: 12), 321 320 ··· 562 561 feedViewPost: feedState.posts[index], 563 562 accountDid: accountDid, 564 563 variant: PostCardVariant.grid, 564 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 565 565 ), 566 566 ), 567 567 ), ··· 594 594 child: Center(child: CircularProgressIndicator()), 595 595 ); 596 596 } 597 - return PostCardWithActions(feedViewPost: feedState.posts[index], accountDid: _resolvedActor ?? ''); 597 + return PostCardWithActions( 598 + feedViewPost: feedState.posts[index], 599 + accountDid: _resolvedActor ?? '', 600 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 601 + ); 598 602 }, 599 603 ), 600 604 ),
+71 -32
lib/features/search/presentation/search_screen.dart
··· 1 1 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 2 import 'package:bluesky/app_bsky_feed_defs.dart'; 3 3 import 'package:bluesky/app_bsky_feed_post.dart'; 4 + import 'package:bluesky/moderation.dart' as bsky_moderation; 4 5 import 'package:flutter/material.dart'; 5 6 import 'package:flutter_bloc/flutter_bloc.dart'; 6 7 import 'package:go_router/go_router.dart'; 7 8 import 'package:intl/intl.dart'; 8 9 import 'package:lazurite/core/router/app_shell.dart'; 9 10 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 11 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 12 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 13 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 14 + import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 10 15 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 11 16 12 17 class SearchScreen extends StatefulWidget { ··· 561 566 Widget build(BuildContext context) { 562 567 final record = _tryParseRecord(post.record); 563 568 final createdAt = record?.createdAt ?? post.indexedAt; 569 + final moderationService = maybeModerationService(context); 570 + final postUi = 571 + moderationService?.postUi(post, bsky_moderation.ModerationBehaviorContext.contentList) ?? 572 + const bsky_moderation.ModerationUI(); 564 573 565 574 return Card( 566 575 margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 1), 567 576 elevation: 0, 568 577 shape: const RoundedRectangleBorder(), 569 - child: Padding( 570 - padding: const EdgeInsets.all(16), 571 - child: Column( 572 - crossAxisAlignment: CrossAxisAlignment.start, 573 - children: [ 574 - _buildHeader(context, post.author, createdAt), 575 - if (record != null && record.text.isNotEmpty) ...[ 578 + child: ModeratedBlurOverlay( 579 + ui: postUi, 580 + child: Padding( 581 + padding: const EdgeInsets.all(16), 582 + child: Column( 583 + crossAxisAlignment: CrossAxisAlignment.start, 584 + children: [ 585 + _buildHeader(context, post.author, createdAt), 586 + if (postUi.alert || postUi.inform) ...[const SizedBox(height: 10), ModerationBadgeRow(ui: postUi)], 587 + if (record != null && record.text.isNotEmpty) ...[ 588 + const SizedBox(height: 12), 589 + FacetText(text: record.text, facets: record.facets, style: Theme.of(context).textTheme.bodyLarge), 590 + ], 576 591 const SizedBox(height: 12), 577 - FacetText(text: record.text, facets: record.facets, style: Theme.of(context).textTheme.bodyLarge), 592 + _buildActions(context), 578 593 ], 579 - const SizedBox(height: 12), 580 - _buildActions(context), 581 - ], 594 + ), 582 595 ), 583 596 ), 584 597 ); 585 598 } 586 599 587 600 Widget _buildHeader(BuildContext context, ProfileViewBasic author, DateTime createdAt) { 601 + final moderationService = maybeModerationService(context); 602 + final avatarUi = 603 + moderationService?.profileBasicUi(author, bsky_moderation.ModerationBehaviorContext.avatar) ?? 604 + const bsky_moderation.ModerationUI(); 588 605 return InkWell( 589 606 onTap: () => _navigateToProfile(context, author.did), 590 607 child: Row( 591 608 crossAxisAlignment: CrossAxisAlignment.start, 592 609 children: [ 593 - CircleAvatar( 594 - radius: 22, 595 - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 596 - backgroundImage: author.avatar != null ? NetworkImage(author.avatar!) : null, 597 - child: author.avatar == null 598 - ? Text(_initials(author.displayName ?? author.handle), style: Theme.of(context).textTheme.labelLarge) 599 - : null, 610 + ModeratedAvatar( 611 + size: 44, 612 + ui: avatarUi, 613 + imageUrl: author.avatar, 614 + initials: _initials(author.displayName ?? author.handle), 615 + shape: BoxShape.circle, 600 616 ), 601 617 const SizedBox(width: 12), 602 618 Expanded( ··· 710 726 711 727 @override 712 728 Widget build(BuildContext context) { 729 + final moderationService = maybeModerationService(context); 730 + final profileUi = 731 + moderationService?.profileUi(actor, bsky_moderation.ModerationBehaviorContext.profileList) ?? 732 + const bsky_moderation.ModerationUI(); 733 + final avatarUi = 734 + moderationService?.profileUi(actor, bsky_moderation.ModerationBehaviorContext.avatar) ?? 735 + const bsky_moderation.ModerationUI(); 736 + 713 737 return InkWell( 714 738 onTap: () => _navigateToProfile(context, actor.did), 715 739 child: Container( ··· 719 743 ), 720 744 child: Row( 721 745 children: [ 722 - CircleAvatar( 723 - radius: 24, 724 - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 725 - backgroundImage: actor.avatar != null ? NetworkImage(actor.avatar!) : null, 726 - child: actor.avatar == null 727 - ? Text(_initials(actor.displayName ?? actor.handle), style: Theme.of(context).textTheme.labelLarge) 728 - : null, 746 + ModeratedAvatar( 747 + size: 48, 748 + ui: avatarUi, 749 + imageUrl: actor.avatar, 750 + initials: _initials(actor.displayName ?? actor.handle), 751 + shape: BoxShape.circle, 729 752 ), 730 753 const SizedBox(width: 12), 731 754 Expanded( ··· 744 767 context, 745 768 ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 746 769 ), 770 + if (profileUi.alert || profileUi.inform) ...[ 771 + const SizedBox(height: 8), 772 + ModerationBadgeRow(ui: profileUi), 773 + ], 747 774 if (actor.description != null && actor.description!.isNotEmpty) ...[ 748 775 const SizedBox(height: 2), 749 776 Text( ··· 793 820 794 821 @override 795 822 Widget build(BuildContext context) { 823 + final moderationService = maybeModerationService(context); 824 + final profileUi = 825 + moderationService?.profileBasicUi(actor, bsky_moderation.ModerationBehaviorContext.profileList) ?? 826 + const bsky_moderation.ModerationUI(); 827 + final avatarUi = 828 + moderationService?.profileBasicUi(actor, bsky_moderation.ModerationBehaviorContext.avatar) ?? 829 + const bsky_moderation.ModerationUI(); 830 + 796 831 return InkWell( 797 832 onTap: onTap, 798 833 child: Container( 799 834 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 800 835 child: Row( 801 836 children: [ 802 - CircleAvatar( 803 - radius: 20, 804 - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 805 - backgroundImage: actor.avatar != null ? NetworkImage(actor.avatar!) : null, 806 - child: actor.avatar == null 807 - ? Text(_initials(actor.displayName ?? actor.handle), style: Theme.of(context).textTheme.labelMedium) 808 - : null, 837 + ModeratedAvatar( 838 + size: 40, 839 + ui: avatarUi, 840 + imageUrl: actor.avatar, 841 + initials: _initials(actor.displayName ?? actor.handle), 842 + shape: BoxShape.circle, 843 + placeholderTextStyle: Theme.of(context).textTheme.labelMedium, 809 844 ), 810 845 const SizedBox(width: 12), 811 846 Expanded( ··· 824 859 context, 825 860 ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 826 861 ), 862 + if (profileUi.alert || profileUi.inform) ...[ 863 + const SizedBox(height: 8), 864 + ModerationBadgeRow(ui: profileUi), 865 + ], 827 866 ], 828 867 ), 829 868 ),
+82
lib/features/settings/presentation/settings_screen.dart
··· 6 6 import 'package:lazurite/core/theme/feed_architecture.dart'; 7 7 import 'package:lazurite/core/theme/ui_density.dart'; 8 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 10 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 9 11 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 10 12 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 11 13 ··· 52 54 const SizedBox(height: 24), 53 55 _buildSectionHeader(context, 'Layout'), 54 56 _buildLayoutSettings(context), 57 + const SizedBox(height: 24), 58 + _buildSectionHeader(context, 'Moderation'), 59 + const _ModerationSettingsPreview(), 55 60 const SizedBox(height: 24), 56 61 _buildSectionHeader(context, 'Account'), 57 62 _SettingsTile( ··· 259 264 ), 260 265 ], 261 266 ), 267 + ); 268 + }, 269 + ); 270 + } 271 + } 272 + 273 + class _ModerationSettingsPreview extends StatefulWidget { 274 + const _ModerationSettingsPreview(); 275 + 276 + @override 277 + State<_ModerationSettingsPreview> createState() => _ModerationSettingsPreviewState(); 278 + } 279 + 280 + class _ModerationSettingsPreviewState extends State<_ModerationSettingsPreview> { 281 + bool _isUpdating = false; 282 + 283 + ModerationService? get _service => maybeModerationService(context); 284 + 285 + Future<void> _toggleAdultContent(bool value) async { 286 + final service = _service; 287 + if (service == null) { 288 + return; 289 + } 290 + 291 + setState(() => _isUpdating = true); 292 + try { 293 + await service.setAdultContentEnabled(value); 294 + if (mounted) { 295 + setState(() {}); 296 + } 297 + } catch (error) { 298 + if (mounted) { 299 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update adult content: $error'))); 300 + } 301 + } finally { 302 + if (mounted) { 303 + setState(() => _isUpdating = false); 304 + } 305 + } 306 + } 307 + 308 + @override 309 + Widget build(BuildContext context) { 310 + final service = _service; 311 + if (service == null) { 312 + return _SettingsTile( 313 + icon: Icons.shield_outlined, 314 + title: 'Content Moderation', 315 + subtitle: 'Manage labelers and visibility rules', 316 + onTap: () => context.push('/settings/moderation'), 317 + ); 318 + } 319 + 320 + return StreamBuilder( 321 + stream: service.optsStream, 322 + initialData: service.currentOpts, 323 + builder: (context, snapshot) { 324 + final adultEnabled = adultContentEnabledFromPreferences(service.currentPreferences); 325 + final customLabelers = 326 + service.currentPrefs?.labelers.where((labeler) => labeler.did != officialBlueskyLabelerDid).length ?? 0; 327 + 328 + return Column( 329 + children: [ 330 + _SettingsTile( 331 + icon: Icons.visibility_outlined, 332 + title: 'Adult Content', 333 + subtitle: adultEnabled ? '18+ labels can be configured' : 'Required before 18+ labels can be configured', 334 + trailing: Switch.adaptive(value: adultEnabled, onChanged: _isUpdating ? null : _toggleAdultContent), 335 + ), 336 + const Divider(height: 1), 337 + _SettingsTile( 338 + icon: Icons.policy_outlined, 339 + title: 'Content Moderation', 340 + subtitle: '$customLabelers custom labeler${customLabelers == 1 ? '' : 's'} subscribed', 341 + onTap: () => context.push('/settings/moderation'), 342 + ), 343 + ], 262 344 ); 263 345 }, 264 346 );
+154
test/features/moderation/presentation/moderation_screens_test.dart
··· 1 + import 'package:atproto/com_atproto_label_defs.dart'; 2 + import 'package:atproto_core/atproto_core.dart'; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:bluesky/app_bsky_labeler_defs.dart'; 5 + import 'package:bluesky/app_bsky_labeler_getservices.dart'; 6 + import 'package:bluesky/moderation.dart' as bsky_moderation; 7 + import 'package:flutter/material.dart'; 8 + import 'package:flutter_bloc/flutter_bloc.dart'; 9 + import 'package:flutter_test/flutter_test.dart'; 10 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 11 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 12 + import 'package:lazurite/features/moderation/presentation/screens/labeler_detail_screen.dart'; 13 + import 'package:lazurite/features/moderation/presentation/screens/moderation_settings_screen.dart'; 14 + import 'package:mocktail/mocktail.dart'; 15 + 16 + class MockModerationService extends Mock implements ModerationService {} 17 + 18 + void main() { 19 + late MockModerationService moderationService; 20 + late LabelerViewDetailed officialLabeler; 21 + late LabelerViewDetailed customLabeler; 22 + 23 + setUpAll(() { 24 + registerFallbackValue(KnownContentLabelPrefVisibility.warn); 25 + }); 26 + 27 + setUp(() { 28 + moderationService = MockModerationService(); 29 + officialLabeler = _buildLabeler( 30 + did: officialBlueskyLabelerDid, 31 + handle: 'safety.bsky.social', 32 + displayName: 'Bluesky Safety', 33 + description: 'Official moderation policies.', 34 + definitionName: 'Graphic Media', 35 + definitionIdentifier: 'graphic-media', 36 + ); 37 + customLabeler = _buildLabeler( 38 + did: 'did:plc:custom-labeler', 39 + handle: 'cinder.example', 40 + displayName: 'Cinder Moderation', 41 + description: 'Crowdsourced media warnings.', 42 + definitionName: 'Spoilers', 43 + definitionIdentifier: 'spoilers', 44 + ); 45 + 46 + when(() => moderationService.ensureInitialized()).thenAnswer((_) async {}); 47 + when(() => moderationService.currentOpts).thenReturn(null); 48 + when(() => moderationService.optsStream).thenAnswer((_) => const Stream.empty()); 49 + when(() => moderationService.currentPreferences).thenReturn([ 50 + const UPreferences.adultContentPref(data: AdultContentPref(enabled: true)), 51 + const UPreferences.labelersPref( 52 + data: LabelersPref(labelers: [LabelerPrefItem(did: 'did:plc:custom-labeler')]), 53 + ), 54 + ]); 55 + when(() => moderationService.currentPrefs).thenReturn( 56 + const bsky_moderation.ModerationPrefs( 57 + adultContentEnabled: true, 58 + labels: {}, 59 + labelers: [ 60 + bsky_moderation.ModerationPrefsLabeler(did: officialBlueskyLabelerDid, labels: {}), 61 + bsky_moderation.ModerationPrefsLabeler(did: 'did:plc:custom-labeler', labels: {}), 62 + ], 63 + mutedWords: [], 64 + hiddenPosts: [], 65 + ), 66 + ); 67 + when( 68 + () => moderationService.getSubscribedLabelers(), 69 + ).thenAnswer((_) async => [ULabelerGetServicesViews.labelerViewDetailed(data: customLabeler)]); 70 + when(() => moderationService.getLabelerDetails(officialBlueskyLabelerDid)).thenAnswer((_) async => officialLabeler); 71 + when(() => moderationService.getLabelerDetails('did:plc:custom-labeler')).thenAnswer((_) async => customLabeler); 72 + when(() => moderationService.unsubscribeFromLabeler(any())).thenAnswer((_) async {}); 73 + when(() => moderationService.setAdultContentEnabled(any())).thenAnswer((_) async {}); 74 + when( 75 + () => moderationService.setLabelPreference( 76 + label: any(named: 'label'), 77 + labelerDid: any(named: 'labelerDid'), 78 + visibility: any(named: 'visibility'), 79 + ), 80 + ).thenAnswer((_) async {}); 81 + }); 82 + 83 + Widget buildSubject(Widget child) { 84 + return RepositoryProvider<ModerationService>.value( 85 + value: moderationService, 86 + child: MaterialApp(home: child), 87 + ); 88 + } 89 + 90 + testWidgets('moderation settings screen renders official and custom labelers', (tester) async { 91 + await tester.pumpWidget(buildSubject(const ModerationSettingsScreen())); 92 + await tester.pumpAndSettle(); 93 + 94 + expect(find.text('Adult content'), findsOneWidget); 95 + expect(find.text('Bluesky Safety'), findsOneWidget); 96 + await tester.scrollUntilVisible(find.text('Cinder Moderation'), 300); 97 + expect(find.text('Cinder Moderation'), findsOneWidget); 98 + expect(find.textContaining('1/20'), findsOneWidget); 99 + }); 100 + 101 + testWidgets('labeler detail screen renders localized label definitions', (tester) async { 102 + await tester.pumpWidget(buildSubject(const LabelerDetailScreen(did: 'did:plc:custom-labeler'))); 103 + await tester.pumpAndSettle(); 104 + 105 + expect(find.text('Spoilers'), findsOneWidget); 106 + expect(find.text('Spoilers should be treated cautiously.'), findsOneWidget); 107 + await tester.scrollUntilVisible(find.text('Hide'), 300); 108 + expect(find.text('Hide'), findsOneWidget); 109 + expect(find.text('Default warn'), findsOneWidget); 110 + }); 111 + } 112 + 113 + LabelerViewDetailed _buildLabeler({ 114 + required String did, 115 + required String handle, 116 + required String displayName, 117 + required String description, 118 + required String definitionName, 119 + required String definitionIdentifier, 120 + }) { 121 + return LabelerViewDetailed( 122 + uri: AtUri.parse('at://$did/app.bsky.labeler.service/self'), 123 + cid: 'cid-$did', 124 + creator: ProfileView( 125 + did: did, 126 + handle: handle, 127 + displayName: displayName, 128 + description: description, 129 + avatar: 'https://example.com/$handle.png', 130 + ), 131 + policies: LabelerPolicies( 132 + labelValues: [LabelValue.unknown(data: definitionIdentifier)], 133 + labelValueDefinitions: [ 134 + LabelValueDefinition( 135 + identifier: definitionIdentifier, 136 + severity: const LabelValueDefinitionSeverity.knownValue(data: KnownLabelValueDefinitionSeverity.alert), 137 + blurs: const LabelValueDefinitionBlurs.knownValue(data: KnownLabelValueDefinitionBlurs.content), 138 + defaultSetting: const LabelValueDefinitionDefaultSetting.knownValue( 139 + data: KnownLabelValueDefinitionDefaultSetting.warn, 140 + ), 141 + adultOnly: false, 142 + locales: [ 143 + LabelValueDefinitionStrings( 144 + lang: 'en', 145 + name: definitionName, 146 + description: '$definitionName should be treated cautiously.', 147 + ), 148 + ], 149 + ), 150 + ], 151 + ), 152 + indexedAt: DateTime.utc(2026, 3, 21), 153 + ); 154 + }