diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index dbb24ea..acaa5b8 100644 --- a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart +++ b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart @@ -401,7 +401,7 @@ final directories = <_widgetbook.WidgetbookNode>[ name: 'Message Composer', children: [ _widgetbook.WidgetbookComponent( - name: 'MessageComposerAttachmentLinkPreview', + name: 'MessageComposerLinkPreviewAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', @@ -412,7 +412,7 @@ final directories = <_widgetbook.WidgetbookNode>[ ], ), _widgetbook.WidgetbookComponent( - name: 'MessageComposerAttachmentMediaFile', + name: 'MessageComposerMediaFileAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', @@ -423,7 +423,7 @@ final directories = <_widgetbook.WidgetbookNode>[ ], ), _widgetbook.WidgetbookComponent( - name: 'MessageComposerAttachmentReply', + name: 'MessageComposerReplyAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', @@ -434,7 +434,7 @@ final directories = <_widgetbook.WidgetbookNode>[ ], ), _widgetbook.WidgetbookComponent( - name: 'StreamBaseMessageComposer', + name: 'StreamCoreMessageComposer', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', diff --git a/apps/design_system_gallery/lib/components/context_menu/stream_context_menu.dart b/apps/design_system_gallery/lib/components/context_menu/stream_context_menu.dart index f777195..8e66864 100644 --- a/apps/design_system_gallery/lib/components/context_menu/stream_context_menu.dart +++ b/apps/design_system_gallery/lib/components/context_menu/stream_context_menu.dart @@ -24,58 +24,51 @@ class _PlaygroundDemo extends StatefulWidget { } class _PlaygroundDemoState extends State<_PlaygroundDemo> { + String? _inlineTapped; + String? _dialogReturned; + @override Widget build(BuildContext context) { final icons = context.streamIcons; + final spacing = context.streamSpacing; - final itemCount = context.knobs.int.slider( - label: 'Item Count', + final actionCount = context.knobs.int.slider( + label: 'Action Count', initialValue: 5, min: 1, max: 8, - description: 'Number of regular menu items to display.', + description: 'Number of regular menu actions to display.', ); final showLeadingIcon = context.knobs.boolean( label: 'Leading Icon', initialValue: true, - description: 'Show an icon before each item label.', + description: 'Show an icon before each action label.', ); final showTrailingIcon = context.knobs.boolean( label: 'Trailing Icon', - description: 'Show a chevron after each item label.', + description: 'Show a chevron after each action label.', ); final showSeparator = context.knobs.boolean( label: 'Separator', initialValue: true, - description: 'Show a divider before the last item group.', + description: 'Show a divider before the destructive group.', ); - final showDestructiveItem = context.knobs.boolean( - label: 'Destructive Item', + final showDestructiveAction = context.knobs.boolean( + label: 'Destructive Action', initialValue: true, description: 'Include a destructive action at the end.', ); - final hasDisabledItem = context.knobs.boolean( - label: 'Disabled Item', - description: 'Make the second item disabled.', + final hasDisabledAction = context.knobs.boolean( + label: 'Disabled Action', + description: 'Make the second action disabled.', ); - void onTap(String label) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text('Tapped: $label'), - duration: const Duration(seconds: 1), - ), - ); - } - - final itemData = <({String label, IconData icon})>[ + final actionData = <({String label, IconData icon})>[ (label: 'Reply', icon: icons.arrowShareLeft), (label: 'Thread Reply', icon: icons.bubbleText6ChatMessage), (label: 'Pin to Conversation', icon: icons.pin), @@ -86,25 +79,129 @@ class _PlaygroundDemoState extends State<_PlaygroundDemo> { (label: 'Flag Message', icon: icons.flag2), ]; - final items = [ - for (var i = 0; i < itemCount; i++) - StreamContextMenuItem( - label: Text(itemData[i].label), - leading: showLeadingIcon ? Icon(itemData[i].icon) : null, - trailing: showTrailingIcon ? Icon(icons.chevronRight) : null, - onPressed: (hasDisabledItem && i == 1) ? null : () => onTap(itemData[i].label), - ), - if (showSeparator && showDestructiveItem) const StreamContextMenuSeparator(), - if (showDestructiveItem) - StreamContextMenuItem.destructive( - label: const Text('Delete Message'), - leading: showLeadingIcon ? Icon(icons.trashBin) : null, - onPressed: () => onTap('Delete Message'), - ), - ]; + List buildInlineActions() { + return [ + for (var i = 0; i < actionCount; i++) + StreamContextMenuAction( + value: actionData[i].label, + label: Text(actionData[i].label), + leading: showLeadingIcon ? Icon(actionData[i].icon) : null, + trailing: showTrailingIcon ? Icon(icons.chevronRight) : null, + enabled: !(hasDisabledAction && i == 1), + onTap: () => setState(() => _inlineTapped = actionData[i].label), + ), + if (showSeparator && showDestructiveAction) const StreamContextMenuSeparator(), + if (showDestructiveAction) + StreamContextMenuAction.destructive( + value: 'Delete Message', + label: const Text('Delete Message'), + leading: showLeadingIcon ? Icon(icons.trashBin) : null, + onTap: () => setState(() => _inlineTapped = 'Delete Message'), + ), + ]; + } + + List buildDialogActions() { + return [ + for (var i = 0; i < actionCount; i++) + StreamContextMenuAction( + value: actionData[i].label, + label: Text(actionData[i].label), + leading: showLeadingIcon ? Icon(actionData[i].icon) : null, + trailing: showTrailingIcon ? Icon(icons.chevronRight) : null, + enabled: !(hasDisabledAction && i == 1), + ), + if (showSeparator && showDestructiveAction) const StreamContextMenuSeparator(), + if (showDestructiveAction) + StreamContextMenuAction.destructive( + value: 'Delete Message', + label: const Text('Delete Message'), + leading: showLeadingIcon ? Icon(icons.trashBin) : null, + ), + ]; + } + + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; return Center( - child: StreamContextMenu(children: items), + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(vertical: spacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Inline header. + Text( + 'Inline', + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + SizedBox(height: spacing.xxs), + Text( + 'Rendered directly — taps fire onTap', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + SizedBox(height: spacing.sm), + + StreamContextMenu(children: buildInlineActions()), + SizedBox(height: spacing.xs), + _ResultChip( + label: _inlineTapped != null ? 'onTap: "$_inlineTapped"' : 'Tap an action', + isActive: _inlineTapped != null, + ), + + Padding( + padding: EdgeInsets.symmetric(vertical: spacing.xl), + child: Divider(indent: spacing.xl, endIndent: spacing.xl), + ), + + // Dialog header. + Text( + 'Dialog', + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + SizedBox(height: spacing.xxs), + Text( + 'Opened in a route — taps return value via pop', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + SizedBox(height: spacing.sm), + + StreamButton( + label: 'Open as Dialog', + type: StreamButtonType.outline, + size: StreamButtonSize.small, + onTap: () async { + final result = await showDialog( + context: context, + useRootNavigator: false, + builder: (_) => Dialog( + backgroundColor: StreamColors.transparent, + child: StreamContextMenu( + children: buildDialogActions(), + ), + ), + ); + if (mounted && result != null) { + setState(() => _dialogReturned = result); + } + }, + ), + SizedBox(height: spacing.xs), + _ResultChip( + label: _dialogReturned != null ? 'value: "$_dialogReturned"' : 'Open and select', + isActive: _dialogReturned != null, + ), + ], + ), + ), ); } } @@ -130,10 +227,14 @@ Widget buildStreamContextMenuShowcase(BuildContext context) { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _ItemStatesSection(), + const _ActionStatesSection(), SizedBox(height: spacing.xl), const _MenuCompositionsSection(), SizedBox(height: spacing.xl), + const _DialogIntegrationSection(), + SizedBox(height: spacing.xl), + const _CustomThemingSection(), + SizedBox(height: spacing.xl), const _RealWorldSection(), ], ), @@ -142,11 +243,11 @@ Widget buildStreamContextMenuShowcase(BuildContext context) { } // ============================================================================= -// Item States Section +// Action States Section // ============================================================================= -class _ItemStatesSection extends StatelessWidget { - const _ItemStatesSection(); +class _ActionStatesSection extends StatelessWidget { + const _ActionStatesSection(); @override Widget build(BuildContext context) { @@ -156,7 +257,7 @@ class _ItemStatesSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: spacing.md, children: const [ - _SectionLabel(label: 'ITEM STATES'), + _SectionLabel(label: 'ACTION STATES'), _NormalStatesCard(), _DestructiveStatesCard(), ], @@ -179,7 +280,7 @@ class _NormalStatesCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: spacing.xxs, children: [ - StreamContextMenuItem( + StreamContextMenuAction( label: const Text( 'With Leading & Trailing', maxLines: 1, @@ -187,21 +288,19 @@ class _NormalStatesCard extends StatelessWidget { ), leading: Icon(icons.plusLarge), trailing: Icon(icons.chevronRight), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('With Leading Only'), leading: Icon(icons.plusLarge), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Label Only'), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Disabled'), leading: Icon(icons.plusLarge), trailing: Icon(icons.chevronRight), + enabled: false, ), ], ), @@ -224,7 +323,7 @@ class _DestructiveStatesCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: spacing.xxs, children: [ - StreamContextMenuItem.destructive( + StreamContextMenuAction.destructive( label: const Text( 'With Leading & Trailing', maxLines: 1, @@ -232,21 +331,19 @@ class _DestructiveStatesCard extends StatelessWidget { ), leading: Icon(icons.trashBin), trailing: Icon(icons.chevronRight), - onPressed: () {}, ), - StreamContextMenuItem.destructive( + StreamContextMenuAction.destructive( label: const Text('With Leading Only'), leading: Icon(icons.trashBin), - onPressed: () {}, ), - StreamContextMenuItem.destructive( + StreamContextMenuAction.destructive( label: const Text('Label Only'), - onPressed: () {}, ), - StreamContextMenuItem.destructive( + StreamContextMenuAction.destructive( label: const Text('Disabled'), leading: Icon(icons.trashBin), trailing: Icon(icons.chevronRight), + enabled: false, ), ], ), @@ -273,21 +370,18 @@ class _MenuCompositionsSection extends StatelessWidget { const _SectionLabel(label: 'MENU COMPOSITIONS'), _ExampleCard( title: 'Simple Menu', - description: 'Basic items without icons', + description: 'Basic actions without icons', child: Center( child: StreamContextMenu( children: [ - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Cut'), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Copy'), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Paste'), - onPressed: () {}, ), ], ), @@ -295,24 +389,21 @@ class _MenuCompositionsSection extends StatelessWidget { ), _ExampleCard( title: 'With Icons', - description: 'Items with leading icons', + description: 'Actions with leading icons', child: Center( child: StreamContextMenu( children: [ - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Reply'), leading: Icon(icons.arrowShareLeft), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Copy Message'), leading: Icon(icons.squareBehindSquare2Copy), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Flag Message'), leading: Icon(icons.flag2), - onPressed: () {}, ), ], ), @@ -324,21 +415,18 @@ class _MenuCompositionsSection extends StatelessWidget { child: Center( child: StreamContextMenu( children: [ - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Reply'), leading: Icon(icons.arrowShareLeft), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Copy Message'), leading: Icon(icons.squareBehindSquare2Copy), - onPressed: () {}, ), const StreamContextMenuSeparator(), - StreamContextMenuItem.destructive( + StreamContextMenuAction.destructive( label: const Text('Delete'), leading: Icon(icons.trashBin), - onPressed: () {}, ), ], ), @@ -346,61 +434,336 @@ class _MenuCompositionsSection extends StatelessWidget { ), _ExampleCard( title: 'Auto-Separated', - description: 'Using StreamContextMenu.separated', + description: 'Using StreamContextMenuAction.separated', child: Center( - child: StreamContextMenu.separated( - children: [ - StreamContextMenuItem( - label: const Text('Reply'), - leading: Icon(icons.arrowShareLeft), - onPressed: () {}, - ), - StreamContextMenuItem( - label: const Text('Copy Message'), - leading: Icon(icons.squareBehindSquare2Copy), - onPressed: () {}, - ), - StreamContextMenuItem( - label: const Text('Flag Message'), - leading: Icon(icons.flag2), - onPressed: () {}, + child: StreamContextMenu( + children: StreamContextMenuAction.separated( + items: [ + StreamContextMenuAction( + label: const Text('Reply'), + leading: Icon(icons.arrowShareLeft), + ), + StreamContextMenuAction( + label: const Text('Copy Message'), + leading: Icon(icons.squareBehindSquare2Copy), + ), + StreamContextMenuAction( + label: const Text('Flag Message'), + leading: Icon(icons.flag2), + ), + ], + ), + ), + ), + ), + _ExampleCard( + title: 'Auto-Sectioned', + description: 'Using StreamContextMenuAction.sectioned', + child: Center( + child: StreamContextMenu( + children: StreamContextMenuAction.sectioned( + sections: [ + [ + StreamContextMenuAction( + label: const Text('Reply'), + leading: Icon(icons.arrowShareLeft), + ), + StreamContextMenuAction( + label: const Text('Copy Message'), + leading: Icon(icons.squareBehindSquare2Copy), + ), + StreamContextMenuAction( + label: const Text('Flag Message'), + leading: Icon(icons.flag2), + ), + ], + [ + StreamContextMenuAction.destructive( + label: const Text('Delete Message'), + leading: Icon(icons.trashBin), + ), + ], + ], + ), + ), + ), + ), + _ExampleCard( + title: 'Auto-Partitioned', + description: 'Using StreamContextMenuAction.partitioned', + child: Center( + child: StreamContextMenu( + children: StreamContextMenuAction.partitioned( + items: [ + StreamContextMenuAction( + label: const Text('Reply'), + leading: Icon(icons.arrowShareLeft), + ), + StreamContextMenuAction( + label: const Text('Copy Message'), + leading: Icon(icons.squareBehindSquare2Copy), + ), + StreamContextMenuAction( + label: const Text('Flag Message'), + leading: Icon(icons.flag2), + ), + StreamContextMenuAction.destructive( + label: const Text('Delete Message'), + leading: Icon(icons.trashBin), + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Dialog Integration Section +// ============================================================================= + +enum _SampleAction { reply, copy, flag, delete } + +class _DialogIntegrationSection extends StatelessWidget { + const _DialogIntegrationSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: const [ + _SectionLabel(label: 'DIALOG INTEGRATION'), + _TypedValueReturnCard(), + _EnumValueReturnCard(), + ], + ); + } +} + +class _TypedValueReturnCard extends StatefulWidget { + const _TypedValueReturnCard(); + + @override + State<_TypedValueReturnCard> createState() => _TypedValueReturnCardState(); +} + +class _TypedValueReturnCardState extends State<_TypedValueReturnCard> { + String? _result; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return _ExampleCard( + title: 'String Value Return', + description: 'Actions carry a String value returned via Navigator.pop', + child: Center( + child: Column( + spacing: spacing.sm, + children: [ + StreamButton( + label: 'Open Menu', + type: StreamButtonType.outline, + size: StreamButtonSize.small, + onTap: () async { + final result = await showDialog( + context: context, + builder: (_) => Dialog( + backgroundColor: StreamColors.transparent, + child: StreamContextMenu( + children: [ + StreamContextMenuAction( + value: 'reply', + label: const Text('Reply'), + leading: Icon(icons.arrowShareLeft), + ), + StreamContextMenuAction( + value: 'copy', + label: const Text('Copy Message'), + leading: Icon(icons.squareBehindSquare2Copy), + ), + StreamContextMenuAction( + value: 'flag', + label: const Text('Flag Message'), + leading: Icon(icons.flag2), + ), + ], + ), + ), + ); + setState(() => _result = result); + }, + ), + _ResultChip( + label: _result != null ? '"$_result"' : 'No selection yet', + isActive: _result != null, + ), + ], + ), + ), + ); + } +} + +class _EnumValueReturnCard extends StatefulWidget { + const _EnumValueReturnCard(); + + @override + State<_EnumValueReturnCard> createState() => _EnumValueReturnCardState(); +} + +class _EnumValueReturnCardState extends State<_EnumValueReturnCard> { + _SampleAction? _result; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return _ExampleCard( + title: 'Enum Value Return', + description: 'Type-safe actions using an enum as the value type', + child: Center( + child: Column( + spacing: spacing.sm, + children: [ + StreamButton( + label: 'Open Menu', + type: StreamButtonType.outline, + size: StreamButtonSize.small, + onTap: () async { + final result = await showDialog<_SampleAction>( + context: context, + builder: (_) => Dialog( + backgroundColor: StreamColors.transparent, + child: StreamContextMenu( + children: [ + StreamContextMenuAction<_SampleAction>( + value: _SampleAction.reply, + label: const Text('Reply'), + leading: Icon(icons.arrowShareLeft), + ), + StreamContextMenuAction<_SampleAction>( + value: _SampleAction.copy, + label: const Text('Copy Message'), + leading: Icon(icons.squareBehindSquare2Copy), + ), + StreamContextMenuAction<_SampleAction>( + value: _SampleAction.flag, + label: const Text('Flag Message'), + leading: Icon(icons.flag2), + ), + const StreamContextMenuSeparator(), + StreamContextMenuAction<_SampleAction>.destructive( + value: _SampleAction.delete, + label: const Text('Delete Message'), + leading: Icon(icons.trashBin), + ), + ], + ), + ), + ); + setState(() => _result = result); + }, + ), + _ResultChip( + label: _result != null ? '_SampleAction.${_result!.name}' : 'No selection yet', + isActive: _result != null, + ), + ], + ), + ), + ); + } +} + +// ============================================================================= +// Custom Theming Section +// ============================================================================= + +class _CustomThemingSection extends StatelessWidget { + const _CustomThemingSection(); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'CUSTOM THEMING'), + _ExampleCard( + title: 'Compact Style', + description: 'Smaller padding and text for dense layouts', + child: Center( + child: StreamContextMenuActionTheme( + data: const StreamContextMenuActionThemeData( + style: StreamContextMenuActionStyle( + minimumSize: WidgetStatePropertyAll(Size(200, 32)), + iconSize: WidgetStatePropertyAll(16), + padding: WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), ), - ], + ), + child: StreamContextMenu( + children: [ + StreamContextMenuAction( + label: const Text('Reply'), + leading: Icon(icons.arrowShareLeft), + ), + StreamContextMenuAction( + label: const Text('Copy'), + leading: Icon(icons.squareBehindSquare2Copy), + ), + StreamContextMenuAction( + label: const Text('Flag'), + leading: Icon(icons.flag2), + ), + ], + ), ), ), ), _ExampleCard( title: 'Sub-Menu Navigation', - description: 'Back item with nested items', + description: 'Back action with tertiary styling via a local theme', child: Center( child: StreamContextMenu( children: [ - StreamContextMenuItemTheme( - data: StreamContextMenuItemThemeData( - style: StreamContextMenuItemStyle( + StreamContextMenuActionTheme( + data: StreamContextMenuActionThemeData( + style: StreamContextMenuActionStyle( foregroundColor: WidgetStatePropertyAll( - context.streamColorScheme.textTertiary, + colorScheme.textTertiary, ), iconColor: WidgetStatePropertyAll( - context.streamColorScheme.textTertiary, + colorScheme.textTertiary, ), ), ), - child: StreamContextMenuItem( + child: StreamContextMenuAction( label: const Text('Reactions'), leading: Icon(icons.chevronLeft), - onPressed: () {}, ), ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Love'), leading: Icon(icons.heart2), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Smile'), leading: Icon(icons.emojiSmile), - onPressed: () {}, ), ], ), @@ -434,56 +797,46 @@ class _RealWorldSection extends StatelessWidget { child: Center( child: StreamContextMenu( children: [ - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Reply'), leading: Icon(icons.arrowShareLeft), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Thread Reply'), leading: Icon(icons.bubbleText6ChatMessage), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Pin to Conversation'), leading: Icon(icons.pin), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Copy Message'), leading: Icon(icons.squareBehindSquare2Copy), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Mark Unread'), leading: Icon(icons.bubbleWideNotificationChatMessage), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Remind Me'), leading: Icon(icons.bellNotification), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Save For Later'), leading: Icon(icons.fileBend), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Flag Message'), leading: Icon(icons.flag2), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Mute User'), leading: Icon(icons.mute), - onPressed: () {}, ), const StreamContextMenuSeparator(), - StreamContextMenuItem.destructive( + StreamContextMenuAction.destructive( label: const Text('Block User'), leading: Icon(icons.circleBanSign), - onPressed: () {}, ), ], ), @@ -495,36 +848,30 @@ class _RealWorldSection extends StatelessWidget { child: Center( child: StreamContextMenu( children: [ - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Reply'), leading: Icon(icons.arrowShareLeft), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Thread Reply'), leading: Icon(icons.bubbleText6ChatMessage), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Pin to Conversation'), leading: Icon(icons.pin), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Copy Message'), leading: Icon(icons.squareBehindSquare2Copy), - onPressed: () {}, ), - StreamContextMenuItem( + StreamContextMenuAction( label: const Text('Edit Message'), leading: Icon(icons.editBig), - onPressed: () {}, ), const StreamContextMenuSeparator(), - StreamContextMenuItem.destructive( + StreamContextMenuAction.destructive( label: const Text('Delete Message'), leading: Icon(icons.trashBin), - onPressed: () {}, ), ], ), @@ -643,3 +990,38 @@ class _SectionLabel extends StatelessWidget { ); } } + +class _ResultChip extends StatelessWidget { + const _ResultChip({required this.label, required this.isActive}); + + final String label; + final bool isActive; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Container( + padding: EdgeInsets.symmetric( + horizontal: spacing.sm, + vertical: spacing.xs, + ), + decoration: BoxDecoration( + color: isActive ? colorScheme.accentPrimary.withValues(alpha: 0.1) : colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isActive ? colorScheme.accentPrimary : colorScheme.borderDefault, + ), + ), + child: Text( + label, + style: textTheme.metadataEmphasis.copyWith( + fontFamily: isActive ? 'monospace' : null, + color: isActive ? colorScheme.accentPrimary : colorScheme.textTertiary, + ), + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 94c1ef5..13fda19 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -11,7 +11,7 @@ export 'components/buttons/stream_emoji_button.dart' hide DefaultStreamEmojiButt export 'components/common/stream_checkbox.dart' hide DefaultStreamCheckbox; export 'components/common/stream_progress_bar.dart' hide DefaultStreamProgressBar; export 'components/context_menu/stream_context_menu.dart'; -export 'components/context_menu/stream_context_menu_item.dart' hide DefaultStreamContextMenuItem; +export 'components/context_menu/stream_context_menu_action.dart' hide DefaultStreamContextMenuAction; export 'components/controls/stream_remove_control.dart'; export 'components/message_composer.dart'; export 'components/reaction/picker/stream_reaction_picker_sheet.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu.dart b/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu.dart index caf613d..9833cbd 100644 --- a/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu.dart +++ b/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu.dart @@ -13,34 +13,35 @@ import '../../theme/stream_theme_extensions.dart'; /// decorated container with a shape, border, and drop shadow. The container /// is sized intrinsically to the width of its widest child. /// -/// Children are typically [StreamContextMenuItem] and -/// [StreamContextMenuSeparator] widgets. Use [StreamContextMenu.separated] -/// to automatically insert separators between each child. +/// Children are typically [StreamContextMenuAction] and +/// [StreamContextMenuSeparator] widgets. For automatic separator insertion, +/// use [StreamContextMenuAction.separated] to divide every item, or +/// [StreamContextMenuAction.sectioned] to divide logical groups. /// /// The container's appearance can be customized via [StreamContextMenuTheme]. /// /// {@tool snippet} /// -/// Display a context menu with items: +/// A basic context menu with a manual separator: /// /// ```dart /// StreamContextMenu( /// children: [ -/// StreamContextMenuItem( +/// StreamContextMenuAction( +/// value: 'reply', /// label: Text('Reply'), /// leading: Icon(Icons.reply), -/// onPressed: () => handleReply(), /// ), -/// StreamContextMenuItem( +/// StreamContextMenuAction( +/// value: 'copy', /// label: Text('Copy Message'), /// leading: Icon(Icons.copy), -/// onPressed: () => handleCopy(), /// ), /// StreamContextMenuSeparator(), -/// StreamContextMenuItem.destructive( +/// StreamContextMenuAction.destructive( +/// value: 'block', /// label: Text('Block User'), /// leading: Icon(Icons.block), -/// onPressed: () => handleBlock(), /// ), /// ], /// ) @@ -49,10 +50,14 @@ import '../../theme/stream_theme_extensions.dart'; /// /// See also: /// -/// * [StreamContextMenuItem], for individual menu items. -/// * [StreamContextMenuSeparator], for visual dividers between groups. +/// * [StreamContextMenuAction], for individual menu items. +/// * [StreamContextMenuAction.separated], for auto-inserting separators between +/// every item. +/// * [StreamContextMenuAction.sectioned], for auto-inserting separators between +/// logical groups of items. +/// * [StreamContextMenuSeparator], for manual visual dividers. /// * [StreamContextMenuTheme], for customizing container appearance. -/// * [StreamContextMenuItemTheme], for customizing item appearance. +/// * [StreamContextMenuActionTheme], for customizing item appearance. class StreamContextMenu extends StatelessWidget { /// Creates a context menu container. const StreamContextMenu({ @@ -61,41 +66,9 @@ class StreamContextMenu extends StatelessWidget { this.clipBehavior = Clip.hardEdge, }); - /// Creates a context menu with [StreamContextMenuSeparator] widgets - /// automatically inserted between each child. - /// - /// {@tool snippet} - /// - /// ```dart - /// StreamContextMenu.separated( - /// children: [ - /// StreamContextMenuItem(label: Text('Reply'), onPressed: () {}), - /// StreamContextMenuItem(label: Text('Copy'), onPressed: () {}), - /// StreamContextMenuItem(label: Text('Delete'), onPressed: () {}), - /// ], - /// ) - /// ``` - /// {@end-tool} - factory StreamContextMenu.separated({ - Key? key, - required List children, - Clip clipBehavior = Clip.hardEdge, - }) { - return StreamContextMenu( - key: key, - clipBehavior: clipBehavior, - children: [ - for (final (index, child) in children.indexed) ...[ - if (index > 0) const StreamContextMenuSeparator(), - child, - ], - ], - ); - } - /// The menu items to display. /// - /// Typically a list of [StreamContextMenuItem] and + /// Typically a list of [StreamContextMenuAction] and /// [StreamContextMenuSeparator] widgets. final List children; @@ -126,10 +99,12 @@ class StreamContextMenu extends StatelessWidget { color: effectiveBackgroundColor, shadows: effectiveBoxShadow, ), - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .stretch, - children: children, + child: SingleChildScrollView( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: children, + ), ), ), ); @@ -142,8 +117,9 @@ class StreamContextMenu extends StatelessWidget { /// separate groups of related context menu items, for example between regular /// actions and destructive actions. /// -/// For automatic separator insertion between every child, use -/// [StreamContextMenu.separated] instead. +/// For automatic separator insertion, use [StreamContextMenuAction.separated] +/// (between every item) or [StreamContextMenuAction.sectioned] (between logical +/// groups) instead of placing separators manually. /// /// {@tool snippet} /// @@ -152,12 +128,11 @@ class StreamContextMenu extends StatelessWidget { /// ```dart /// StreamContextMenu( /// children: [ -/// StreamContextMenuItem(label: Text('Reply'), onPressed: () {}), -/// StreamContextMenuItem(label: Text('Copy'), onPressed: () {}), +/// StreamContextMenuAction(label: Text('Reply')), +/// StreamContextMenuAction(label: Text('Copy')), /// StreamContextMenuSeparator(), -/// StreamContextMenuItem.destructive( +/// StreamContextMenuAction.destructive( /// label: Text('Delete'), -/// onPressed: () {}, /// ), /// ], /// ) @@ -167,8 +142,11 @@ class StreamContextMenu extends StatelessWidget { /// See also: /// /// * [StreamContextMenu], which contains this separator. -/// * [StreamContextMenu.separated], which auto-inserts separators. -/// * [StreamContextMenuItem], for menu items. +/// * [StreamContextMenuAction.separated], which auto-inserts separators +/// between every item. +/// * [StreamContextMenuAction.sectioned], which auto-inserts separators +/// between logical groups of items. +/// * [StreamContextMenuAction], for menu items. class StreamContextMenuSeparator extends StatelessWidget { /// Creates a context menu separator. const StreamContextMenuSeparator({super.key}); diff --git a/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu_action.dart b/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu_action.dart new file mode 100644 index 0000000..89a9054 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu_action.dart @@ -0,0 +1,462 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core/stream_core.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_context_menu_action_theme.dart'; +import '../../theme/primitives/stream_colors.dart'; +import '../../theme/primitives/stream_radius.dart'; +import '../../theme/primitives/stream_spacing.dart'; +import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/semantics/stream_text_theme.dart'; +import '../../theme/stream_theme_extensions.dart'; +import 'stream_context_menu.dart'; + +/// A single action row in a [StreamContextMenu]. +/// +/// [StreamContextMenuAction] displays a tappable row with an optional [leading] +/// widget, a [label] widget, and an optional [trailing] widget. It supports +/// both normal and destructive styles. +/// +/// The visual appearance adapts to interaction states (hover, pressed, +/// disabled) and can be fully customized via [StreamContextMenuActionTheme]. +/// +/// Each action carries a [value], and when tapped the default implementation +/// calls [Navigator.pop] with that value so the dialog caller can handle it. +/// Use [enabled] to control whether the action is interactive (disabled actions +/// remain visible but are not tappable). +/// +/// The type parameter [T] represents the type of [value] that will be returned +/// when the action is selected. +/// +/// A typical use case is to pass a [Text] as the [label]. If the text may be +/// long, set [Text.overflow] to [TextOverflow.ellipsis] and [Text.maxLines] +/// to 1, as without it the text will wrap to the next line. +/// +/// {@tool snippet} +/// +/// Display a normal context menu action: +/// +/// ```dart +/// StreamContextMenuAction( +/// value: 'reply', +/// label: Text('Reply'), +/// leading: Icon(Icons.reply), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Display a destructive context menu action: +/// +/// ```dart +/// StreamContextMenuAction.destructive( +/// value: 'block', +/// label: Text('Block User'), +/// leading: Icon(Icons.block), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamContextMenu], which contains these actions. +/// * [StreamContextMenuSeparator], for visual dividers between actions. +/// * [StreamContextMenuActionTheme], for customizing action appearance. +class StreamContextMenuAction extends StatelessWidget { + /// Creates a context menu action. + StreamContextMenuAction({ + super.key, + T? value, + required Widget label, + VoidCallback? onTap, + bool enabled = true, + Widget? leading, + Widget? trailing, + bool isDestructive = false, + }) : props = .new( + value: value, + label: label, + onTap: onTap, + enabled: enabled, + leading: leading, + trailing: trailing, + isDestructive: isDestructive, + ); + + /// Creates a destructive context menu action. + /// + /// Uses error/danger colors for text and icons, typically for + /// actions like "Delete", "Block", or "Remove". + /// + /// {@tool snippet} + /// + /// ```dart + /// StreamContextMenuAction.destructive( + /// value: 'block', + /// label: Text('Block User'), + /// leading: Icon(Icons.block), + /// ) + /// ``` + /// {@end-tool} + StreamContextMenuAction.destructive({ + super.key, + T? value, + required Widget label, + VoidCallback? onTap, + bool enabled = true, + Widget? leading, + Widget? trailing, + }) : props = .new( + value: value, + label: label, + onTap: onTap, + enabled: enabled, + leading: leading, + trailing: trailing, + isDestructive: true, + ); + + /// Add a [StreamContextMenuSeparator] between each action in the given + /// [items]. + /// + /// If [items] is empty or contains a single element, no separators are + /// added. The result can be passed directly to [StreamContextMenu.new]'s + /// `children` parameter. + /// + /// {@tool snippet} + /// + /// ```dart + /// StreamContextMenu( + /// children: StreamContextMenuAction.separated( + /// items: [ + /// StreamContextMenuAction(label: Text('Reply')), + /// StreamContextMenuAction(label: Text('Copy')), + /// StreamContextMenuAction(label: Text('Delete')), + /// ], + /// ), + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [StreamContextMenuSeparator], which you can use to obtain this effect + /// manually. + static List separated({ + required List items, + }) { + return [ + for (final (index, child) in items.indexed) ...[ + if (index > 0) const StreamContextMenuSeparator(), + child, + ], + ]; + } + + /// Flatten [sections] into a single list, inserting a + /// [StreamContextMenuSeparator] between each non-empty section. + /// + /// Each section is an [Iterable] of widgets that belong together as a + /// logical group. Empty sections are silently ignored so that no stray + /// separators appear. The result can be passed directly to + /// [StreamContextMenu.new]'s `children` parameter. + /// + /// {@tool snippet} + /// + /// ```dart + /// StreamContextMenu( + /// children: StreamContextMenuAction.sectioned( + /// sections: [ + /// [ + /// StreamContextMenuAction(label: Text('Reply')), + /// StreamContextMenuAction(label: Text('Copy')), + /// ], + /// [ + /// StreamContextMenuAction.destructive( + /// label: Text('Delete'), + /// ), + /// ], + /// ], + /// ), + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [StreamContextMenuSeparator], which you can use to obtain this effect + /// manually. + /// * [separated], which inserts a separator between every action instead + /// of between groups. + static List sectioned({ + required Iterable> sections, + }) { + final nonEmptySections = sections.where((s) => s.isNotEmpty); + return [ + for (final (index, section) in nonEmptySections.indexed) ...[ + if (index > 0) const StreamContextMenuSeparator(), + ...section, + ], + ]; + } + + /// Partition [items] into non-destructive and destructive groups, then + /// insert a [StreamContextMenuSeparator] between the two groups. + /// + /// Items whose [StreamContextMenuActionProps.isDestructive] is `false` form + /// the leading section; destructive items form the trailing section. + /// Under the hood this calls [sectioned] with the two groups. + /// + /// {@tool snippet} + /// + /// ```dart + /// StreamContextMenu( + /// children: StreamContextMenuAction.partitioned( + /// items: actions, + /// ), + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [sectioned], which lets you provide arbitrary section groupings. + static List partitioned({ + required List> items, + }) { + final (normal, destructive) = items.partition((it) => !it.props.isDestructive); + return sectioned(sections: [normal, destructive]); + } + + /// The props controlling the appearance and behavior of this action. + final StreamContextMenuActionProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.maybeOf(context)?.contextMenuAction; + if (builder != null) return builder(context, props); + return DefaultStreamContextMenuAction(props: props); + } +} + +/// Properties for configuring a [StreamContextMenuAction]. +/// +/// This class holds all the configuration options for a context menu action, +/// including its visual representation, behavior, and an optional [value] +/// that is returned when the action is selected. +/// +/// The type parameter [T] represents the type of [value]. +/// +/// See also: +/// +/// * [StreamContextMenuAction], which uses these properties. +/// * [DefaultStreamContextMenuAction], the default implementation. +@immutable +class StreamContextMenuActionProps { + /// Creates properties for a context menu action. + const StreamContextMenuActionProps({ + this.value, + required this.label, + this.onTap, + this.enabled = true, + this.leading, + this.trailing, + this.isDestructive = false, + }); + + /// The value returned when this action is selected inside a popup route + /// (e.g. a dialog or bottom sheet). + /// + /// When the action is rendered inline — outside any popup route — the value + /// is not returned; only [onTap] fires. + /// + /// Consumers can also use this to find, remove, reorder, or replace specific + /// actions when customizing a default list of context menu actions. + final T? value; + + /// The label widget displayed on the action. + /// + /// Typically a [Text] widget. The label fills the available horizontal + /// space, so text wrapping and overflow behavior are controlled by the + /// consumer. If the text may be long, use [TextOverflow.ellipsis] on the + /// [Text.overflow] property to truncate rather than wrap: + /// + /// ```dart + /// StreamContextMenuAction( + /// label: Text('Very long label text', overflow: TextOverflow.ellipsis), + /// ) + /// ``` + final Widget label; + + /// Called when the action is tapped. + /// + /// When used inside a popup route, this is called **after** the route is + /// dismissed with [value]. The dismissal happens first so that [onTap] can + /// safely push a new route without conflicting with the closing menu. + /// + /// When used inline (outside any popup route), this is the only callback + /// that fires. + final VoidCallback? onTap; + + /// Whether this action is interactive. + /// + /// When `false`, the action is visually styled as disabled and taps are + /// ignored. + /// + /// Defaults to `true`. + final bool enabled; + + /// An optional widget displayed before the label. + /// + /// Typically an [Icon] widget. The icon color and size are controlled by + /// [StreamContextMenuActionStyle.foregroundColor] and + /// [StreamContextMenuActionStyle.iconSize]. + final Widget? leading; + + /// An optional widget displayed after the label. + /// + /// Typically a chevron icon for sub-menu navigation, or a keyboard shortcut + /// indicator. + final Widget? trailing; + + /// Whether this action uses destructive (error/danger) styling. + /// + /// When true, the action uses [StreamColorScheme.accentError] for text and + /// icon colors. Use [StreamContextMenuAction.destructive] for convenience. + final bool isDestructive; +} + +/// Default implementation of [StreamContextMenuAction]. +/// +/// Lays out the optional [StreamContextMenuActionProps.leading], the +/// [StreamContextMenuActionProps.label], and optional +/// [StreamContextMenuActionProps.trailing] in a horizontal row. +/// +/// All visual properties are resolved from [StreamContextMenuActionTheme] with +/// fallback to sensible defaults, providing automatic state-based feedback +/// (hover, pressed, disabled). +class DefaultStreamContextMenuAction extends StatelessWidget { + /// Creates a default context menu action. + const DefaultStreamContextMenuAction({super.key, required this.props}); + + /// The props controlling the appearance and behavior of this action. + final StreamContextMenuActionProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final themeStyle = context.streamContextMenuActionTheme.style; + final defaults = _ContextMenuActionThemeDefaults(context, isDestructive: props.isDestructive); + + final effectiveBackgroundColor = themeStyle?.backgroundColor ?? defaults.backgroundColor; + final effectiveForegroundColor = themeStyle?.foregroundColor ?? defaults.foregroundColor; + final effectiveOverlayColor = themeStyle?.overlayColor ?? defaults.overlayColor; + final effectiveIconColor = themeStyle?.iconColor ?? defaults.iconColor; + final effectiveTextStyle = themeStyle?.textStyle ?? defaults.textStyle; + final effectiveIconSize = themeStyle?.iconSize ?? defaults.iconSize; + final effectiveMinimumSize = themeStyle?.minimumSize ?? defaults.minimumSize; + final effectiveMaximumSize = themeStyle?.maximumSize ?? defaults.maximumSize; + final effectivePadding = themeStyle?.padding ?? defaults.padding; + final effectiveShape = themeStyle?.shape ?? defaults.shape; + + void handleTap() { + // Dismiss the route first so that onTap can safely push a new route + // without conflicting with the closing menu. + // + // The guard ensures we only pop when the action is actually inside a + // popup route (dialog, bottom sheet, etc.). When rendered inline there + // is no route to dismiss, so only onTap fires. + if (ModalRoute.of(context) case PopupRoute()) { + Navigator.pop(context, props.value); + } + + props.onTap?.call(); + } + + return TextButton( + onPressed: props.enabled ? handleTap : null, + style: ButtonStyle( + tapTargetSize: .shrinkWrap, + visualDensity: .standard, + backgroundColor: effectiveBackgroundColor, + foregroundColor: effectiveForegroundColor, + overlayColor: effectiveOverlayColor, + iconColor: effectiveIconColor, + iconSize: effectiveIconSize, + textStyle: effectiveTextStyle, + minimumSize: effectiveMinimumSize, + maximumSize: effectiveMaximumSize, + padding: effectivePadding, + shape: effectiveShape, + ), + child: Row( + spacing: spacing.xs, + mainAxisSize: MainAxisSize.min, + children: [ + ?props.leading, + Expanded(child: props.label), + ?props.trailing, + ], + ), + ); + } +} + +// Provides default values for [StreamContextMenuActionStyle] based on +// the current [StreamColorScheme]. +class _ContextMenuActionThemeDefaults extends StreamContextMenuActionStyle { + _ContextMenuActionThemeDefaults(this.context, {required this.isDestructive}); + + final BuildContext context; + final bool isDestructive; + + late final StreamColorScheme _colorScheme = context.streamColorScheme; + late final StreamTextTheme _textTheme = context.streamTextTheme; + late final StreamSpacing _spacing = context.streamSpacing; + late final StreamRadius _radius = context.streamRadius; + + @override + WidgetStateProperty get backgroundColor => const WidgetStatePropertyAll(StreamColors.transparent); + + @override + WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return isDestructive ? _colorScheme.accentError : _colorScheme.textPrimary; + }); + + @override + WidgetStateProperty get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty get textStyle => WidgetStatePropertyAll(_textTheme.bodyEmphasis); + + @override + WidgetStateProperty get iconColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return isDestructive ? _colorScheme.accentError : _colorScheme.textSecondary; + }); + + @override + WidgetStateProperty get iconSize => const WidgetStatePropertyAll(20); + + @override + WidgetStateProperty get minimumSize => const WidgetStatePropertyAll(Size(242, 40)); + + @override + WidgetStateProperty get maximumSize => const WidgetStatePropertyAll(Size.infinite); + + @override + WidgetStateProperty get padding => WidgetStatePropertyAll( + .symmetric(horizontal: _spacing.sm, vertical: _spacing.xs + _spacing.xxxs), + ); + + @override + WidgetStateProperty get shape => WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: .all(_radius.md)), + ); +} diff --git a/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu_item.dart b/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu_item.dart deleted file mode 100644 index 1a8e6eb..0000000 --- a/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu_item.dart +++ /dev/null @@ -1,288 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../factory/stream_component_factory.dart'; -import '../../theme/components/stream_context_menu_item_theme.dart'; -import '../../theme/primitives/stream_colors.dart'; -import '../../theme/primitives/stream_radius.dart'; -import '../../theme/primitives/stream_spacing.dart'; -import '../../theme/semantics/stream_color_scheme.dart'; -import '../../theme/semantics/stream_text_theme.dart'; -import '../../theme/stream_theme_extensions.dart'; - -/// A single item row in a [StreamContextMenu]. -/// -/// [StreamContextMenuItem] displays a tappable row with an optional [leading] -/// widget, a [label] widget, and an optional [trailing] widget. It supports -/// both normal and destructive styles. -/// -/// The visual appearance adapts to interaction states (hover, pressed, -/// disabled) and can be fully customized via [StreamContextMenuItemTheme]. -/// -/// A typical use case is to pass a [Text] as the [label]. If the text may be -/// long, set [Text.overflow] to [TextOverflow.ellipsis] and [Text.maxLines] -/// to 1, as without it the text will wrap to the next line. -/// -/// {@tool snippet} -/// -/// Display a normal context menu item: -/// -/// ```dart -/// StreamContextMenuItem( -/// label: Text('Reply'), -/// leading: Icon(Icons.reply), -/// onPressed: () => handleReply(), -/// ) -/// ``` -/// {@end-tool} -/// -/// {@tool snippet} -/// -/// Display a destructive context menu item: -/// -/// ```dart -/// StreamContextMenuItem.destructive( -/// label: Text('Block User'), -/// leading: Icon(Icons.block), -/// onPressed: () => handleBlock(), -/// ) -/// ``` -/// {@end-tool} -/// -/// See also: -/// -/// * [StreamContextMenu], which contains these items. -/// * [StreamContextMenuSeparator], for visual dividers between items. -/// * [StreamContextMenuItemTheme], for customizing item appearance. -class StreamContextMenuItem extends StatelessWidget { - /// Creates a context menu item. - StreamContextMenuItem({ - super.key, - required Widget label, - VoidCallback? onPressed, - Widget? leading, - Widget? trailing, - }) : props = .new( - label: label, - onPressed: onPressed, - leading: leading, - trailing: trailing, - ); - - /// Creates a destructive context menu item. - /// - /// Uses error/danger colors for text and icons, typically for - /// actions like "Delete", "Block", or "Remove". - /// - /// {@tool snippet} - /// - /// ```dart - /// StreamContextMenuItem.destructive( - /// label: Text('Block User'), - /// leading: Icon(Icons.block), - /// onPressed: () => handleBlock(), - /// ) - /// ``` - /// {@end-tool} - StreamContextMenuItem.destructive({ - super.key, - required Widget label, - VoidCallback? onPressed, - Widget? leading, - Widget? trailing, - }) : props = .new( - label: label, - onPressed: onPressed, - leading: leading, - trailing: trailing, - isDestructive: true, - ); - - /// The props controlling the appearance and behavior of this item. - final StreamContextMenuItemProps props; - - @override - Widget build(BuildContext context) { - final builder = StreamComponentFactory.maybeOf(context)?.contextMenuItem; - if (builder != null) return builder(context, props); - return DefaultStreamContextMenuItem(props: props); - } -} - -/// Properties for configuring a [StreamContextMenuItem]. -/// -/// This class holds all the configuration options for a context menu item, -/// allowing them to be passed through the [StreamComponentFactory]. -/// -/// See also: -/// -/// * [StreamContextMenuItem], which uses these properties. -/// * [DefaultStreamContextMenuItem], the default implementation. -class StreamContextMenuItemProps { - /// Creates properties for a context menu item. - const StreamContextMenuItemProps({ - required this.label, - this.onPressed, - this.leading, - this.trailing, - this.isDestructive = false, - }); - - /// The label widget displayed on the item. - /// - /// Typically a [Text] widget. The label fills the available horizontal - /// space, so text wrapping and overflow behavior are controlled by the - /// consumer. If the text may be long, use [TextOverflow.ellipsis] on the - /// [Text.overflow] property to truncate rather than wrap: - /// - /// ```dart - /// StreamContextMenuItem( - /// label: Text('Very long label text', overflow: TextOverflow.ellipsis), - /// onPressed: () {}, - /// ) - /// ``` - final Widget label; - - /// Called when the item is activated. - /// - /// If null, the item is visually styled as disabled and is non-interactive. - final VoidCallback? onPressed; - - /// An optional widget displayed before the label. - /// - /// Typically an [Icon] widget. The icon color and size are controlled by - /// [StreamContextMenuItemStyle.foregroundColor] and - /// [StreamContextMenuItemStyle.iconSize]. - final Widget? leading; - - /// An optional widget displayed after the label. - /// - /// Typically a chevron icon for sub-menu navigation, or a keyboard shortcut - /// indicator. - final Widget? trailing; - - /// Whether this item uses destructive (error/danger) styling. - /// - /// When true, the item uses [StreamColorScheme.accentError] for text and - /// icon colors. Use [StreamContextMenuItem.destructive] for convenience. - final bool isDestructive; -} - -/// Default implementation of [StreamContextMenuItem]. -/// -/// Lays out the optional [StreamContextMenuItemProps.leading], the -/// [StreamContextMenuItemProps.label], and optional -/// [StreamContextMenuItemProps.trailing] in a horizontal row. -/// -/// All visual properties are resolved from [StreamContextMenuItemTheme] with -/// fallback to sensible defaults, providing automatic state-based feedback -/// (hover, pressed, disabled). -class DefaultStreamContextMenuItem extends StatelessWidget { - /// Creates a default context menu item. - const DefaultStreamContextMenuItem({super.key, required this.props}); - - /// The props controlling the appearance and behavior of this item. - final StreamContextMenuItemProps props; - - @override - Widget build(BuildContext context) { - final spacing = context.streamSpacing; - final themeStyle = context.streamContextMenuItemTheme.style; - final defaults = _ContextMenuItemThemeDefaults(context, isDestructive: props.isDestructive); - - final effectiveBackgroundColor = themeStyle?.backgroundColor ?? defaults.backgroundColor; - final effectiveForegroundColor = themeStyle?.foregroundColor ?? defaults.foregroundColor; - final effectiveOverlayColor = themeStyle?.overlayColor ?? defaults.overlayColor; - final effectiveIconColor = themeStyle?.iconColor ?? defaults.iconColor; - final effectiveTextStyle = themeStyle?.textStyle ?? defaults.textStyle; - final effectiveIconSize = themeStyle?.iconSize ?? defaults.iconSize; - final effectiveMinimumSize = themeStyle?.minimumSize ?? defaults.minimumSize; - final effectiveMaximumSize = themeStyle?.maximumSize ?? defaults.maximumSize; - final effectivePadding = themeStyle?.padding ?? defaults.padding; - final effectiveShape = themeStyle?.shape ?? defaults.shape; - - return TextButton( - onPressed: props.onPressed, - style: ButtonStyle( - tapTargetSize: .shrinkWrap, - visualDensity: .standard, - backgroundColor: effectiveBackgroundColor, - foregroundColor: effectiveForegroundColor, - overlayColor: effectiveOverlayColor, - iconColor: effectiveIconColor, - iconSize: effectiveIconSize, - textStyle: effectiveTextStyle, - minimumSize: effectiveMinimumSize, - maximumSize: effectiveMaximumSize, - padding: effectivePadding, - shape: effectiveShape, - ), - child: Row( - spacing: spacing.xs, - mainAxisSize: MainAxisSize.min, - children: [ - ?props.leading, - Expanded(child: props.label), - ?props.trailing, - ], - ), - ); - } -} - -// Provides default values for [StreamContextMenuItemStyle] based on -// the current [StreamColorScheme]. -class _ContextMenuItemThemeDefaults extends StreamContextMenuItemStyle { - _ContextMenuItemThemeDefaults(this.context, {required this.isDestructive}); - - final BuildContext context; - final bool isDestructive; - - late final StreamColorScheme _colorScheme = context.streamColorScheme; - late final StreamTextTheme _textTheme = context.streamTextTheme; - late final StreamSpacing _spacing = context.streamSpacing; - late final StreamRadius _radius = context.streamRadius; - - @override - WidgetStateProperty get backgroundColor => const WidgetStatePropertyAll(StreamColors.transparent); - - @override - WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; - return isDestructive ? _colorScheme.accentError : _colorScheme.textPrimary; - }); - - @override - WidgetStateProperty get overlayColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; - if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; - return StreamColors.transparent; - }); - - @override - WidgetStateProperty get textStyle => WidgetStatePropertyAll(_textTheme.bodyEmphasis); - - @override - WidgetStateProperty get iconColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; - return isDestructive ? _colorScheme.accentError : _colorScheme.textSecondary; - }); - - @override - WidgetStateProperty get iconSize => const WidgetStatePropertyAll(20); - - @override - WidgetStateProperty get minimumSize => const WidgetStatePropertyAll(Size(242, 40)); - - @override - WidgetStateProperty get maximumSize => const WidgetStatePropertyAll(Size.infinite); - - @override - WidgetStateProperty get padding => WidgetStatePropertyAll( - .symmetric(horizontal: _spacing.sm, vertical: _spacing.xs + _spacing.xxxs), - ); - - @override - WidgetStateProperty get shape => WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: .all(_radius.md)), - ); -} diff --git a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart index 0e91239..0bde159 100644 --- a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart @@ -156,7 +156,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { this.badgeCount, this.button, this.checkbox, - this.contextMenuItem, + this.contextMenuAction, this.emoji, this.emojiButton, this.fileTypeIcon, @@ -194,10 +194,10 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamCheckbox] uses [DefaultStreamCheckbox]. final StreamComponentBuilder? checkbox; - /// Custom builder for context menu item widgets. + /// Custom builder for context menu action widgets. /// - /// When null, [StreamContextMenuItem] uses [DefaultStreamContextMenuItem]. - final StreamComponentBuilder? contextMenuItem; + /// When null, [StreamContextMenuAction] uses [DefaultStreamContextMenuAction]. + final StreamComponentBuilder? contextMenuAction; /// Custom builder for emoji widgets. /// diff --git a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart index f364335..d373d03 100644 --- a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart @@ -36,7 +36,7 @@ mixin _$StreamComponentBuilders { badgeCount: t < 0.5 ? a.badgeCount : b.badgeCount, button: t < 0.5 ? a.button : b.button, checkbox: t < 0.5 ? a.checkbox : b.checkbox, - contextMenuItem: t < 0.5 ? a.contextMenuItem : b.contextMenuItem, + contextMenuAction: t < 0.5 ? a.contextMenuAction : b.contextMenuAction, emoji: t < 0.5 ? a.emoji : b.emoji, emojiButton: t < 0.5 ? a.emojiButton : b.emojiButton, fileTypeIcon: t < 0.5 ? a.fileTypeIcon : b.fileTypeIcon, @@ -52,7 +52,8 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamBadgeCountProps)? badgeCount, Widget Function(BuildContext, StreamButtonProps)? button, Widget Function(BuildContext, StreamCheckboxProps)? checkbox, - Widget Function(BuildContext, StreamContextMenuItemProps)? contextMenuItem, + Widget Function(BuildContext, StreamContextMenuActionProps)? + contextMenuAction, Widget Function(BuildContext, StreamEmojiProps)? emoji, Widget Function(BuildContext, StreamEmojiButtonProps)? emojiButton, Widget Function(BuildContext, StreamFileTypeIconProps)? fileTypeIcon, @@ -68,7 +69,7 @@ mixin _$StreamComponentBuilders { badgeCount: badgeCount ?? _this.badgeCount, button: button ?? _this.button, checkbox: checkbox ?? _this.checkbox, - contextMenuItem: contextMenuItem ?? _this.contextMenuItem, + contextMenuAction: contextMenuAction ?? _this.contextMenuAction, emoji: emoji ?? _this.emoji, emojiButton: emojiButton ?? _this.emojiButton, fileTypeIcon: fileTypeIcon ?? _this.fileTypeIcon, @@ -95,7 +96,7 @@ mixin _$StreamComponentBuilders { badgeCount: other.badgeCount, button: other.button, checkbox: other.checkbox, - contextMenuItem: other.contextMenuItem, + contextMenuAction: other.contextMenuAction, emoji: other.emoji, emojiButton: other.emojiButton, fileTypeIcon: other.fileTypeIcon, @@ -123,7 +124,7 @@ mixin _$StreamComponentBuilders { _other.badgeCount == _this.badgeCount && _other.button == _this.button && _other.checkbox == _this.checkbox && - _other.contextMenuItem == _this.contextMenuItem && + _other.contextMenuAction == _this.contextMenuAction && _other.emoji == _this.emoji && _other.emojiButton == _this.emojiButton && _other.fileTypeIcon == _this.fileTypeIcon && @@ -143,7 +144,7 @@ mixin _$StreamComponentBuilders { _this.badgeCount, _this.button, _this.checkbox, - _this.contextMenuItem, + _this.contextMenuAction, _this.emoji, _this.emojiButton, _this.fileTypeIcon, diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index 0fcd01a..78452e7 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -4,7 +4,7 @@ export 'theme/components/stream_avatar_theme.dart'; export 'theme/components/stream_badge_count_theme.dart'; export 'theme/components/stream_button_theme.dart'; export 'theme/components/stream_checkbox_theme.dart'; -export 'theme/components/stream_context_menu_item_theme.dart'; +export 'theme/components/stream_context_menu_action_theme.dart'; export 'theme/components/stream_context_menu_theme.dart'; export 'theme/components/stream_emoji_button_theme.dart'; export 'theme/components/stream_input_theme.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_action_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_action_theme.dart new file mode 100644 index 0000000..cfb4828 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_action_theme.dart @@ -0,0 +1,205 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_context_menu_action_theme.g.theme.dart'; + +/// Applies a context menu action theme to descendant context menu action +/// widgets. +/// +/// Wrap a subtree with [StreamContextMenuActionTheme] to override context menu +/// action styling. Access the merged theme using +/// [BuildContext.streamContextMenuActionTheme]. +/// +/// {@tool snippet} +/// +/// Override context menu action styling for a specific section: +/// +/// ```dart +/// StreamContextMenuActionTheme( +/// data: StreamContextMenuActionThemeData( +/// style: StreamContextMenuActionStyle( +/// textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 16)), +/// iconSize: WidgetStatePropertyAll(18), +/// ), +/// ), +/// child: StreamContextMenu( +/// children: [ +/// StreamContextMenuAction(label: Text('Reply')), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamContextMenuActionThemeData], which describes the action theme. +/// * [StreamContextMenuActionStyle], for action-level styling. +/// * [StreamContextMenuAction], which uses this theme. +class StreamContextMenuActionTheme extends InheritedTheme { + /// Creates a context menu action theme that controls descendant actions. + const StreamContextMenuActionTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The context menu action theme data for descendant widgets. + final StreamContextMenuActionThemeData data; + + /// Returns the [StreamContextMenuActionThemeData] from the current theme + /// context. + /// + /// This merges the local theme (if any) with the global theme from + /// [StreamTheme]. + static StreamContextMenuActionThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).contextMenuActionTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamContextMenuActionTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamContextMenuActionTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing context menu actions. +/// +/// {@tool snippet} +/// +/// Customize context menu action appearance globally: +/// +/// ```dart +/// StreamTheme( +/// contextMenuActionTheme: StreamContextMenuActionThemeData( +/// style: StreamContextMenuActionStyle( +/// textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 14)), +/// iconSize: WidgetStatePropertyAll(20), +/// padding: WidgetStatePropertyAll(EdgeInsets.all(8)), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamContextMenuActionTheme], for overriding theme in a widget subtree. +/// * [StreamContextMenuActionStyle], for action-level styling. +@themeGen +@immutable +class StreamContextMenuActionThemeData with _$StreamContextMenuActionThemeData { + /// Creates a context menu action theme with optional style overrides. + const StreamContextMenuActionThemeData({this.style}); + + /// The visual styling for context menu actions. + /// + /// Contains text style, icon size, padding, border radius, and + /// state-based color properties. + final StreamContextMenuActionStyle? style; + + /// Linearly interpolate between two [StreamContextMenuActionThemeData] + /// values. + static StreamContextMenuActionThemeData? lerp( + StreamContextMenuActionThemeData? a, + StreamContextMenuActionThemeData? b, + double t, + ) => _$StreamContextMenuActionThemeData.lerp(a, b, t); +} + +/// Visual styling properties for context menu actions. +/// +/// Defines the appearance of menu actions including layout, text style, and +/// state-based colors. All properties are [WidgetStateProperty] to support +/// interactive feedback (default, hover, pressed, disabled), consistent +/// with Flutter's [ButtonStyle] pattern. +/// +/// See also: +/// +/// * [StreamContextMenuActionThemeData], which contains this style. +/// * [StreamContextMenuAction], which uses this styling. +@themeGen +@immutable +class StreamContextMenuActionStyle with _$StreamContextMenuActionStyle { + /// Creates context menu action style properties. + const StreamContextMenuActionStyle({ + this.backgroundColor, + this.foregroundColor, + this.overlayColor, + this.iconColor, + this.textStyle, + this.iconSize, + this.minimumSize, + this.maximumSize, + this.padding, + this.shape, + }); + + /// The background color of the action. + /// + /// If null, defaults to [StreamColors.transparent]. + final WidgetStateProperty? backgroundColor; + + /// The foreground color for the action's text and icons. + /// + /// This is the default color for both text and icon descendants. To + /// override the icon color independently, use [iconColor]. + /// + /// Supports state-based colors for different interaction states + /// (default, hover, pressed, disabled). + final WidgetStateProperty? foregroundColor; + + /// The overlay color for the action's interaction feedback. + /// + /// Supports state-based colors for hover and press states. + final WidgetStateProperty? overlayColor; + + /// The icon color inside the action. + /// + /// If null, the icon color falls back to [foregroundColor]. + final WidgetStateProperty? iconColor; + + /// The text style for the action label. + /// + /// If null, defaults to [StreamTextTheme.bodyEmphasis]. + /// The text color is controlled by [foregroundColor]. + final WidgetStateProperty? textStyle; + + /// The size of icons in the action. + /// + /// If null, defaults to 20. + final WidgetStateProperty? iconSize; + + /// The minimum size of the action. + /// + /// If null, defaults to `Size(242, 40)`. + final WidgetStateProperty? minimumSize; + + /// The maximum size of the action. + /// + /// If null, defaults to [Size.infinite] (no maximum constraint). + final WidgetStateProperty? maximumSize; + + /// The padding inside the action. + /// + /// If null, defaults are derived from [StreamSpacing]. + final WidgetStateProperty? padding; + + /// The shape of the action's underlying surface. + /// + /// If null, defaults to a [RoundedRectangleBorder] with + /// [StreamRadius.md] border radius. + final WidgetStateProperty? shape; + + /// Linearly interpolate between two [StreamContextMenuActionStyle] values. + static StreamContextMenuActionStyle? lerp( + StreamContextMenuActionStyle? a, + StreamContextMenuActionStyle? b, + double t, + ) => _$StreamContextMenuActionStyle.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_item_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_action_theme.g.theme.dart similarity index 77% rename from packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_item_theme.g.theme.dart rename to packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_action_theme.g.theme.dart index 6d74425..fb89d20 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_item_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_action_theme.g.theme.dart @@ -3,18 +3,18 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, unused_element -part of 'stream_context_menu_item_theme.dart'; +part of 'stream_context_menu_action_theme.dart'; // ************************************************************************** // ThemeGenGenerator // ************************************************************************** -mixin _$StreamContextMenuItemThemeData { +mixin _$StreamContextMenuActionThemeData { bool get canMerge => true; - static StreamContextMenuItemThemeData? lerp( - StreamContextMenuItemThemeData? a, - StreamContextMenuItemThemeData? b, + static StreamContextMenuActionThemeData? lerp( + StreamContextMenuActionThemeData? a, + StreamContextMenuActionThemeData? b, double t, ) { if (identical(a, b)) { @@ -29,19 +29,23 @@ mixin _$StreamContextMenuItemThemeData { return t == 0.0 ? a : null; } - return StreamContextMenuItemThemeData( - style: StreamContextMenuItemStyle.lerp(a.style, b.style, t), + return StreamContextMenuActionThemeData( + style: StreamContextMenuActionStyle.lerp(a.style, b.style, t), ); } - StreamContextMenuItemThemeData copyWith({StreamContextMenuItemStyle? style}) { - final _this = (this as StreamContextMenuItemThemeData); + StreamContextMenuActionThemeData copyWith({ + StreamContextMenuActionStyle? style, + }) { + final _this = (this as StreamContextMenuActionThemeData); - return StreamContextMenuItemThemeData(style: style ?? _this.style); + return StreamContextMenuActionThemeData(style: style ?? _this.style); } - StreamContextMenuItemThemeData merge(StreamContextMenuItemThemeData? other) { - final _this = (this as StreamContextMenuItemThemeData); + StreamContextMenuActionThemeData merge( + StreamContextMenuActionThemeData? other, + ) { + final _this = (this as StreamContextMenuActionThemeData); if (other == null || identical(_this, other)) { return _this; @@ -64,26 +68,26 @@ mixin _$StreamContextMenuItemThemeData { return false; } - final _this = (this as StreamContextMenuItemThemeData); - final _other = (other as StreamContextMenuItemThemeData); + final _this = (this as StreamContextMenuActionThemeData); + final _other = (other as StreamContextMenuActionThemeData); return _other.style == _this.style; } @override int get hashCode { - final _this = (this as StreamContextMenuItemThemeData); + final _this = (this as StreamContextMenuActionThemeData); return Object.hash(runtimeType, _this.style); } } -mixin _$StreamContextMenuItemStyle { +mixin _$StreamContextMenuActionStyle { bool get canMerge => true; - static StreamContextMenuItemStyle? lerp( - StreamContextMenuItemStyle? a, - StreamContextMenuItemStyle? b, + static StreamContextMenuActionStyle? lerp( + StreamContextMenuActionStyle? a, + StreamContextMenuActionStyle? b, double t, ) { if (identical(a, b)) { @@ -98,7 +102,7 @@ mixin _$StreamContextMenuItemStyle { return t == 0.0 ? a : null; } - return StreamContextMenuItemStyle( + return StreamContextMenuActionStyle( backgroundColor: WidgetStateProperty.lerp( a.backgroundColor, b.backgroundColor, @@ -162,7 +166,7 @@ mixin _$StreamContextMenuItemStyle { ); } - StreamContextMenuItemStyle copyWith({ + StreamContextMenuActionStyle copyWith({ WidgetStateProperty? backgroundColor, WidgetStateProperty? foregroundColor, WidgetStateProperty? overlayColor, @@ -174,9 +178,9 @@ mixin _$StreamContextMenuItemStyle { WidgetStateProperty? padding, WidgetStateProperty? shape, }) { - final _this = (this as StreamContextMenuItemStyle); + final _this = (this as StreamContextMenuActionStyle); - return StreamContextMenuItemStyle( + return StreamContextMenuActionStyle( backgroundColor: backgroundColor ?? _this.backgroundColor, foregroundColor: foregroundColor ?? _this.foregroundColor, overlayColor: overlayColor ?? _this.overlayColor, @@ -190,8 +194,8 @@ mixin _$StreamContextMenuItemStyle { ); } - StreamContextMenuItemStyle merge(StreamContextMenuItemStyle? other) { - final _this = (this as StreamContextMenuItemStyle); + StreamContextMenuActionStyle merge(StreamContextMenuActionStyle? other) { + final _this = (this as StreamContextMenuActionStyle); if (other == null || identical(_this, other)) { return _this; @@ -225,8 +229,8 @@ mixin _$StreamContextMenuItemStyle { return false; } - final _this = (this as StreamContextMenuItemStyle); - final _other = (other as StreamContextMenuItemStyle); + final _this = (this as StreamContextMenuActionStyle); + final _other = (other as StreamContextMenuActionStyle); return _other.backgroundColor == _this.backgroundColor && _other.foregroundColor == _this.foregroundColor && @@ -242,7 +246,7 @@ mixin _$StreamContextMenuItemStyle { @override int get hashCode { - final _this = (this as StreamContextMenuItemStyle); + final _this = (this as StreamContextMenuActionStyle); return Object.hash( runtimeType, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_item_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_item_theme.dart deleted file mode 100644 index 899e4e0..0000000 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_item_theme.dart +++ /dev/null @@ -1,203 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; - -import '../stream_theme.dart'; - -part 'stream_context_menu_item_theme.g.theme.dart'; - -/// Applies a context menu item theme to descendant context menu item widgets. -/// -/// Wrap a subtree with [StreamContextMenuItemTheme] to override context menu -/// item styling. Access the merged theme using -/// [BuildContext.streamContextMenuItemTheme]. -/// -/// {@tool snippet} -/// -/// Override context menu item styling for a specific section: -/// -/// ```dart -/// StreamContextMenuItemTheme( -/// data: StreamContextMenuItemThemeData( -/// style: StreamContextMenuItemStyle( -/// textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 16)), -/// iconSize: WidgetStatePropertyAll(18), -/// ), -/// ), -/// child: StreamContextMenu( -/// children: [ -/// StreamContextMenuItem(label: Text('Reply'), onPressed: () {}), -/// ], -/// ), -/// ) -/// ``` -/// {@end-tool} -/// -/// See also: -/// -/// * [StreamContextMenuItemThemeData], which describes the item theme. -/// * [StreamContextMenuItemStyle], for item-level styling. -/// * [StreamContextMenuItem], which uses this theme. -class StreamContextMenuItemTheme extends InheritedTheme { - /// Creates a context menu item theme that controls descendant items. - const StreamContextMenuItemTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The context menu item theme data for descendant widgets. - final StreamContextMenuItemThemeData data; - - /// Returns the [StreamContextMenuItemThemeData] from the current theme - /// context. - /// - /// This merges the local theme (if any) with the global theme from - /// [StreamTheme]. - static StreamContextMenuItemThemeData of(BuildContext context) { - final localTheme = context.dependOnInheritedWidgetOfExactType(); - return StreamTheme.of(context).contextMenuItemTheme.merge(localTheme?.data); - } - - @override - Widget wrap(BuildContext context, Widget child) { - return StreamContextMenuItemTheme(data: data, child: child); - } - - @override - bool updateShouldNotify(StreamContextMenuItemTheme oldWidget) => data != oldWidget.data; -} - -/// Theme data for customizing context menu items. -/// -/// {@tool snippet} -/// -/// Customize context menu item appearance globally: -/// -/// ```dart -/// StreamTheme( -/// contextMenuItemTheme: StreamContextMenuItemThemeData( -/// style: StreamContextMenuItemStyle( -/// textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 14)), -/// iconSize: WidgetStatePropertyAll(20), -/// padding: WidgetStatePropertyAll(EdgeInsets.all(8)), -/// ), -/// ), -/// ) -/// ``` -/// {@end-tool} -/// -/// See also: -/// -/// * [StreamContextMenuItemTheme], for overriding theme in a widget subtree. -/// * [StreamContextMenuItemStyle], for item-level styling. -@themeGen -@immutable -class StreamContextMenuItemThemeData with _$StreamContextMenuItemThemeData { - /// Creates a context menu item theme with optional style overrides. - const StreamContextMenuItemThemeData({this.style}); - - /// The visual styling for context menu items. - /// - /// Contains text style, icon size, padding, border radius, and - /// state-based color properties. - final StreamContextMenuItemStyle? style; - - /// Linearly interpolate between two [StreamContextMenuItemThemeData] values. - static StreamContextMenuItemThemeData? lerp( - StreamContextMenuItemThemeData? a, - StreamContextMenuItemThemeData? b, - double t, - ) => _$StreamContextMenuItemThemeData.lerp(a, b, t); -} - -/// Visual styling properties for context menu items. -/// -/// Defines the appearance of menu items including layout, text style, and -/// state-based colors. All properties are [WidgetStateProperty] to support -/// interactive feedback (default, hover, pressed, disabled), consistent -/// with Flutter's [ButtonStyle] pattern. -/// -/// See also: -/// -/// * [StreamContextMenuItemThemeData], which contains this style. -/// * [StreamContextMenuItem], which uses this styling. -@themeGen -@immutable -class StreamContextMenuItemStyle with _$StreamContextMenuItemStyle { - /// Creates context menu item style properties. - const StreamContextMenuItemStyle({ - this.backgroundColor, - this.foregroundColor, - this.overlayColor, - this.iconColor, - this.textStyle, - this.iconSize, - this.minimumSize, - this.maximumSize, - this.padding, - this.shape, - }); - - /// The background color of the item. - /// - /// If null, defaults to [StreamColors.transparent]. - final WidgetStateProperty? backgroundColor; - - /// The foreground color for the item's text and icons. - /// - /// This is the default color for both text and icon descendants. To - /// override the icon color independently, use [iconColor]. - /// - /// Supports state-based colors for different interaction states - /// (default, hover, pressed, disabled). - final WidgetStateProperty? foregroundColor; - - /// The overlay color for the item's interaction feedback. - /// - /// Supports state-based colors for hover and press states. - final WidgetStateProperty? overlayColor; - - /// The icon color inside the item. - /// - /// If null, the icon color falls back to [foregroundColor]. - final WidgetStateProperty? iconColor; - - /// The text style for the item label. - /// - /// If null, defaults to [StreamTextTheme.bodyEmphasis]. - /// The text color is controlled by [foregroundColor]. - final WidgetStateProperty? textStyle; - - /// The size of icons in the item. - /// - /// If null, defaults to 20. - final WidgetStateProperty? iconSize; - - /// The minimum size of the item. - /// - /// If null, defaults to `Size(242, 40)`. - final WidgetStateProperty? minimumSize; - - /// The maximum size of the item. - /// - /// If null, defaults to [Size.infinite] (no maximum constraint). - final WidgetStateProperty? maximumSize; - - /// The padding inside the item. - /// - /// If null, defaults are derived from [StreamSpacing]. - final WidgetStateProperty? padding; - - /// The shape of the item's underlying surface. - /// - /// If null, defaults to a [RoundedRectangleBorder] with - /// [StreamRadius.md] border radius. - final WidgetStateProperty? shape; - - /// Linearly interpolate between two [StreamContextMenuItemStyle] values. - static StreamContextMenuItemStyle? lerp( - StreamContextMenuItemStyle? a, - StreamContextMenuItemStyle? b, - double t, - ) => _$StreamContextMenuItemStyle.lerp(a, b, t); -} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.dart index efd8e6c..79a388f 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.dart @@ -28,7 +28,7 @@ part 'stream_context_menu_theme.g.theme.dart'; /// ), /// child: StreamContextMenu( /// children: [ -/// StreamContextMenuItem(label: Text('Reply'), onPressed: () {}), +/// StreamContextMenuAction(label: Text('Reply')), /// ], /// ), /// ) @@ -40,7 +40,7 @@ part 'stream_context_menu_theme.g.theme.dart'; /// * [StreamContextMenuThemeData], which describes the context menu theme. /// * [StreamContextMenuStyle], for container-level styling. /// * [StreamContextMenu], which uses this theme. -/// * [StreamContextMenuItemTheme], for customizing individual item appearance. +/// * [StreamContextMenuActionTheme], for customizing individual action appearance. class StreamContextMenuTheme extends InheritedTheme { /// Creates a context menu theme that controls descendant context menus. const StreamContextMenuTheme({ diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index 3f74c29..3870084 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -8,7 +8,7 @@ import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; import 'components/stream_button_theme.dart'; import 'components/stream_checkbox_theme.dart'; -import 'components/stream_context_menu_item_theme.dart'; +import 'components/stream_context_menu_action_theme.dart'; import 'components/stream_context_menu_theme.dart'; import 'components/stream_emoji_button_theme.dart'; import 'components/stream_input_theme.dart'; @@ -94,7 +94,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamButtonThemeData? buttonTheme, StreamCheckboxThemeData? checkboxTheme, StreamContextMenuThemeData? contextMenuTheme, - StreamContextMenuItemThemeData? contextMenuItemTheme, + StreamContextMenuActionThemeData? contextMenuActionTheme, StreamEmojiButtonThemeData? emojiButtonTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, @@ -121,7 +121,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { buttonTheme ??= const StreamButtonThemeData(); checkboxTheme ??= const StreamCheckboxThemeData(); contextMenuTheme ??= const StreamContextMenuThemeData(); - contextMenuItemTheme ??= const StreamContextMenuItemThemeData(); + contextMenuActionTheme ??= const StreamContextMenuActionThemeData(); emojiButtonTheme ??= const StreamEmojiButtonThemeData(); messageTheme ??= const StreamMessageThemeData(); inputTheme ??= const StreamInputThemeData(); @@ -142,7 +142,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { buttonTheme: buttonTheme, checkboxTheme: checkboxTheme, contextMenuTheme: contextMenuTheme, - contextMenuItemTheme: contextMenuItemTheme, + contextMenuActionTheme: contextMenuActionTheme, emojiButtonTheme: emojiButtonTheme, messageTheme: messageTheme, inputTheme: inputTheme, @@ -177,7 +177,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.buttonTheme, required this.checkboxTheme, required this.contextMenuTheme, - required this.contextMenuItemTheme, + required this.contextMenuActionTheme, required this.emojiButtonTheme, required this.messageTheme, required this.inputTheme, @@ -258,8 +258,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The context menu theme for this theme. final StreamContextMenuThemeData contextMenuTheme; - /// The context menu item theme for this theme. - final StreamContextMenuItemThemeData contextMenuItemTheme; + /// The context menu action theme for this theme. + final StreamContextMenuActionThemeData contextMenuActionTheme; /// The emoji button theme for this theme. final StreamEmojiButtonThemeData emojiButtonTheme; @@ -310,7 +310,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { buttonTheme: buttonTheme, checkboxTheme: checkboxTheme, contextMenuTheme: contextMenuTheme, - contextMenuItemTheme: contextMenuItemTheme, + contextMenuActionTheme: contextMenuActionTheme, emojiButtonTheme: emojiButtonTheme, messageTheme: messageTheme, inputTheme: inputTheme, diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart index a9d0448..5af58e2 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart @@ -25,7 +25,7 @@ mixin _$StreamTheme on ThemeExtension { StreamButtonThemeData? buttonTheme, StreamCheckboxThemeData? checkboxTheme, StreamContextMenuThemeData? contextMenuTheme, - StreamContextMenuItemThemeData? contextMenuItemTheme, + StreamContextMenuActionThemeData? contextMenuActionTheme, StreamEmojiButtonThemeData? emojiButtonTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, @@ -48,7 +48,8 @@ mixin _$StreamTheme on ThemeExtension { buttonTheme: buttonTheme ?? _this.buttonTheme, checkboxTheme: checkboxTheme ?? _this.checkboxTheme, contextMenuTheme: contextMenuTheme ?? _this.contextMenuTheme, - contextMenuItemTheme: contextMenuItemTheme ?? _this.contextMenuItemTheme, + contextMenuActionTheme: + contextMenuActionTheme ?? _this.contextMenuActionTheme, emojiButtonTheme: emojiButtonTheme ?? _this.emojiButtonTheme, messageTheme: messageTheme ?? _this.messageTheme, inputTheme: inputTheme ?? _this.inputTheme, @@ -103,9 +104,9 @@ mixin _$StreamTheme on ThemeExtension { other.contextMenuTheme, t, )!, - contextMenuItemTheme: StreamContextMenuItemThemeData.lerp( - _this.contextMenuItemTheme, - other.contextMenuItemTheme, + contextMenuActionTheme: StreamContextMenuActionThemeData.lerp( + _this.contextMenuActionTheme, + other.contextMenuActionTheme, t, )!, emojiButtonTheme: StreamEmojiButtonThemeData.lerp( @@ -154,7 +155,7 @@ mixin _$StreamTheme on ThemeExtension { _other.buttonTheme == _this.buttonTheme && _other.checkboxTheme == _this.checkboxTheme && _other.contextMenuTheme == _this.contextMenuTheme && - _other.contextMenuItemTheme == _this.contextMenuItemTheme && + _other.contextMenuActionTheme == _this.contextMenuActionTheme && _other.emojiButtonTheme == _this.emojiButtonTheme && _other.messageTheme == _this.messageTheme && _other.inputTheme == _this.inputTheme && @@ -181,7 +182,7 @@ mixin _$StreamTheme on ThemeExtension { _this.buttonTheme, _this.checkboxTheme, _this.contextMenuTheme, - _this.contextMenuItemTheme, + _this.contextMenuActionTheme, _this.emojiButtonTheme, _this.messageTheme, _this.inputTheme, diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart index 2a5f147..d6c414e 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart @@ -4,7 +4,7 @@ import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; import 'components/stream_button_theme.dart'; import 'components/stream_checkbox_theme.dart'; -import 'components/stream_context_menu_item_theme.dart'; +import 'components/stream_context_menu_action_theme.dart'; import 'components/stream_context_menu_theme.dart'; import 'components/stream_emoji_button_theme.dart'; import 'components/stream_input_theme.dart'; @@ -80,8 +80,8 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamContextMenuThemeData] from the nearest ancestor. StreamContextMenuThemeData get streamContextMenuTheme => StreamContextMenuTheme.of(this); - /// Returns the [StreamContextMenuItemThemeData] from the nearest ancestor. - StreamContextMenuItemThemeData get streamContextMenuItemTheme => StreamContextMenuItemTheme.of(this); + /// Returns the [StreamContextMenuActionThemeData] from the nearest ancestor. + StreamContextMenuActionThemeData get streamContextMenuActionTheme => StreamContextMenuActionTheme.of(this); /// Returns the [StreamEmojiButtonThemeData] from the nearest ancestor. StreamEmojiButtonThemeData get streamEmojiButtonTheme => StreamEmojiButtonTheme.of(this);