diff --git a/lib/src/core/design_system/components/atoms/surfaces/app_surface.dart b/lib/src/core/design_system/components/atoms/surfaces/app_surface.dart new file mode 100644 index 00000000..d59924c2 --- /dev/null +++ b/lib/src/core/design_system/components/atoms/surfaces/app_surface.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/tokens/shadows.dart'; + +enum AppSurfaceVariant { outlined, raised } + +class AppSurface extends StatelessWidget { + const AppSurface({ + required this.child, + super.key, + this.variant = AppSurfaceVariant.outlined, + this.margin = EdgeInsets.zero, + this.padding = EdgeInsets.zero, + }); + + final Widget child; + final AppSurfaceVariant variant; + final EdgeInsetsGeometry margin; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final borderRadius = BorderRadius.circular(16); + + return Padding( + padding: margin, + child: DecoratedBox( + decoration: BoxDecoration( + color: isDark + ? colorScheme.surfaceContainerHighest + : colorScheme.surface, + borderRadius: borderRadius, + border: Border.all(color: colorScheme.outline), + boxShadow: variant == AppSurfaceVariant.raised + ? const [AppShadows.shadowXs] + : null, + ), + child: ClipRRect( + borderRadius: borderRadius, + child: Padding(padding: padding, child: child), + ), + ), + ); + } +} diff --git a/lib/src/core/design_system/components/molecules/app_choice_group.dart b/lib/src/core/design_system/components/molecules/app_choice_group.dart new file mode 100644 index 00000000..52bc2a95 --- /dev/null +++ b/lib/src/core/design_system/components/molecules/app_choice_group.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; +import 'package:spark/src/core/design_system/tokens/typography.dart'; + +@immutable +class AppChoiceOption { + const AppChoiceOption({ + required this.value, + required this.label, + this.enabled = true, + }); + + final T value; + final String label; + final bool enabled; +} + +class AppChoiceGroup extends StatelessWidget { + const AppChoiceGroup({ + required this.value, + required this.options, + required this.onChanged, + super.key, + this.enabled = true, + }) : assert(options.length > 1); + + final T value; + final List> options; + final ValueChanged onChanged; + final bool enabled; + + @override + Widget build(BuildContext context) { + return Row( + spacing: 6, + children: [ + for (final option in options) + Expanded( + child: _ChoiceButton( + option: option, + isSelected: option.value == value, + enabled: enabled && option.enabled, + onSelected: onChanged, + ), + ), + ], + ); + } +} + +class _ChoiceButton extends StatelessWidget { + const _ChoiceButton({ + required this.option, + required this.isSelected, + required this.enabled, + required this.onSelected, + }); + + final AppChoiceOption option; + final bool isSelected; + final bool enabled; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final onTap = enabled ? () => onSelected(option.value) : null; + final backgroundColor = enabled + ? isSelected + ? colorScheme.primary + : colorScheme.surface + : colorScheme.onSurface.withValues(alpha: 0.12); + final foregroundColor = enabled + ? isSelected + ? colorScheme.onPrimary + : colorScheme.onSurface + : colorScheme.onSurface.withValues(alpha: 0.38); + final borderRadius = BorderRadius.circular(12); + + return Semantics( + label: option.label, + button: true, + enabled: enabled, + selected: isSelected, + inMutuallyExclusiveGroup: true, + onTap: onTap, + child: ExcludeSemantics( + child: InteractivePressable( + onTap: onTap, + borderRadius: borderRadius, + child: Material( + color: backgroundColor, + elevation: isSelected && enabled ? 2 : 0, + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + side: BorderSide(color: colorScheme.outline, width: 0.5), + ), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 40), + child: Align( + child: Text( + option.label, + overflow: TextOverflow.ellipsis, + style: AppTypography.textExtraSmallBold.copyWith( + color: foregroundColor, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/core/design_system/templates/chat_list_page_template.dart b/lib/src/core/design_system/templates/chat_list_page_template.dart index 8faf9a03..b5a03b0b 100644 --- a/lib/src/core/design_system/templates/chat_list_page_template.dart +++ b/lib/src/core/design_system/templates/chat_list_page_template.dart @@ -34,6 +34,7 @@ class ChatListPageTemplate extends StatelessWidget { this.loadingItemCount = 8, this.onAddTap, this.onRefresh, + this.itemWrapper, }); const ChatListPageTemplate.loading({ @@ -42,6 +43,7 @@ class ChatListPageTemplate extends StatelessWidget { this.loadingItemCount = 8, this.onAddTap, this.onRefresh, + this.itemWrapper, }) : items = const [], onItemTap = _noopItemTap, loading = true; @@ -53,6 +55,8 @@ class ChatListPageTemplate extends StatelessWidget { final int loadingItemCount; final VoidCallback? onAddTap; final Future Function()? onRefresh; + final Widget Function(BuildContext context, int index, Widget child)? + itemWrapper; @override Widget build(BuildContext context) { @@ -86,10 +90,13 @@ class ChatListPageTemplate extends StatelessWidget { padding: EdgeInsets.zero, itemCount: items.length, separatorBuilder: (_, _) => const SizedBox.shrink(), - itemBuilder: (context, index) => _ChatTile( - data: items[index], - onTap: () => onItemTap(index), - ), + itemBuilder: (context, index) { + final tile = _ChatTile( + data: items[index], + onTap: () => onItemTap(index), + ); + return itemWrapper?.call(context, index, tile) ?? tile; + }, ), ), ), diff --git a/lib/src/core/design_system/templates/image_review_page_template.dart b/lib/src/core/design_system/templates/image_review_page_template.dart index a5de199f..fd9a8bd1 100644 --- a/lib/src/core/design_system/templates/image_review_page_template.dart +++ b/lib/src/core/design_system/templates/image_review_page_template.dart @@ -46,6 +46,7 @@ class ImageReviewPageTemplate extends StatelessWidget { this.mentionController, this.onMentionsChanged, this.showCrossPostWarning = false, + this.contentWarningSection, this.backgroundColor, this.isOverLimit = false, }); @@ -70,6 +71,7 @@ class ImageReviewPageTemplate extends StatelessWidget { final bool crossPostValue; final ValueChanged onCrossPostChanged; final bool showCrossPostWarning; + final Widget? contentWarningSection; final String? selectedSoundTitle; final String? selectedSoundSubtitle; final VoidCallback? onAddSound; @@ -148,6 +150,10 @@ class ImageReviewPageTemplate extends StatelessWidget { onMentionsChanged: onMentionsChanged, maxChars: descriptionMaxChars, ), + if (contentWarningSection != null) ...[ + const SizedBox(height: 20), + contentWarningSection!, + ], const SizedBox(height: 20), _CrossPostSection( value: crossPostValue, diff --git a/lib/src/core/design_system/templates/video_review_page_template.dart b/lib/src/core/design_system/templates/video_review_page_template.dart index f66f3ddb..153e50b6 100644 --- a/lib/src/core/design_system/templates/video_review_page_template.dart +++ b/lib/src/core/design_system/templates/video_review_page_template.dart @@ -37,6 +37,7 @@ class VideoReviewPageTemplate extends StatelessWidget { this.uploadIndeterminate = false, this.hasUploadError = false, this.onUploadRetry, + this.contentWarningSection, super.key, }); @@ -63,6 +64,7 @@ class VideoReviewPageTemplate extends StatelessWidget { final bool uploadIndeterminate; final bool hasUploadError; final VoidCallback? onUploadRetry; + final Widget? contentWarningSection; @override Widget build(BuildContext context) { @@ -116,6 +118,10 @@ class VideoReviewPageTemplate extends StatelessWidget { onMentionsChanged: onMentionsChanged, maxChars: descriptionMaxChars, ), + if (contentWarningSection != null) ...[ + const SizedBox(height: 20), + contentWarningSection!, + ], if (showCrossPost) ...[ const SizedBox(height: 20), _CrossPostSection( diff --git a/lib/src/core/l10n/app_localizations.dart b/lib/src/core/l10n/app_localizations.dart index d29b8bf6..073cd88e 100644 --- a/lib/src/core/l10n/app_localizations.dart +++ b/lib/src/core/l10n/app_localizations.dart @@ -274,6 +274,12 @@ abstract class AppLocalizations { /// **'Settings'** String get pageTitleSettings; + /// Moderation settings page title + /// + /// In en, this message translates to: + /// **'Moderation'** + String get pageTitleModeration; + /// Story manager page title /// /// In en, this message translates to: @@ -1730,6 +1736,234 @@ abstract class AppLocalizations { int count, ); + /// Heading for author-applied media content warnings + /// + /// In en, this message translates to: + /// **'Content warnings'** + String get labelContentWarnings; + + /// Explanation shown above author self-label controls + /// + /// In en, this message translates to: + /// **'Add any warnings that apply to the media you are posting.'** + String get messageContentWarningsDescription; + + /// Heading for mutually exclusive adult-content warnings + /// + /// In en, this message translates to: + /// **'Adult Content'** + String get labelAdultContent; + + /// Heading over content concealed by moderation + /// + /// In en, this message translates to: + /// **'Content warning'** + String get moderationContentWarning; + + /// Generic fallback for a labeler notice with no localized label strings + /// + /// In en, this message translates to: + /// **'Content notice'** + String get moderationContentNotice; + + /// Button that reveals moderated content + /// + /// In en, this message translates to: + /// **'View content'** + String get buttonViewContent; + + /// Button that opens moderation label details + /// + /// In en, this message translates to: + /// **'Why am I seeing this?'** + String get buttonModerationDetails; + + /// Message for an imperative or age-gated moderation decision + /// + /// In en, this message translates to: + /// **'This restriction cannot be overridden.'** + String get moderationCannotOverride; + + /// Title of the moderation details sheet + /// + /// In en, this message translates to: + /// **'Moderation details'** + String get moderationDetailsTitle; + + /// Label before the DID of the labeler that applied a label + /// + /// In en, this message translates to: + /// **'Applied by'** + String get moderationAppliedBy; + + /// Label before a moderation label expiration timestamp + /// + /// In en, this message translates to: + /// **'Expires'** + String get moderationExpires; + + /// Button for appealing a moderation label applied to the current user + /// + /// In en, this message translates to: + /// **'Appeal label'** + String get buttonAppealLabel; + + /// Default text sent with a label appeal + /// + /// In en, this message translates to: + /// **'I am appealing this label.'** + String get moderationAppealReason; + + /// Confirmation after a moderation label appeal succeeds + /// + /// In en, this message translates to: + /// **'Appeal sent'** + String get moderationAppealSent; + + /// Error after a moderation label appeal fails + /// + /// In en, this message translates to: + /// **'Could not send the appeal'** + String get moderationAppealFailed; + + /// Settings toggle for allowing configurable adult-only labels + /// + /// In en, this message translates to: + /// **'Show adult content'** + String get settingAdultContent; + + /// Explanation for the adult content setting + /// + /// In en, this message translates to: + /// **'When off, adult-only labels are hidden and cannot be overridden.'** + String get settingAdultContentDescription; + + /// Moderation preference that shows labeled content + /// + /// In en, this message translates to: + /// **'Show'** + String get labelShow; + + /// Moderation preference that turns off treatment for a label + /// + /// In en, this message translates to: + /// **'Off'** + String get labelOff; + + /// Moderation preference that warns before showing labeled content + /// + /// In en, this message translates to: + /// **'Warn'** + String get labelWarn; + + /// Moderation preference that shows an informational badge on labeled content + /// + /// In en, this message translates to: + /// **'Badge'** + String get labelBadge; + + /// Moderation preference that hides labeled content + /// + /// In en, this message translates to: + /// **'Hide'** + String get labelHide; + + /// Explanation for a global label shown on a labeler settings page + /// + /// In en, this message translates to: + /// **'Configured in Moderation settings.'** + String get moderationConfiguredGlobally; + + /// Name of the built-in porn moderation label + /// + /// In en, this message translates to: + /// **'Adult Content'** + String get moderationLabelPornName; + + /// Description of the built-in porn moderation label + /// + /// In en, this message translates to: + /// **'Explicit sexual images.'** + String get moderationLabelPornDescription; + + /// Name of the built-in sexually suggestive moderation label + /// + /// In en, this message translates to: + /// **'Sexually Suggestive'** + String get moderationLabelSexualName; + + /// Description of the built-in sexually suggestive moderation label + /// + /// In en, this message translates to: + /// **'Does not include nudity.'** + String get moderationLabelSexualDescription; + + /// Name of the built-in non-sexual nudity moderation label + /// + /// In en, this message translates to: + /// **'Non-sexual Nudity'** + String get moderationLabelNudityName; + + /// Description of the built-in non-sexual nudity moderation label + /// + /// In en, this message translates to: + /// **'For example, artistic nudes.'** + String get moderationLabelNudityDescription; + + /// Name of the built-in graphic media moderation label + /// + /// In en, this message translates to: + /// **'Graphic Media'** + String get moderationLabelGraphicMediaName; + + /// Description of the built-in graphic media moderation label + /// + /// In en, this message translates to: + /// **'Explicit or potentially disturbing media.'** + String get moderationLabelGraphicMediaDescription; + + /// Name of the built-in gore moderation label + /// + /// In en, this message translates to: + /// **'Gore'** + String get moderationLabelGoreName; + + /// Description of the built-in gore moderation label + /// + /// In en, this message translates to: + /// **'Graphic depictions of severe injury, blood, or death.'** + String get moderationLabelGoreDescription; + + /// Error shown when a labeler cannot be resolved, validated, or saved + /// + /// In en, this message translates to: + /// **'Could not add that labeler. Check the DID or handle and try again.'** + String get errorAddingLabeler; + + /// Label for choosing the service that receives a moderation report + /// + /// In en, this message translates to: + /// **'Moderation service'** + String get moderationService; + + /// Fallback name for the app's default moderation service + /// + /// In en, this message translates to: + /// **'Default moderation service'** + String get moderationDefaultService; + + /// Message when no subscribed moderation service supports a report + /// + /// In en, this message translates to: + /// **'None of your moderation services accepts this type of report.'** + String get moderationNoCompatibleService; + + /// Error shown when compatible moderation services cannot be loaded + /// + /// In en, this message translates to: + /// **'Could not load moderation services.'** + String get moderationServiceLoadFailed; + /// Send button text /// /// In en, this message translates to: diff --git a/lib/src/core/l10n/app_localizations_en.dart b/lib/src/core/l10n/app_localizations_en.dart index e305c7b0..b1f969d7 100644 --- a/lib/src/core/l10n/app_localizations_en.dart +++ b/lib/src/core/l10n/app_localizations_en.dart @@ -98,6 +98,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pageTitleSettings => 'Settings'; + @override + String get pageTitleModeration => 'Moderation'; + @override String get pageTitleStoryManager => 'Story Manager'; @@ -964,6 +967,129 @@ class AppLocalizationsEn extends AppLocalizations { return 'Followed by $firstName, $secondName, and $_temp0'; } + @override + String get labelContentWarnings => 'Content warnings'; + + @override + String get messageContentWarningsDescription => + 'Add any warnings that apply to the media you are posting.'; + + @override + String get labelAdultContent => 'Adult Content'; + + @override + String get moderationContentWarning => 'Content warning'; + + @override + String get moderationContentNotice => 'Content notice'; + + @override + String get buttonViewContent => 'View content'; + + @override + String get buttonModerationDetails => 'Why am I seeing this?'; + + @override + String get moderationCannotOverride => + 'This restriction cannot be overridden.'; + + @override + String get moderationDetailsTitle => 'Moderation details'; + + @override + String get moderationAppliedBy => 'Applied by'; + + @override + String get moderationExpires => 'Expires'; + + @override + String get buttonAppealLabel => 'Appeal label'; + + @override + String get moderationAppealReason => 'I am appealing this label.'; + + @override + String get moderationAppealSent => 'Appeal sent'; + + @override + String get moderationAppealFailed => 'Could not send the appeal'; + + @override + String get settingAdultContent => 'Show adult content'; + + @override + String get settingAdultContentDescription => + 'When off, adult-only labels are hidden and cannot be overridden.'; + + @override + String get labelShow => 'Show'; + + @override + String get labelOff => 'Off'; + + @override + String get labelWarn => 'Warn'; + + @override + String get labelBadge => 'Badge'; + + @override + String get labelHide => 'Hide'; + + @override + String get moderationConfiguredGlobally => + 'Configured in Moderation settings.'; + + @override + String get moderationLabelPornName => 'Adult Content'; + + @override + String get moderationLabelPornDescription => 'Explicit sexual images.'; + + @override + String get moderationLabelSexualName => 'Sexually Suggestive'; + + @override + String get moderationLabelSexualDescription => 'Does not include nudity.'; + + @override + String get moderationLabelNudityName => 'Non-sexual Nudity'; + + @override + String get moderationLabelNudityDescription => 'For example, artistic nudes.'; + + @override + String get moderationLabelGraphicMediaName => 'Graphic Media'; + + @override + String get moderationLabelGraphicMediaDescription => + 'Explicit or potentially disturbing media.'; + + @override + String get moderationLabelGoreName => 'Gore'; + + @override + String get moderationLabelGoreDescription => + 'Graphic depictions of severe injury, blood, or death.'; + + @override + String get errorAddingLabeler => + 'Could not add that labeler. Check the DID or handle and try again.'; + + @override + String get moderationService => 'Moderation service'; + + @override + String get moderationDefaultService => 'Default moderation service'; + + @override + String get moderationNoCompatibleService => + 'None of your moderation services accepts this type of report.'; + + @override + String get moderationServiceLoadFailed => + 'Could not load moderation services.'; + @override String get buttonSend => 'Send'; } diff --git a/lib/src/core/l10n/intl_en.arb b/lib/src/core/l10n/intl_en.arb index 4e955acc..9aeff253 100644 --- a/lib/src/core/l10n/intl_en.arb +++ b/lib/src/core/l10n/intl_en.arb @@ -152,6 +152,11 @@ "description": "Settings page title" }, + "pageTitleModeration": "Moderation", + "@pageTitleModeration": { + "description": "Moderation settings page title" + }, + "pageTitleStoryManager": "Story Manager", "@pageTitleStoryManager": { "description": "Story manager page title" @@ -1503,6 +1508,159 @@ } }, + "labelContentWarnings": "Content warnings", + "@labelContentWarnings": { + "description": "Heading for author-applied media content warnings" + }, + "messageContentWarningsDescription": "Add any warnings that apply to the media you are posting.", + "@messageContentWarningsDescription": { + "description": "Explanation shown above author self-label controls" + }, + "labelAdultContent": "Adult Content", + "@labelAdultContent": { + "description": "Heading for mutually exclusive adult-content warnings" + }, + "moderationContentWarning": "Content warning", + "@moderationContentWarning": { + "description": "Heading over content concealed by moderation" + }, + "moderationContentNotice": "Content notice", + "@moderationContentNotice": { + "description": "Generic fallback for a labeler notice with no localized label strings" + }, + "buttonViewContent": "View content", + "@buttonViewContent": { + "description": "Button that reveals moderated content" + }, + "buttonModerationDetails": "Why am I seeing this?", + "@buttonModerationDetails": { + "description": "Button that opens moderation label details" + }, + "moderationCannotOverride": "This restriction cannot be overridden.", + "@moderationCannotOverride": { + "description": "Message for an imperative or age-gated moderation decision" + }, + "moderationDetailsTitle": "Moderation details", + "@moderationDetailsTitle": { + "description": "Title of the moderation details sheet" + }, + "moderationAppliedBy": "Applied by", + "@moderationAppliedBy": { + "description": "Label before the DID of the labeler that applied a label" + }, + "moderationExpires": "Expires", + "@moderationExpires": { + "description": "Label before a moderation label expiration timestamp" + }, + "buttonAppealLabel": "Appeal label", + "@buttonAppealLabel": { + "description": "Button for appealing a moderation label applied to the current user" + }, + "moderationAppealReason": "I am appealing this label.", + "@moderationAppealReason": { + "description": "Default text sent with a label appeal" + }, + "moderationAppealSent": "Appeal sent", + "@moderationAppealSent": { + "description": "Confirmation after a moderation label appeal succeeds" + }, + "moderationAppealFailed": "Could not send the appeal", + "@moderationAppealFailed": { + "description": "Error after a moderation label appeal fails" + }, + "settingAdultContent": "Show adult content", + "@settingAdultContent": { + "description": "Settings toggle for allowing configurable adult-only labels" + }, + "settingAdultContentDescription": "When off, adult-only labels are hidden and cannot be overridden.", + "@settingAdultContentDescription": { + "description": "Explanation for the adult content setting" + }, + "labelShow": "Show", + "@labelShow": { + "description": "Moderation preference that shows labeled content" + }, + "labelOff": "Off", + "@labelOff": { + "description": "Moderation preference that turns off treatment for a label" + }, + "labelWarn": "Warn", + "@labelWarn": { + "description": "Moderation preference that warns before showing labeled content" + }, + "labelBadge": "Badge", + "@labelBadge": { + "description": "Moderation preference that shows an informational badge on labeled content" + }, + "labelHide": "Hide", + "@labelHide": { + "description": "Moderation preference that hides labeled content" + }, + "moderationConfiguredGlobally": "Configured in Moderation settings.", + "@moderationConfiguredGlobally": { + "description": "Explanation for a global label shown on a labeler settings page" + }, + "moderationLabelPornName": "Adult Content", + "@moderationLabelPornName": { + "description": "Name of the built-in porn moderation label" + }, + "moderationLabelPornDescription": "Explicit sexual images.", + "@moderationLabelPornDescription": { + "description": "Description of the built-in porn moderation label" + }, + "moderationLabelSexualName": "Sexually Suggestive", + "@moderationLabelSexualName": { + "description": "Name of the built-in sexually suggestive moderation label" + }, + "moderationLabelSexualDescription": "Does not include nudity.", + "@moderationLabelSexualDescription": { + "description": "Description of the built-in sexually suggestive moderation label" + }, + "moderationLabelNudityName": "Non-sexual Nudity", + "@moderationLabelNudityName": { + "description": "Name of the built-in non-sexual nudity moderation label" + }, + "moderationLabelNudityDescription": "For example, artistic nudes.", + "@moderationLabelNudityDescription": { + "description": "Description of the built-in non-sexual nudity moderation label" + }, + "moderationLabelGraphicMediaName": "Graphic Media", + "@moderationLabelGraphicMediaName": { + "description": "Name of the built-in graphic media moderation label" + }, + "moderationLabelGraphicMediaDescription": "Explicit or potentially disturbing media.", + "@moderationLabelGraphicMediaDescription": { + "description": "Description of the built-in graphic media moderation label" + }, + "moderationLabelGoreName": "Gore", + "@moderationLabelGoreName": { + "description": "Name of the built-in gore moderation label" + }, + "moderationLabelGoreDescription": "Graphic depictions of severe injury, blood, or death.", + "@moderationLabelGoreDescription": { + "description": "Description of the built-in gore moderation label" + }, + "errorAddingLabeler": "Could not add that labeler. Check the DID or handle and try again.", + "@errorAddingLabeler": { + "description": "Error shown when a labeler cannot be resolved, validated, or saved" + }, + "moderationService": "Moderation service", + "@moderationService": { + "description": "Label for choosing the service that receives a moderation report" + }, + "moderationDefaultService": "Default moderation service", + "@moderationDefaultService": { + "description": "Fallback name for the app's default moderation service" + }, + "moderationNoCompatibleService": "None of your moderation services accepts this type of report.", + "@moderationNoCompatibleService": { + "description": "Message when no subscribed moderation service supports a report" + }, + "moderationServiceLoadFailed": "Could not load moderation services.", + "@moderationServiceLoadFailed": { + "description": "Error shown when compatible moderation services cannot be loaded" + }, + "buttonSend": "Send", "@buttonSend": { "description": "Send button text" diff --git a/lib/src/core/moderation/moderated_content.dart b/lib/src/core/moderation/moderated_content.dart new file mode 100644 index 00000000..7751dd5b --- /dev/null +++ b/lib/src/core/moderation/moderated_content.dart @@ -0,0 +1,528 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get_it/get_it.dart'; +import 'package:poptart/poptart.dart'; +import 'package:poptart_lex/com/atproto/admin/defs.dart'; +import 'package:poptart_lex/com/atproto/label/defs.dart'; +import 'package:poptart_lex/com/atproto/moderation/create_report.dart'; +import 'package:poptart_lex/com/atproto/moderation/defs.dart'; +import 'package:poptart_lex/com/atproto/repo/strong_ref.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/moderation/moderation.dart'; +import 'package:spark/src/core/moderation/moderation_provider.dart'; +import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; + +/// Applies the same moderation decision and reveal rules to every surface. +class ModeratedContent extends ConsumerStatefulWidget { + const ModeratedContent({ + required this.labels, + required this.target, + required this.context, + required this.child, + super.key, + this.subjectDid, + this.authorLabels = const [], + this.compact = false, + this.blurredChild, + this.onConcealedTap, + this.filterReplacement = const SizedBox.shrink(), + this.onConcealChanged, + }); + + final List