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/components/molecules/profile_avatar.dart b/lib/src/core/design_system/components/molecules/profile_avatar.dart index 615df354..0a86472c 100644 --- a/lib/src/core/design_system/components/molecules/profile_avatar.dart +++ b/lib/src/core/design_system/components/molecules/profile_avatar.dart @@ -14,6 +14,7 @@ class ProfileAvatar extends StatelessWidget { this.onTap, this.showAddButton = false, this.onAddTap, + this.avatarBuilder, }); final String? avatarUrl; @@ -23,6 +24,7 @@ class ProfileAvatar extends StatelessWidget { final VoidCallback? onTap; final bool showAddButton; final VoidCallback? onAddTap; + final Widget Function(Widget avatar)? avatarBuilder; @override Widget build(BuildContext context) { @@ -111,24 +113,22 @@ class ProfileAvatar extends StatelessWidget { required bool isDarkMode, required double avatarSize, }) { - if (avatarUrl != null && avatarUrl!.isNotEmpty) { - return ClipOval( - child: CachedNetworkImage( - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - imageUrl: avatarUrl!, - width: avatarSize, - height: avatarSize, - fit: BoxFit.cover, - placeholder: (context, url) => - _buildPlaceholder(context, isDarkMode, avatarSize), - errorWidget: (context, url, error) => - _buildPlaceholder(context, isDarkMode, avatarSize), - ), - ); - } + final avatar = avatarUrl != null && avatarUrl!.isNotEmpty + ? CachedNetworkImage( + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + imageUrl: avatarUrl!, + width: avatarSize, + height: avatarSize, + fit: BoxFit.cover, + placeholder: (context, url) => + _buildPlaceholder(context, isDarkMode, avatarSize), + errorWidget: (context, url, error) => + _buildPlaceholder(context, isDarkMode, avatarSize), + ) + : _buildPlaceholder(context, isDarkMode, avatarSize); - return _buildPlaceholder(context, isDarkMode, avatarSize); + return ClipOval(child: avatarBuilder?.call(avatar) ?? avatar); } Widget _buildPlaceholder( diff --git a/lib/src/core/design_system/components/molecules/profile_card.dart b/lib/src/core/design_system/components/molecules/profile_card.dart index f3c2fc82..9cd4eaa3 100644 --- a/lib/src/core/design_system/components/molecules/profile_card.dart +++ b/lib/src/core/design_system/components/molecules/profile_card.dart @@ -20,6 +20,7 @@ class ProfileCard extends StatelessWidget { this.onTap, this.hasStories = false, this.onAvatarTap, + this.avatarBuilder, super.key, }); @@ -37,6 +38,7 @@ class ProfileCard extends StatelessWidget { VoidCallback? onTap, bool hasStories = false, VoidCallback? onAvatarTap, + Widget Function(Widget avatar)? avatarBuilder, Key? key, }) : this( imageUrl: imageUrl, @@ -51,6 +53,7 @@ class ProfileCard extends StatelessWidget { onTap: onTap, hasStories: hasStories, onAvatarTap: onAvatarTap, + avatarBuilder: avatarBuilder, key: key, ); @@ -66,6 +69,7 @@ class ProfileCard extends StatelessWidget { final VoidCallback? onTap; final bool hasStories; final VoidCallback? onAvatarTap; + final Widget Function(Widget avatar)? avatarBuilder; @override Widget build(BuildContext context) { @@ -73,6 +77,14 @@ class ProfileCard extends StatelessWidget { final isDark = Theme.of(context).brightness == Brightness.dark; final radius = BorderRadius.circular(AppShapes.squircleRadius); final borderColor = isDark ? AppColors.grey800 : AppColors.grey200; + final avatar = ProfileAvatar( + avatarUrl: imageUrl.isNotEmpty ? imageUrl : null, + displayName: userName, + size: 36, + hasStories: hasStories, + onTap: onAvatarTap ?? onTap, + avatarBuilder: avatarBuilder, + ); final Widget content = ConstrainedBox( constraints: const BoxConstraints(minHeight: 60), @@ -97,13 +109,7 @@ class ProfileCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ProfileAvatar( - avatarUrl: imageUrl.isNotEmpty ? imageUrl : null, - displayName: userName, - size: 36, - hasStories: hasStories, - onTap: onAvatarTap ?? onTap, - ), + avatar, const SizedBox(width: 10), Expanded( child: Column( diff --git a/lib/src/core/design_system/components/molecules/story_circle.dart b/lib/src/core/design_system/components/molecules/story_circle.dart index 0344fec0..00105048 100644 --- a/lib/src/core/design_system/components/molecules/story_circle.dart +++ b/lib/src/core/design_system/components/molecules/story_circle.dart @@ -12,24 +12,28 @@ class StoryCircle extends StatelessWidget { final String userName; final String imageUrl; final String live; + final Widget Function(Widget avatar)? avatarBuilder; const StoryCircle._({ required this.type, required this.userName, required this.imageUrl, required this.live, + this.avatarBuilder, }); /// Constructor variant for an unread story with an accent border. factory StoryCircle.story({ required String userName, required String imageUrl, + Widget Function(Widget avatar)? avatarBuilder, }) { return StoryCircle._( type: StoryType.story, userName: userName, imageUrl: imageUrl, live: '', + avatarBuilder: avatarBuilder, ); } @@ -38,22 +42,29 @@ class StoryCircle extends StatelessWidget { required String userName, required String imageUrl, required String live, + Widget Function(Widget avatar)? avatarBuilder, }) { return StoryCircle._( type: StoryType.live, userName: userName, imageUrl: imageUrl, live: live, + avatarBuilder: avatarBuilder, ); } /// Constructor variant for a "Close Friends" story with a green border. - factory StoryCircle.cf({required String userName, required String imageUrl}) { + factory StoryCircle.cf({ + required String userName, + required String imageUrl, + Widget Function(Widget avatar)? avatarBuilder, + }) { return StoryCircle._( type: StoryType.cf, userName: userName, imageUrl: imageUrl, live: '', + avatarBuilder: avatarBuilder, ); } @@ -62,12 +73,14 @@ class StoryCircle extends StatelessWidget { factory StoryCircle.create({ required String userName, required String imageUrl, + Widget Function(Widget avatar)? avatarBuilder, }) { return StoryCircle._( type: StoryType.create, userName: userName, imageUrl: imageUrl, live: '', + avatarBuilder: avatarBuilder, ); } @@ -111,20 +124,9 @@ class StoryCircle extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(_ringGap), child: ClipOval( - child: imageUrl.isNotEmpty - ? CachedNetworkImage( - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - imageUrl: imageUrl, - width: _imageSize, - height: _imageSize, - fit: BoxFit.cover, - errorWidget: (context, url, error) => - const DefaultProfileAvatar( - size: _imageSize, - ), - ) - : const DefaultProfileAvatar(size: _imageSize), + child: + avatarBuilder?.call(_buildAvatar()) ?? + _buildAvatar(), ), ), ), @@ -160,6 +162,22 @@ class StoryCircle extends StatelessWidget { return null; } } + + Widget _buildAvatar() { + if (imageUrl.isEmpty) { + return const DefaultProfileAvatar(size: _imageSize); + } + return CachedNetworkImage( + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + imageUrl: imageUrl, + width: _imageSize, + height: _imageSize, + fit: BoxFit.cover, + errorWidget: (context, url, error) => + const DefaultProfileAvatar(size: _imageSize), + ); + } } class _LiveBadge extends StatelessWidget { 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..0ab11854 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 @@ -13,6 +13,7 @@ class ChatListItemData { this.avatarUrl, this.verified = false, this.unread = false, + this.avatarBuilder, }); final String? avatarUrl; @@ -22,6 +23,7 @@ class ChatListItemData { final String preview; final bool verified; final bool unread; + final Widget Function(Widget avatar)? avatarBuilder; } class ChatListPageTemplate extends StatelessWidget { @@ -34,6 +36,7 @@ class ChatListPageTemplate extends StatelessWidget { this.loadingItemCount = 8, this.onAddTap, this.onRefresh, + this.itemWrapper, }); const ChatListPageTemplate.loading({ @@ -42,6 +45,7 @@ class ChatListPageTemplate extends StatelessWidget { this.loadingItemCount = 8, this.onAddTap, this.onRefresh, + this.itemWrapper, }) : items = const [], onItemTap = _noopItemTap, loading = true; @@ -53,6 +57,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 +92,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; + }, ), ), ), @@ -110,16 +119,17 @@ class _ChatTile extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final onSurface = theme.colorScheme.onSurface; + final avatar = UserAvatar( + imageUrl: data.avatarUrl ?? '', + username: data.handle, + size: 50.45, + ); return ListTile( onTap: onTap, horizontalTitleGap: 12, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - leading: UserAvatar( - imageUrl: data.avatarUrl ?? '', - username: data.handle, - size: 50.45, - ), + leading: data.avatarBuilder?.call(avatar) ?? avatar, title: Row( children: [ Expanded( diff --git a/lib/src/core/design_system/templates/info_bar_template.dart b/lib/src/core/design_system/templates/info_bar_template.dart index 02683340..f5848df5 100644 --- a/lib/src/core/design_system/templates/info_bar_template.dart +++ b/lib/src/core/design_system/templates/info_bar_template.dart @@ -23,6 +23,7 @@ class InfoBarTemplate extends StatefulWidget { this.altAvailable = false, this.onAltTap, this.avatarUrl, + this.avatarBuilder, }); /// Display name @@ -57,6 +58,7 @@ class InfoBarTemplate extends StatefulWidget { /// Avatar shown on left of the name/handle. final String? avatarUrl; + final Widget Function(Widget avatar)? avatarBuilder; @override State createState() => _InfoBarTemplateState(); @@ -95,6 +97,7 @@ class _InfoBarTemplateState extends State displayName: widget.displayName, size: 32, onTap: widget.onAvatarTap ?? widget.onTitleTap, + avatarBuilder: widget.avatarBuilder, ), ), diff --git a/lib/src/core/design_system/templates/profile_page_template.dart b/lib/src/core/design_system/templates/profile_page_template.dart index e25ef529..98930153 100644 --- a/lib/src/core/design_system/templates/profile_page_template.dart +++ b/lib/src/core/design_system/templates/profile_page_template.dart @@ -24,6 +24,7 @@ class ProfilePageTemplate extends StatelessWidget { required this.tabsWidget, super.key, this.avatarUrl, + this.avatarBuilder, this.description, this.links, this.knownFollowers, @@ -58,6 +59,7 @@ class ProfilePageTemplate extends StatelessWidget { final String followersCount; final String followingCount; final String? avatarUrl; + final Widget Function(Widget avatar)? avatarBuilder; final String? description; final List? links; final KnownFollowers? knownFollowers; @@ -119,6 +121,7 @@ class ProfilePageTemplate extends StatelessWidget { followersCount: followersCount, followingCount: followingCount, avatarUrl: avatarUrl, + avatarBuilder: avatarBuilder, description: description, links: links, knownFollowers: knownFollowers, @@ -169,6 +172,7 @@ class _ProfileHeaderSection extends StatelessWidget { required this.isBlocking, required this.isEarlySupporter, this.avatarUrl, + this.avatarBuilder, this.description, this.links, this.knownFollowers, @@ -190,6 +194,7 @@ class _ProfileHeaderSection extends StatelessWidget { final String followersCount; final String followingCount; final String? avatarUrl; + final Widget Function(Widget avatar)? avatarBuilder; final String? description; final List? links; final KnownFollowers? knownFollowers; @@ -211,6 +216,17 @@ class _ProfileHeaderSection extends StatelessWidget { @override Widget build(BuildContext context) { + final avatar = ProfileAvatar( + avatarUrl: avatarUrl, + displayName: displayName, + hasStories: hasStories, + size: 80, + onTap: onAvatarTap, + showAddButton: isCurrentUser, + onAddTap: onAddStoryTap, + avatarBuilder: avatarBuilder, + ); + return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -219,17 +235,7 @@ class _ProfileHeaderSection extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Skeleton.keep( - child: ProfileAvatar( - avatarUrl: avatarUrl, - displayName: displayName, - hasStories: hasStories, - size: 80, - onTap: onAvatarTap, - showAddButton: isCurrentUser, - onAddTap: onAddStoryTap, - ), - ), + Skeleton.keep(child: avatar), const SizedBox(width: 16), Expanded( child: Column( diff --git a/lib/src/core/l10n/app_localizations.dart b/lib/src/core/l10n/app_localizations.dart index d29b8bf6..cd68a7cc 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,216 @@ abstract class AppLocalizations { int count, ); + /// 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..2971881e 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,119 @@ class AppLocalizationsEn extends AppLocalizations { return 'Followed by $firstName, $secondName, and $_temp0'; } + @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..b33fbf65 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,147 @@ } }, + "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/feed_generator_moderation.dart b/lib/src/core/moderation/feed_generator_moderation.dart new file mode 100644 index 00000000..5755a7d2 --- /dev/null +++ b/lib/src/core/moderation/feed_generator_moderation.dart @@ -0,0 +1,10 @@ +import 'package:spark/src/core/moderation/moderation_subject.dart'; +import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; + +ModerationSubject feedGeneratorModerationSubject(GeneratorView generator) { + return ModerationSubject.content( + labels: generator.labels ?? const [], + authorLabels: generator.creator.labels ?? const [], + subjectDid: generator.creator.did, + ); +} diff --git a/lib/src/core/moderation/moderated_content.dart b/lib/src/core/moderation/moderated_content.dart new file mode 100644 index 00000000..e02c7f8a --- /dev/null +++ b/lib/src/core/moderation/moderated_content.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:poptart_lex/com/atproto/label/defs.dart'; +import 'package:spark/src/core/moderation/moderation_details_sheet.dart'; +import 'package:spark/src/core/moderation/moderation_engine.dart'; +import 'package:spark/src/core/moderation/moderation_models.dart'; +import 'package:spark/src/core/moderation/moderation_presentation.dart'; +import 'package:spark/src/core/moderation/moderation_provider.dart'; +import 'package:spark/src/core/moderation/moderation_subject.dart'; + +/// Applies the same moderation decision and reveal rules to every surface. +class ModeratedContent extends ConsumerStatefulWidget { + const ModeratedContent({ + required this.subject, + required this.context, + required this.child, + super.key, + this.presentation = const ModerationPresentation.standard(), + this.filterReplacement = const SizedBox.shrink(), + this.onConcealChanged, + }); + + final ModerationSubject subject; + final ModerationContext context; + final Widget child; + final ModerationPresentation presentation; + final Widget filterReplacement; + final ValueChanged? onConcealChanged; + + @override + ConsumerState createState() => _ModeratedContentState(); +} + +class ModeratedProfileAvatar extends StatelessWidget { + const ModeratedProfileAvatar({ + required this.labels, + required this.subjectDid, + required this.child, + super.key, + }); + + final List