From b59242d8a9c467747542a4d24a4d8e93a06bace7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 17 Feb 2026 16:39:21 +0530 Subject: [PATCH 1/5] refactor(ui): centralize button defaults and add iconSize property --- .../src/components/buttons/stream_button.dart | 335 ++++++------------ .../theme/components/stream_button_theme.dart | 6 + 2 files changed, 124 insertions(+), 217 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart index c037ce3..ec12a69 100644 --- a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart @@ -299,6 +299,7 @@ class _DefaultStreamButtonState extends State { final effectiveBorderColor = themeStyle?.borderColor ?? defaults.borderColor; final effectiveOverlayColor = themeStyle?.overlayColor ?? defaults.overlayColor; final effectiveElevation = themeStyle?.elevation ?? defaults.elevation; + final effectiveIconSize = themeStyle?.iconSize ?? defaults.iconSize; final buttonSize = props.size.value; final isIconButton = props.label == null; @@ -307,9 +308,9 @@ class _DefaultStreamButtonState extends State { onPressed: props.onTap, statesController: _statesController, style: ButtonStyle( - iconSize: .all(20), tapTargetSize: .padded, visualDensity: .standard, + iconSize: effectiveIconSize, elevation: effectiveElevation, backgroundColor: effectiveBackgroundColor, foregroundColor: effectiveForegroundColor, @@ -343,404 +344,304 @@ class _DefaultStreamButtonState extends State { } } +// -- Shared defaults -------------------------------------------------------- + +mixin _SharedButtonDefaults on StreamButtonThemeStyle { + bool get isFloating; + StreamColorScheme get colorScheme; + + @override + WidgetStateProperty get iconSize => const WidgetStatePropertyAll(20); + + @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 elevation => WidgetStateProperty.resolveWith((states) { + if (!isFloating) return 0; + if (states.contains(WidgetState.disabled)) return 0.0; + if (states.contains(WidgetState.pressed)) return 6.0; + if (states.contains(WidgetState.hovered)) return 8.0; + return 6.0; + }); +} + // -- Primary defaults ------------------------------------------------------- // Default style for primary solid buttons. -class _PrimarySolidDefaults extends StreamButtonThemeStyle { +class _PrimarySolidDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { _PrimarySolidDefaults( this.context, { required this.isFloating, - }) : _colorScheme = context.streamColorScheme; + }) : colorScheme = context.streamColorScheme; final BuildContext context; - final StreamColorScheme _colorScheme; + @override + final StreamColorScheme colorScheme; + @override final bool isFloating; @override WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.backgroundDisabled; - final base = _colorScheme.accentPrimary; - if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + if (states.contains(WidgetState.disabled)) return colorScheme.backgroundDisabled; + final base = colorScheme.accentPrimary; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.stateSelected, base); return base; }); @override WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; - return _colorScheme.textOnAccent; - }); - - @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 elevation => WidgetStateProperty.resolveWith((states) { - if (!isFloating) return 0; - if (states.contains(WidgetState.disabled)) return 0.0; - if (states.contains(WidgetState.pressed)) return 6.0; - if (states.contains(WidgetState.hovered)) return 8.0; - return 6.0; + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.textOnAccent; }); } // Default style for primary outline buttons. -class _PrimaryOutlineDefaults extends StreamButtonThemeStyle { +class _PrimaryOutlineDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { _PrimaryOutlineDefaults( this.context, { required this.isFloating, - }) : _colorScheme = context.streamColorScheme; + }) : colorScheme = context.streamColorScheme; final BuildContext context; - final StreamColorScheme _colorScheme; + @override + final StreamColorScheme colorScheme; + @override final bool isFloating; @override WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) return StreamColors.transparent; - final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.stateSelected, base); return base; }); @override WidgetStateProperty? get borderColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.borderDisabled; - return _colorScheme.brand.shade200; + if (states.contains(WidgetState.disabled)) return colorScheme.borderDisabled; + return colorScheme.brand.shade200; }); @override WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; - return _colorScheme.accentPrimary; - }); - - @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 elevation => WidgetStateProperty.resolveWith((states) { - if (!isFloating) return 0; - if (states.contains(WidgetState.disabled)) return 0.0; - if (states.contains(WidgetState.pressed)) return 6.0; - if (states.contains(WidgetState.hovered)) return 8.0; - return 6.0; + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.accentPrimary; }); } // Default style for primary ghost buttons. -class _PrimaryGhostDefaults extends StreamButtonThemeStyle { +class _PrimaryGhostDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { _PrimaryGhostDefaults( this.context, { required this.isFloating, - }) : _colorScheme = context.streamColorScheme; + }) : colorScheme = context.streamColorScheme; final BuildContext context; - final StreamColorScheme _colorScheme; + @override + final StreamColorScheme colorScheme; + @override final bool isFloating; @override WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) return StreamColors.transparent; - final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.stateSelected, base); return base; }); @override WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; - return _colorScheme.accentPrimary; - }); - - @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 elevation => WidgetStateProperty.resolveWith((states) { - if (!isFloating) return 0; - if (states.contains(WidgetState.disabled)) return 0.0; - if (states.contains(WidgetState.pressed)) return 6.0; - if (states.contains(WidgetState.hovered)) return 8.0; - return 6.0; + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.accentPrimary; }); } // -- Secondary defaults ----------------------------------------------------- // Default style for secondary solid buttons. -class _SecondarySolidDefaults extends StreamButtonThemeStyle { +class _SecondarySolidDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { _SecondarySolidDefaults( this.context, { required this.isFloating, - }) : _colorScheme = context.streamColorScheme; + }) : colorScheme = context.streamColorScheme; final BuildContext context; - final StreamColorScheme _colorScheme; + @override + final StreamColorScheme colorScheme; + @override final bool isFloating; @override WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.backgroundDisabled; - final base = _colorScheme.backgroundSurface; - if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + if (states.contains(WidgetState.disabled)) return colorScheme.backgroundDisabled; + final base = colorScheme.backgroundSurface; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.stateSelected, base); return base; }); @override WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; - return _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 elevation => WidgetStateProperty.resolveWith((states) { - if (!isFloating) return 0; - if (states.contains(WidgetState.disabled)) return 0.0; - if (states.contains(WidgetState.pressed)) return 6.0; - if (states.contains(WidgetState.hovered)) return 8.0; - return 6.0; + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.textPrimary; }); } // Default style for secondary outline buttons. -class _SecondaryOutlineDefaults extends StreamButtonThemeStyle { +class _SecondaryOutlineDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { _SecondaryOutlineDefaults( this.context, { required this.isFloating, - }) : _colorScheme = context.streamColorScheme; + }) : colorScheme = context.streamColorScheme; final BuildContext context; - final StreamColorScheme _colorScheme; + @override + final StreamColorScheme colorScheme; + @override final bool isFloating; @override WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) return StreamColors.transparent; - final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.stateSelected, base); return base; }); @override WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; - return _colorScheme.textPrimary; + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.textPrimary; }); @override WidgetStateProperty? get borderColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.borderDisabled; - return _colorScheme.borderDefault; - }); - - @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 elevation => WidgetStateProperty.resolveWith((states) { - if (!isFloating) return 0; - if (states.contains(WidgetState.disabled)) return 0.0; - if (states.contains(WidgetState.pressed)) return 6.0; - if (states.contains(WidgetState.hovered)) return 8.0; - return 6.0; + if (states.contains(WidgetState.disabled)) return colorScheme.borderDisabled; + return colorScheme.borderDefault; }); } // Default style for secondary ghost buttons. -class _SecondaryGhostDefaults extends StreamButtonThemeStyle { +class _SecondaryGhostDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { _SecondaryGhostDefaults( this.context, { required this.isFloating, - }) : _colorScheme = context.streamColorScheme; + }) : colorScheme = context.streamColorScheme; final BuildContext context; - final StreamColorScheme _colorScheme; + @override + final StreamColorScheme colorScheme; + @override final bool isFloating; @override WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) return StreamColors.transparent; - final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.stateSelected, base); return base; }); @override WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; - return _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 elevation => WidgetStateProperty.resolveWith((states) { - if (!isFloating) return 0; - if (states.contains(WidgetState.disabled)) return 0.0; - if (states.contains(WidgetState.pressed)) return 6.0; - if (states.contains(WidgetState.hovered)) return 8.0; - return 6.0; + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.textPrimary; }); } // -- Destructive defaults --------------------------------------------------- // Default style for destructive solid buttons. -class _DestructiveSolidDefaults extends StreamButtonThemeStyle { +class _DestructiveSolidDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { _DestructiveSolidDefaults( this.context, { required this.isFloating, - }) : _colorScheme = context.streamColorScheme; + }) : colorScheme = context.streamColorScheme; final BuildContext context; - final StreamColorScheme _colorScheme; + @override + final StreamColorScheme colorScheme; + @override final bool isFloating; @override WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.backgroundDisabled; - final base = _colorScheme.accentError; - if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + if (states.contains(WidgetState.disabled)) return colorScheme.backgroundDisabled; + final base = colorScheme.accentError; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.stateSelected, base); return base; }); @override WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; - return _colorScheme.textOnAccent; - }); - - @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 elevation => WidgetStateProperty.resolveWith((states) { - if (!isFloating) return 0; - if (states.contains(WidgetState.disabled)) return 0.0; - if (states.contains(WidgetState.pressed)) return 6.0; - if (states.contains(WidgetState.hovered)) return 8.0; - return 6.0; + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.textOnAccent; }); } // Default style for destructive outline buttons. -class _DestructiveOutlineDefaults extends StreamButtonThemeStyle { +class _DestructiveOutlineDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { _DestructiveOutlineDefaults( this.context, { required this.isFloating, - }) : _colorScheme = context.streamColorScheme; + }) : colorScheme = context.streamColorScheme; final BuildContext context; - final StreamColorScheme _colorScheme; + @override + final StreamColorScheme colorScheme; + @override final bool isFloating; @override WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) return StreamColors.transparent; - final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.stateSelected, base); return base; }); @override WidgetStateProperty? get borderColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.borderDisabled; - return _colorScheme.accentError; + if (states.contains(WidgetState.disabled)) return colorScheme.borderDisabled; + return colorScheme.accentError; }); @override WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; - return _colorScheme.accentError; - }); - - @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 elevation => WidgetStateProperty.resolveWith((states) { - if (!isFloating) return 0; - if (states.contains(WidgetState.disabled)) return 0.0; - if (states.contains(WidgetState.pressed)) return 6.0; - if (states.contains(WidgetState.hovered)) return 8.0; - return 6.0; + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.accentError; }); } // Default style for destructive ghost buttons. -class _DestructiveGhostDefaults extends StreamButtonThemeStyle { +class _DestructiveGhostDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { _DestructiveGhostDefaults( this.context, { required this.isFloating, - }) : _colorScheme = context.streamColorScheme; + }) : colorScheme = context.streamColorScheme; final BuildContext context; - final StreamColorScheme _colorScheme; + @override + final StreamColorScheme colorScheme; + @override final bool isFloating; @override WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) return StreamColors.transparent; - final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.stateSelected, base); return base; }); @override WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; - return _colorScheme.accentError; - }); - - @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 elevation => WidgetStateProperty.resolveWith((states) { - if (!isFloating) return 0; - if (states.contains(WidgetState.disabled)) return 0.0; - if (states.contains(WidgetState.pressed)) return 6.0; - if (states.contains(WidgetState.hovered)) return 8.0; - return 6.0; + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.accentError; }); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart index 7c0b4d0..e680524 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart @@ -138,6 +138,7 @@ class StreamButtonThemeStyle { this.borderColor, this.overlayColor, this.elevation, + this.iconSize, }); /// The background color for the button. @@ -165,4 +166,9 @@ class StreamButtonThemeStyle { /// /// Controls the shadow depth. Typically non-zero only for floating buttons. final WidgetStateProperty? elevation; + + /// The size of icons inside the button. + /// + /// If null, defaults to 20. + final WidgetStateProperty? iconSize; } From 19a51975290008fe1957c859bc897528cdef6724 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 17 Feb 2026 22:28:32 +0530 Subject: [PATCH 2/5] feat: add target platform override to preview toolbar - Add `PlatformSelector` component to switch between target platforms (Android, iOS, etc.). - Update `PreviewConfiguration` to store and notify platform changes. - Apply platform override in `PreviewWrapper` to ensure `StreamTheme` and `Theme` reflect the selected platform. - Add the selector to the main `Toolbar`. --- .../lib/config/preview_configuration.dart | 26 ++++- .../lib/core/preview_wrapper.dart | 54 ++++++++++- .../widgets/toolbar/platform_selector.dart | 95 +++++++++++++++++++ .../lib/widgets/toolbar/toolbar.dart | 8 ++ 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 apps/design_system_gallery/lib/widgets/toolbar/platform_selector.dart diff --git a/apps/design_system_gallery/lib/config/preview_configuration.dart b/apps/design_system_gallery/lib/config/preview_configuration.dart index 8adf352..19e15a6 100644 --- a/apps/design_system_gallery/lib/config/preview_configuration.dart +++ b/apps/design_system_gallery/lib/config/preview_configuration.dart @@ -3,8 +3,8 @@ import 'package:flutter/widgets.dart'; /// Preview configuration for device frame and text scale. /// -/// Manages the device frame, text scale, text direction, and device frame -/// visibility for the widget preview area. +/// Manages the device frame, text scale, text direction, target platform, +/// and device frame visibility for the widget preview area. class PreviewConfiguration extends ChangeNotifier { PreviewConfiguration(); @@ -16,6 +16,7 @@ class PreviewConfiguration extends ChangeNotifier { var _textScale = 1.0; var _showDeviceFrame = false; var _textDirection = TextDirection.ltr; + TargetPlatform? _targetPlatform; // ========================================================================= // Getters @@ -26,6 +27,9 @@ class PreviewConfiguration extends ChangeNotifier { bool get showDeviceFrame => _showDeviceFrame; TextDirection get textDirection => _textDirection; + /// The target platform override, or `null` to use the system default. + TargetPlatform? get targetPlatform => _targetPlatform; + // ========================================================================= // Static Options // ========================================================================= @@ -43,6 +47,18 @@ class PreviewConfiguration extends ChangeNotifier { static const textDirectionOptions = TextDirection.values; + /// Platform options available for override. + /// + /// `null` represents the system default (no override). + static const platformOptions = [ + null, + TargetPlatform.android, + TargetPlatform.iOS, + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.linux, + ]; + // ========================================================================= // Setters // ========================================================================= @@ -69,4 +85,10 @@ class PreviewConfiguration extends ChangeNotifier { _textDirection = direction; notifyListeners(); } + + void setTargetPlatform(TargetPlatform? platform) { + if (_targetPlatform == platform) return; + _targetPlatform = platform; + notifyListeners(); + } } diff --git a/apps/design_system_gallery/lib/core/preview_wrapper.dart b/apps/design_system_gallery/lib/core/preview_wrapper.dart index 71ab291..956fff4 100644 --- a/apps/design_system_gallery/lib/core/preview_wrapper.dart +++ b/apps/design_system_gallery/lib/core/preview_wrapper.dart @@ -30,7 +30,9 @@ class PreviewWrapper extends StatelessWidget { final radius = context.streamRadius; final spacing = context.streamSpacing; - final content = Builder( + final targetPlatform = previewConfig.targetPlatform; + + Widget content = Builder( builder: (context) => MediaQuery( data: MediaQuery.of(context).copyWith( textScaler: .linear(previewConfig.textScale), @@ -53,6 +55,16 @@ class PreviewWrapper extends StatelessWidget { ), ); + // Apply platform override to both Material theme and Stream theme so + // that platform-aware primitives (e.g. StreamRadius, StreamTypography) + // resolve correctly for the selected platform. + if (targetPlatform != null) { + content = _PlatformOverride( + platform: targetPlatform, + child: content, + ); + } + if (previewConfig.showDeviceFrame) { return Center( child: Padding( @@ -83,3 +95,43 @@ class PreviewWrapper extends StatelessWidget { ); } } + +/// Overrides the target platform for both Material and Stream themes. +/// +/// Rebuilds [StreamTheme] with the given [platform] so that platform-aware +/// primitives like [StreamRadius] and [StreamTypography] use the correct +/// platform-specific values. +class _PlatformOverride extends StatelessWidget { + const _PlatformOverride({ + required this.platform, + required this.child, + }); + + final TargetPlatform platform; + final Widget child; + + @override + Widget build(BuildContext context) { + final currentTheme = Theme.of(context); + final currentStreamTheme = context.streamTheme; + + // Rebuild StreamTheme with the overridden platform so that + // platform-aware values (radius, typography) are recalculated. + final overriddenStreamTheme = StreamTheme( + brightness: currentStreamTheme.brightness, + platform: platform, + colorScheme: currentStreamTheme.colorScheme, + ); + + return Theme( + data: currentTheme.copyWith( + platform: platform, + extensions: { + ...currentTheme.extensions.values, + overriddenStreamTheme, + }, + ), + child: child, + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/platform_selector.dart b/apps/design_system_gallery/lib/widgets/toolbar/platform_selector.dart new file mode 100644 index 0000000..583af23 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/toolbar/platform_selector.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A dropdown selector for choosing the target platform override. +/// +/// Displays the current platform with an icon and allows the user to switch +/// between system default, Android, iOS, macOS, Windows, and Linux. +class PlatformSelector extends StatelessWidget { + const PlatformSelector({ + super.key, + required this.value, + required this.options, + required this.onChanged, + }); + + final TargetPlatform? value; + final List options; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Container( + padding: EdgeInsets.symmetric(horizontal: spacing.sm), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderDefault), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + icon: Icon( + Icons.unfold_more, + color: colorScheme.textTertiary, + size: 16, + ), + style: textTheme.captionDefault.copyWith( + color: colorScheme.textPrimary, + ), + dropdownColor: colorScheme.backgroundSurface, + items: options.map((platform) { + return DropdownMenuItem( + value: platform, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _iconFor(platform), + size: 14, + color: colorScheme.textTertiary, + ), + SizedBox(width: spacing.sm), + Text( + _labelFor(platform), + style: textTheme.captionDefault, + ), + ], + ), + ); + }).toList(), + onChanged: onChanged, + ), + ), + ); + } + + static String _labelFor(TargetPlatform? platform) => switch (platform) { + null => 'System', + TargetPlatform.android => 'Android', + TargetPlatform.iOS => 'iOS', + TargetPlatform.macOS => 'macOS', + TargetPlatform.windows => 'Windows', + TargetPlatform.linux => 'Linux', + TargetPlatform.fuchsia => 'Fuchsia', + }; + + static IconData _iconFor(TargetPlatform? platform) => switch (platform) { + null => Icons.settings_suggest, + TargetPlatform.android => Icons.android, + TargetPlatform.iOS => Icons.phone_iphone, + TargetPlatform.macOS => Icons.laptop_mac, + TargetPlatform.windows => Icons.desktop_windows, + TargetPlatform.linux => Icons.terminal, + TargetPlatform.fuchsia => Icons.all_inclusive, + }; +} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart index 93883d5..abce4f8 100644 --- a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart +++ b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart @@ -9,6 +9,7 @@ import '../../config/theme_configuration.dart'; import '../../core/stream_icons.dart'; import 'debug_paint_toggle.dart'; import 'device_selector.dart'; +import 'platform_selector.dart'; import 'text_direction_selector.dart'; import 'text_scale_selector.dart'; import 'theme_mode_toggle.dart'; @@ -90,6 +91,13 @@ class GalleryToolbar extends StatelessWidget { onChanged: previewConfig.setTextDirection, ), + // Platform override selector + PlatformSelector( + value: previewConfig.targetPlatform, + options: PreviewConfiguration.platformOptions, + onChanged: previewConfig.setTargetPlatform, + ), + // Debug tools (debug mode only) if (kDebugMode) ...[ const DebugPaintToggle(), From 0d2cb811a7b6e3d78b1f06a9f3153046de9aa291 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 17 Feb 2026 22:38:35 +0530 Subject: [PATCH 3/5] chore: flutter format --- .../widgets/toolbar/platform_selector.dart | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/design_system_gallery/lib/widgets/toolbar/platform_selector.dart b/apps/design_system_gallery/lib/widgets/toolbar/platform_selector.dart index 583af23..c7f8bc9 100644 --- a/apps/design_system_gallery/lib/widgets/toolbar/platform_selector.dart +++ b/apps/design_system_gallery/lib/widgets/toolbar/platform_selector.dart @@ -74,22 +74,22 @@ class PlatformSelector extends StatelessWidget { } static String _labelFor(TargetPlatform? platform) => switch (platform) { - null => 'System', - TargetPlatform.android => 'Android', - TargetPlatform.iOS => 'iOS', - TargetPlatform.macOS => 'macOS', - TargetPlatform.windows => 'Windows', - TargetPlatform.linux => 'Linux', - TargetPlatform.fuchsia => 'Fuchsia', - }; + null => 'System', + TargetPlatform.android => 'Android', + TargetPlatform.iOS => 'iOS', + TargetPlatform.macOS => 'macOS', + TargetPlatform.windows => 'Windows', + TargetPlatform.linux => 'Linux', + TargetPlatform.fuchsia => 'Fuchsia', + }; static IconData _iconFor(TargetPlatform? platform) => switch (platform) { - null => Icons.settings_suggest, - TargetPlatform.android => Icons.android, - TargetPlatform.iOS => Icons.phone_iphone, - TargetPlatform.macOS => Icons.laptop_mac, - TargetPlatform.windows => Icons.desktop_windows, - TargetPlatform.linux => Icons.terminal, - TargetPlatform.fuchsia => Icons.all_inclusive, - }; + null => Icons.settings_suggest, + TargetPlatform.android => Icons.android, + TargetPlatform.iOS => Icons.phone_iphone, + TargetPlatform.macOS => Icons.laptop_mac, + TargetPlatform.windows => Icons.desktop_windows, + TargetPlatform.linux => Icons.terminal, + TargetPlatform.fuchsia => Icons.all_inclusive, + }; } From 8a0c8c44f1c94b536ac2ee8d812ffcf8e5b6a52c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 17 Feb 2026 22:40:13 +0530 Subject: [PATCH 4/5] feat: add StreamContextMenu and StreamContextMenuItem components with playground examples --- .../lib/app/gallery_app.directories.g.dart | 24 + .../context_menu/stream_context_menu.dart | 645 ++++++++++++++++++ .../lib/src/components.dart | 2 + .../context_menu/stream_context_menu.dart | 216 ++++++ .../stream_context_menu_item.dart | 288 ++++++++ .../src/factory/stream_component_factory.dart | 6 + .../stream_component_factory.g.theme.dart | 6 + .../stream_core_flutter/lib/src/theme.dart | 2 + .../stream_context_menu_item_theme.dart | 203 ++++++ ...tream_context_menu_item_theme.g.theme.dart | 261 +++++++ .../components/stream_context_menu_theme.dart | 162 +++++ .../stream_context_menu_theme.g.theme.dart | 187 +++++ .../lib/src/theme/stream_theme.dart | 16 + .../lib/src/theme/stream_theme.g.theme.dart | 18 + .../src/theme/stream_theme_extensions.dart | 8 + 15 files changed, 2044 insertions(+) create mode 100644 apps/design_system_gallery/lib/components/context_menu/stream_context_menu.dart create mode 100644 packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu.dart create mode 100644 packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu_item.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_item_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_item_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.g.theme.dart 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 67bac2b..ba7325c 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 @@ -28,6 +28,8 @@ import 'package:design_system_gallery/components/buttons/button.dart' as _design_system_gallery_components_buttons_button; import 'package:design_system_gallery/components/buttons/stream_emoji_button.dart' as _design_system_gallery_components_buttons_stream_emoji_button; +import 'package:design_system_gallery/components/context_menu/stream_context_menu.dart' + as _design_system_gallery_components_context_menu_stream_context_menu; import 'package:design_system_gallery/components/message_composer/message_composer.dart' as _design_system_gallery_components_message_composer_message_composer; import 'package:design_system_gallery/components/message_composer/message_composer_attachment_link_preview.dart' @@ -330,6 +332,28 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Context Menu', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamContextMenu', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_context_menu_stream_context_menu + .buildStreamContextMenuPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_context_menu_stream_context_menu + .buildStreamContextMenuShowcase, + ), + ], + ), + ], + ), _widgetbook.WidgetbookFolder( name: 'Message Composer', children: [ 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 new file mode 100644 index 0000000..f777195 --- /dev/null +++ b/apps/design_system_gallery/lib/components/context_menu/stream_context_menu.dart @@ -0,0 +1,645 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamContextMenu, + path: '[Components]/Context Menu', +) +Widget buildStreamContextMenuPlayground(BuildContext context) { + return const _PlaygroundDemo(); +} + +class _PlaygroundDemo extends StatefulWidget { + const _PlaygroundDemo(); + + @override + State<_PlaygroundDemo> createState() => _PlaygroundDemoState(); +} + +class _PlaygroundDemoState extends State<_PlaygroundDemo> { + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + + final itemCount = context.knobs.int.slider( + label: 'Item Count', + initialValue: 5, + min: 1, + max: 8, + description: 'Number of regular menu items to display.', + ); + + final showLeadingIcon = context.knobs.boolean( + label: 'Leading Icon', + initialValue: true, + description: 'Show an icon before each item label.', + ); + + final showTrailingIcon = context.knobs.boolean( + label: 'Trailing Icon', + description: 'Show a chevron after each item label.', + ); + + final showSeparator = context.knobs.boolean( + label: 'Separator', + initialValue: true, + description: 'Show a divider before the last item group.', + ); + + final showDestructiveItem = context.knobs.boolean( + label: 'Destructive Item', + initialValue: true, + description: 'Include a destructive action at the end.', + ); + + final hasDisabledItem = context.knobs.boolean( + label: 'Disabled Item', + description: 'Make the second item 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})>[ + (label: 'Reply', icon: icons.arrowShareLeft), + (label: 'Thread Reply', icon: icons.bubbleText6ChatMessage), + (label: 'Pin to Conversation', icon: icons.pin), + (label: 'Copy Message', icon: icons.squareBehindSquare2Copy), + (label: 'Mark Unread', icon: icons.bubbleWideNotificationChatMessage), + (label: 'Remind Me', icon: icons.bellNotification), + (label: 'Save For Later', icon: icons.fileBend), + (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'), + ), + ]; + + return Center( + child: StreamContextMenu(children: items), + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamContextMenu, + path: '[Components]/Context Menu', +) +Widget buildStreamContextMenuShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: EdgeInsets.all(spacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _ItemStatesSection(), + SizedBox(height: spacing.xl), + const _MenuCompositionsSection(), + SizedBox(height: spacing.xl), + const _RealWorldSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Item States Section +// ============================================================================= + +class _ItemStatesSection extends StatelessWidget { + const _ItemStatesSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: const [ + _SectionLabel(label: 'ITEM STATES'), + _NormalStatesCard(), + _DestructiveStatesCard(), + ], + ); + } +} + +class _NormalStatesCard extends StatelessWidget { + const _NormalStatesCard(); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return _ExampleCard( + title: 'Normal', + description: 'Default styling for standard actions', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xxs, + children: [ + StreamContextMenuItem( + label: const Text( + 'With Leading & Trailing', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + leading: Icon(icons.plusLarge), + trailing: Icon(icons.chevronRight), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('With Leading Only'), + leading: Icon(icons.plusLarge), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Label Only'), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Disabled'), + leading: Icon(icons.plusLarge), + trailing: Icon(icons.chevronRight), + ), + ], + ), + ); + } +} + +class _DestructiveStatesCard extends StatelessWidget { + const _DestructiveStatesCard(); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return _ExampleCard( + title: 'Destructive', + description: 'Error styling for dangerous actions', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xxs, + children: [ + StreamContextMenuItem.destructive( + label: const Text( + 'With Leading & Trailing', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + leading: Icon(icons.trashBin), + trailing: Icon(icons.chevronRight), + onPressed: () {}, + ), + StreamContextMenuItem.destructive( + label: const Text('With Leading Only'), + leading: Icon(icons.trashBin), + onPressed: () {}, + ), + StreamContextMenuItem.destructive( + label: const Text('Label Only'), + onPressed: () {}, + ), + StreamContextMenuItem.destructive( + label: const Text('Disabled'), + leading: Icon(icons.trashBin), + trailing: Icon(icons.chevronRight), + ), + ], + ), + ); + } +} + +// ============================================================================= +// Menu Compositions Section +// ============================================================================= + +class _MenuCompositionsSection extends StatelessWidget { + const _MenuCompositionsSection(); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'MENU COMPOSITIONS'), + _ExampleCard( + title: 'Simple Menu', + description: 'Basic items without icons', + child: Center( + child: StreamContextMenu( + children: [ + StreamContextMenuItem( + label: const Text('Cut'), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Copy'), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Paste'), + onPressed: () {}, + ), + ], + ), + ), + ), + _ExampleCard( + title: 'With Icons', + description: 'Items with leading icons', + child: Center( + child: StreamContextMenu( + 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: () {}, + ), + ], + ), + ), + ), + _ExampleCard( + title: 'With Separator', + description: 'Groups divided by a separator', + child: Center( + child: StreamContextMenu( + children: [ + StreamContextMenuItem( + label: const Text('Reply'), + leading: Icon(icons.arrowShareLeft), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Copy Message'), + leading: Icon(icons.squareBehindSquare2Copy), + onPressed: () {}, + ), + const StreamContextMenuSeparator(), + StreamContextMenuItem.destructive( + label: const Text('Delete'), + leading: Icon(icons.trashBin), + onPressed: () {}, + ), + ], + ), + ), + ), + _ExampleCard( + title: 'Auto-Separated', + description: 'Using StreamContextMenu.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: () {}, + ), + ], + ), + ), + ), + _ExampleCard( + title: 'Sub-Menu Navigation', + description: 'Back item with nested items', + child: Center( + child: StreamContextMenu( + children: [ + StreamContextMenuItemTheme( + data: StreamContextMenuItemThemeData( + style: StreamContextMenuItemStyle( + foregroundColor: WidgetStatePropertyAll( + context.streamColorScheme.textTertiary, + ), + iconColor: WidgetStatePropertyAll( + context.streamColorScheme.textTertiary, + ), + ), + ), + child: StreamContextMenuItem( + label: const Text('Reactions'), + leading: Icon(icons.chevronLeft), + onPressed: () {}, + ), + ), + StreamContextMenuItem( + label: const Text('Love'), + leading: Icon(icons.heart2), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Smile'), + leading: Icon(icons.emojiSmile), + onPressed: () {}, + ), + ], + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Real-World Examples Section +// ============================================================================= + +class _RealWorldSection extends StatelessWidget { + const _RealWorldSection(); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'REAL-WORLD EXAMPLES'), + _ExampleCard( + title: 'Incoming Message Actions', + description: 'Actions for a message from another user', + child: Center( + child: StreamContextMenu( + children: [ + StreamContextMenuItem( + label: const Text('Reply'), + leading: Icon(icons.arrowShareLeft), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Thread Reply'), + leading: Icon(icons.bubbleText6ChatMessage), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Pin to Conversation'), + leading: Icon(icons.pin), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Copy Message'), + leading: Icon(icons.squareBehindSquare2Copy), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Mark Unread'), + leading: Icon(icons.bubbleWideNotificationChatMessage), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Remind Me'), + leading: Icon(icons.bellNotification), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Save For Later'), + leading: Icon(icons.fileBend), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Flag Message'), + leading: Icon(icons.flag2), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Mute User'), + leading: Icon(icons.mute), + onPressed: () {}, + ), + const StreamContextMenuSeparator(), + StreamContextMenuItem.destructive( + label: const Text('Block User'), + leading: Icon(icons.circleBanSign), + onPressed: () {}, + ), + ], + ), + ), + ), + _ExampleCard( + title: 'Outgoing Message Actions', + description: 'Actions for a message sent by the current user', + child: Center( + child: StreamContextMenu( + children: [ + StreamContextMenuItem( + label: const Text('Reply'), + leading: Icon(icons.arrowShareLeft), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Thread Reply'), + leading: Icon(icons.bubbleText6ChatMessage), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Pin to Conversation'), + leading: Icon(icons.pin), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Copy Message'), + leading: Icon(icons.squareBehindSquare2Copy), + onPressed: () {}, + ), + StreamContextMenuItem( + label: const Text('Edit Message'), + leading: Icon(icons.editBig), + onPressed: () {}, + ), + const StreamContextMenuSeparator(), + StreamContextMenuItem.destructive( + label: const Text('Delete Message'), + leading: Icon(icons.trashBin), + onPressed: () {}, + ), + ], + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.title, + required this.description, + required this.child, + }); + + final String title; + final String description; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final boxShadow = context.streamBoxShadow; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.all(radius.lg), + boxShadow: boxShadow.elevation1, + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + spacing.md, + spacing.sm, + spacing.md, + spacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + description, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + ), + Divider(height: 1, color: colorScheme.borderSubtle), + Container( + width: double.infinity, + padding: EdgeInsets.all(spacing.md), + color: colorScheme.backgroundSurface, + child: child, + ), + ], + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Container( + padding: EdgeInsets.symmetric( + horizontal: spacing.sm, + vertical: spacing.xs, + ), + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + borderRadius: BorderRadius.all(radius.xs), + ), + child: Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textOnAccent, + letterSpacing: 1, + fontSize: 9, + ), + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 46cb141..be5e3f8 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -7,6 +7,8 @@ export 'components/badge/stream_badge_count.dart' hide DefaultStreamBadgeCount; export 'components/badge/stream_online_indicator.dart' hide DefaultStreamOnlineIndicator; export 'components/buttons/stream_button.dart' hide DefaultStreamButton; export 'components/buttons/stream_emoji_button.dart' hide DefaultStreamEmojiButton; +export 'components/context_menu/stream_context_menu.dart'; +export 'components/context_menu/stream_context_menu_item.dart' hide DefaultStreamContextMenuItem; 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 new file mode 100644 index 0000000..caf613d --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; + +import '../../theme/components/stream_context_menu_theme.dart'; +import '../../theme/primitives/stream_radius.dart'; +import '../../theme/primitives/stream_spacing.dart'; +import '../../theme/semantics/stream_box_shadow.dart'; +import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/stream_theme_extensions.dart'; + +/// A contextual menu container that displays a list of menu items. +/// +/// [StreamContextMenu] renders its [children] in a vertical list inside a +/// 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. +/// +/// The container's appearance can be customized via [StreamContextMenuTheme]. +/// +/// {@tool snippet} +/// +/// Display a context menu with items: +/// +/// ```dart +/// StreamContextMenu( +/// children: [ +/// StreamContextMenuItem( +/// label: Text('Reply'), +/// leading: Icon(Icons.reply), +/// onPressed: () => handleReply(), +/// ), +/// StreamContextMenuItem( +/// label: Text('Copy Message'), +/// leading: Icon(Icons.copy), +/// onPressed: () => handleCopy(), +/// ), +/// StreamContextMenuSeparator(), +/// StreamContextMenuItem.destructive( +/// label: Text('Block User'), +/// leading: Icon(Icons.block), +/// onPressed: () => handleBlock(), +/// ), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamContextMenuItem], for individual menu items. +/// * [StreamContextMenuSeparator], for visual dividers between groups. +/// * [StreamContextMenuTheme], for customizing container appearance. +/// * [StreamContextMenuItemTheme], for customizing item appearance. +class StreamContextMenu extends StatelessWidget { + /// Creates a context menu container. + const StreamContextMenu({ + super.key, + required this.children, + 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 + /// [StreamContextMenuSeparator] widgets. + final List children; + + /// The clip behavior for the menu container. + /// + /// Clips the menu content to the container's rounded shape. + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + final themeStyle = context.streamContextMenuTheme.style; + final defaults = _ContextMenuStyleDefaults(context); + + final effectiveBackgroundColor = themeStyle?.backgroundColor ?? defaults.backgroundColor; + final effectiveBoxShadow = themeStyle?.boxShadow ?? defaults.boxShadow; + final effectivePadding = themeStyle?.padding ?? defaults.padding; + final effectiveSide = themeStyle?.side ?? defaults.side; + final effectiveShape = (themeStyle?.shape ?? defaults.shape).copyWith(side: effectiveSide); + + return IntrinsicWidth( + child: Container( + clipBehavior: clipBehavior, + padding: effectivePadding, + decoration: ShapeDecoration( + shape: effectiveShape, + color: effectiveBackgroundColor, + shadows: effectiveBoxShadow, + ), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: children, + ), + ), + ); + } +} + +/// A visual separator between groups of items in a [StreamContextMenu]. +/// +/// Displays a thin horizontal line with vertical padding. Use it to visually +/// 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. +/// +/// {@tool snippet} +/// +/// Add a separator between item groups: +/// +/// ```dart +/// StreamContextMenu( +/// children: [ +/// StreamContextMenuItem(label: Text('Reply'), onPressed: () {}), +/// StreamContextMenuItem(label: Text('Copy'), onPressed: () {}), +/// StreamContextMenuSeparator(), +/// StreamContextMenuItem.destructive( +/// label: Text('Delete'), +/// onPressed: () {}, +/// ), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamContextMenu], which contains this separator. +/// * [StreamContextMenu.separated], which auto-inserts separators. +/// * [StreamContextMenuItem], for menu items. +class StreamContextMenuSeparator extends StatelessWidget { + /// Creates a context menu separator. + const StreamContextMenuSeparator({super.key}); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return Padding( + padding: .symmetric(vertical: spacing.xxs), + child: Divider(height: 1, thickness: 1, color: colorScheme.borderDefault), + ); + } +} + +/// Default values for [StreamContextMenuStyle]. +/// +/// Provides sensible defaults based on the current [StreamColorScheme], +/// [StreamRadius], [StreamSpacing], and [StreamBoxShadow]. +class _ContextMenuStyleDefaults extends StreamContextMenuStyle { + _ContextMenuStyleDefaults(this.context); + + final BuildContext context; + + late final StreamRadius _radius = context.streamRadius; + late final StreamSpacing _spacing = context.streamSpacing; + late final StreamBoxShadow _boxShadow = context.streamBoxShadow; + late final StreamColorScheme _colorScheme = context.streamColorScheme; + + @override + OutlinedBorder get shape => RoundedRectangleBorder(borderRadius: .all(_radius.lg)); + + @override + BorderSide get side => BorderSide(color: _colorScheme.borderDefault); + + @override + Color get backgroundColor => _colorScheme.backgroundElevation2; + + @override + List get boxShadow => _boxShadow.elevation2; + + @override + EdgeInsetsGeometry get padding => EdgeInsets.all(_spacing.xxs); +} 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 new file mode 100644 index 0000000..1a8e6eb --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/context_menu/stream_context_menu_item.dart @@ -0,0 +1,288 @@ +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 209705a..da02ead 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 @@ -155,6 +155,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { this.avatarStack, this.badgeCount, this.button, + this.contextMenuItem, this.emoji, this.emojiButton, this.fileTypeIcon, @@ -186,6 +187,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamButton] uses [DefaultStreamButton]. final StreamComponentBuilder? button; + /// Custom builder for context menu item widgets. + /// + /// When null, [StreamContextMenuItem] uses [DefaultStreamContextMenuItem]. + final StreamComponentBuilder? contextMenuItem; + /// Custom builder for emoji widgets. /// /// When null, [StreamEmoji] uses [DefaultStreamEmoji]. 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 6edc707..4292212 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 @@ -35,6 +35,7 @@ mixin _$StreamComponentBuilders { avatarStack: t < 0.5 ? a.avatarStack : b.avatarStack, badgeCount: t < 0.5 ? a.badgeCount : b.badgeCount, button: t < 0.5 ? a.button : b.button, + contextMenuItem: t < 0.5 ? a.contextMenuItem : b.contextMenuItem, emoji: t < 0.5 ? a.emoji : b.emoji, emojiButton: t < 0.5 ? a.emojiButton : b.emojiButton, fileTypeIcon: t < 0.5 ? a.fileTypeIcon : b.fileTypeIcon, @@ -48,6 +49,7 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamAvatarStackProps)? avatarStack, Widget Function(BuildContext, StreamBadgeCountProps)? badgeCount, Widget Function(BuildContext, StreamButtonProps)? button, + Widget Function(BuildContext, StreamContextMenuItemProps)? contextMenuItem, Widget Function(BuildContext, StreamEmojiProps)? emoji, Widget Function(BuildContext, StreamEmojiButtonProps)? emojiButton, Widget Function(BuildContext, StreamFileTypeIconProps)? fileTypeIcon, @@ -61,6 +63,7 @@ mixin _$StreamComponentBuilders { avatarStack: avatarStack ?? _this.avatarStack, badgeCount: badgeCount ?? _this.badgeCount, button: button ?? _this.button, + contextMenuItem: contextMenuItem ?? _this.contextMenuItem, emoji: emoji ?? _this.emoji, emojiButton: emojiButton ?? _this.emojiButton, fileTypeIcon: fileTypeIcon ?? _this.fileTypeIcon, @@ -85,6 +88,7 @@ mixin _$StreamComponentBuilders { avatarStack: other.avatarStack, badgeCount: other.badgeCount, button: other.button, + contextMenuItem: other.contextMenuItem, emoji: other.emoji, emojiButton: other.emojiButton, fileTypeIcon: other.fileTypeIcon, @@ -110,6 +114,7 @@ mixin _$StreamComponentBuilders { _other.avatarStack == _this.avatarStack && _other.badgeCount == _this.badgeCount && _other.button == _this.button && + _other.contextMenuItem == _this.contextMenuItem && _other.emoji == _this.emoji && _other.emojiButton == _this.emojiButton && _other.fileTypeIcon == _this.fileTypeIcon && @@ -127,6 +132,7 @@ mixin _$StreamComponentBuilders { _this.avatarStack, _this.badgeCount, _this.button, + _this.contextMenuItem, _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 f66f1c0..aa01875 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -3,6 +3,8 @@ export 'factory/stream_component_factory.dart'; 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_context_menu_item_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'; export 'theme/components/stream_message_theme.dart'; 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 new file mode 100644 index 0000000..899e4e0 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_item_theme.dart @@ -0,0 +1,203 @@ +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_item_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_item_theme.g.theme.dart new file mode 100644 index 0000000..6d74425 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_item_theme.g.theme.dart @@ -0,0 +1,261 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_context_menu_item_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamContextMenuItemThemeData { + bool get canMerge => true; + + static StreamContextMenuItemThemeData? lerp( + StreamContextMenuItemThemeData? a, + StreamContextMenuItemThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamContextMenuItemThemeData( + style: StreamContextMenuItemStyle.lerp(a.style, b.style, t), + ); + } + + StreamContextMenuItemThemeData copyWith({StreamContextMenuItemStyle? style}) { + final _this = (this as StreamContextMenuItemThemeData); + + return StreamContextMenuItemThemeData(style: style ?? _this.style); + } + + StreamContextMenuItemThemeData merge(StreamContextMenuItemThemeData? other) { + final _this = (this as StreamContextMenuItemThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamContextMenuItemThemeData); + final _other = (other as StreamContextMenuItemThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamContextMenuItemThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamContextMenuItemStyle { + bool get canMerge => true; + + static StreamContextMenuItemStyle? lerp( + StreamContextMenuItemStyle? a, + StreamContextMenuItemStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamContextMenuItemStyle( + backgroundColor: WidgetStateProperty.lerp( + a.backgroundColor, + b.backgroundColor, + t, + Color.lerp, + ), + foregroundColor: WidgetStateProperty.lerp( + a.foregroundColor, + b.foregroundColor, + t, + Color.lerp, + ), + overlayColor: WidgetStateProperty.lerp( + a.overlayColor, + b.overlayColor, + t, + Color.lerp, + ), + iconColor: WidgetStateProperty.lerp( + a.iconColor, + b.iconColor, + t, + Color.lerp, + ), + textStyle: WidgetStateProperty.lerp( + a.textStyle, + b.textStyle, + t, + TextStyle.lerp, + ), + iconSize: WidgetStateProperty.lerp( + a.iconSize, + b.iconSize, + t, + lerpDouble$, + ), + minimumSize: WidgetStateProperty.lerp( + a.minimumSize, + b.minimumSize, + t, + Size.lerp, + ), + maximumSize: WidgetStateProperty.lerp( + a.maximumSize, + b.maximumSize, + t, + Size.lerp, + ), + padding: WidgetStateProperty.lerp( + a.padding, + b.padding, + t, + EdgeInsetsGeometry.lerp, + ), + shape: WidgetStateProperty.lerp( + a.shape, + b.shape, + t, + OutlinedBorder.lerp, + ), + ); + } + + StreamContextMenuItemStyle copyWith({ + WidgetStateProperty? backgroundColor, + WidgetStateProperty? foregroundColor, + WidgetStateProperty? overlayColor, + WidgetStateProperty? iconColor, + WidgetStateProperty? textStyle, + WidgetStateProperty? iconSize, + WidgetStateProperty? minimumSize, + WidgetStateProperty? maximumSize, + WidgetStateProperty? padding, + WidgetStateProperty? shape, + }) { + final _this = (this as StreamContextMenuItemStyle); + + return StreamContextMenuItemStyle( + backgroundColor: backgroundColor ?? _this.backgroundColor, + foregroundColor: foregroundColor ?? _this.foregroundColor, + overlayColor: overlayColor ?? _this.overlayColor, + iconColor: iconColor ?? _this.iconColor, + textStyle: textStyle ?? _this.textStyle, + iconSize: iconSize ?? _this.iconSize, + minimumSize: minimumSize ?? _this.minimumSize, + maximumSize: maximumSize ?? _this.maximumSize, + padding: padding ?? _this.padding, + shape: shape ?? _this.shape, + ); + } + + StreamContextMenuItemStyle merge(StreamContextMenuItemStyle? other) { + final _this = (this as StreamContextMenuItemStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + foregroundColor: other.foregroundColor, + overlayColor: other.overlayColor, + iconColor: other.iconColor, + textStyle: other.textStyle, + iconSize: other.iconSize, + minimumSize: other.minimumSize, + maximumSize: other.maximumSize, + padding: other.padding, + shape: other.shape, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamContextMenuItemStyle); + final _other = (other as StreamContextMenuItemStyle); + + return _other.backgroundColor == _this.backgroundColor && + _other.foregroundColor == _this.foregroundColor && + _other.overlayColor == _this.overlayColor && + _other.iconColor == _this.iconColor && + _other.textStyle == _this.textStyle && + _other.iconSize == _this.iconSize && + _other.minimumSize == _this.minimumSize && + _other.maximumSize == _this.maximumSize && + _other.padding == _this.padding && + _other.shape == _this.shape; + } + + @override + int get hashCode { + final _this = (this as StreamContextMenuItemStyle); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.foregroundColor, + _this.overlayColor, + _this.iconColor, + _this.textStyle, + _this.iconSize, + _this.minimumSize, + _this.maximumSize, + _this.padding, + _this.shape, + ); + } +} 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 new file mode 100644 index 0000000..efd8e6c --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.dart @@ -0,0 +1,162 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_context_menu_theme.g.theme.dart'; + +/// Applies a context menu theme to descendant context menu widgets. +/// +/// Wrap a subtree with [StreamContextMenuTheme] to override context menu +/// styling. Access the merged theme using +/// [BuildContext.streamContextMenuTheme]. +/// +/// {@tool snippet} +/// +/// Override context menu styling for a specific section: +/// +/// ```dart +/// StreamContextMenuTheme( +/// data: StreamContextMenuThemeData( +/// style: StreamContextMenuStyle( +/// backgroundColor: Colors.grey.shade100, +/// shape: RoundedRectangleBorder( +/// borderRadius: BorderRadius.circular(16), +/// ), +/// side: BorderSide(color: Colors.grey.shade300), +/// ), +/// ), +/// child: StreamContextMenu( +/// children: [ +/// StreamContextMenuItem(label: Text('Reply'), onPressed: () {}), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamContextMenuThemeData], which describes the context menu theme. +/// * [StreamContextMenuStyle], for container-level styling. +/// * [StreamContextMenu], which uses this theme. +/// * [StreamContextMenuItemTheme], for customizing individual item appearance. +class StreamContextMenuTheme extends InheritedTheme { + /// Creates a context menu theme that controls descendant context menus. + const StreamContextMenuTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The context menu theme data for descendant widgets. + final StreamContextMenuThemeData data; + + /// Returns the [StreamContextMenuThemeData] from the current theme context. + /// + /// This merges the local theme (if any) with the global theme from + /// [StreamTheme]. + static StreamContextMenuThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).contextMenuTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamContextMenuTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamContextMenuTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing context menus. +/// +/// Contains a [StreamContextMenuStyle] that defines the visual properties of +/// the menu container. This follows the same pattern as Flutter's +/// [MenuThemeData] which wraps [MenuStyle]. +/// +/// See also: +/// +/// * [StreamContextMenuTheme], for overriding theme in a widget subtree. +/// * [StreamContextMenuStyle], for container-level styling. +/// * [StreamContextMenu], which uses these properties. +@themeGen +@immutable +class StreamContextMenuThemeData with _$StreamContextMenuThemeData { + /// Creates context menu theme data with optional style overrides. + const StreamContextMenuThemeData({this.style}); + + /// The visual styling for the context menu container. + /// + /// Contains shape, border radius, surface color, and box shadow properties. + final StreamContextMenuStyle? style; + + /// Linearly interpolate between two [StreamContextMenuThemeData] values. + static StreamContextMenuThemeData? lerp( + StreamContextMenuThemeData? a, + StreamContextMenuThemeData? b, + double t, + ) => _$StreamContextMenuThemeData.lerp(a, b, t); +} + +/// Visual styling properties for the context menu container. +/// +/// Inspired by Flutter's [MenuStyle], this defines the appearance of the +/// menu container including its shape, border radius, surface color, and +/// shadow. +/// +/// See also: +/// +/// * [StreamContextMenuThemeData], which wraps this style for theming. +/// * [StreamContextMenu], which uses this styling. +@themeGen +@immutable +class StreamContextMenuStyle with _$StreamContextMenuStyle { + /// Creates context menu style properties. + const StreamContextMenuStyle({ + this.backgroundColor, + this.shape, + this.side, + this.boxShadow, + this.padding, + }); + + /// The background color of the context menu container. + /// + /// If null, defaults to [StreamColorScheme.backgroundElevation2]. + final Color? backgroundColor; + + /// The shape of the menu's underlying surface. + /// + /// This shape is combined with [side] to create a shape decorated with an + /// outline. + /// + /// If null, defaults to a [RoundedRectangleBorder] with [StreamRadius.lg]. + final OutlinedBorder? shape; + + /// The color and weight of the menu's outline. + /// + /// This value is combined with [shape] to create a shape decorated with an + /// outline. + /// + /// If null, defaults to a 1px [StreamColorScheme.borderDefault] border. + final BorderSide? side; + + /// The box shadow of the context menu container. + /// + /// If null, defaults to [StreamBoxShadow.elevation2]. + final List? boxShadow; + + /// The padding between the menu's boundary and its children. + /// + /// If null, defaults to [StreamSpacing.xxs] on all sides. + final EdgeInsetsGeometry? padding; + + /// Linearly interpolate between two [StreamContextMenuStyle] values. + static StreamContextMenuStyle? lerp( + StreamContextMenuStyle? a, + StreamContextMenuStyle? b, + double t, + ) => _$StreamContextMenuStyle.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.g.theme.dart new file mode 100644 index 0000000..877de03 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.g.theme.dart @@ -0,0 +1,187 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_context_menu_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamContextMenuThemeData { + bool get canMerge => true; + + static StreamContextMenuThemeData? lerp( + StreamContextMenuThemeData? a, + StreamContextMenuThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamContextMenuThemeData( + style: StreamContextMenuStyle.lerp(a.style, b.style, t), + ); + } + + StreamContextMenuThemeData copyWith({StreamContextMenuStyle? style}) { + final _this = (this as StreamContextMenuThemeData); + + return StreamContextMenuThemeData(style: style ?? _this.style); + } + + StreamContextMenuThemeData merge(StreamContextMenuThemeData? other) { + final _this = (this as StreamContextMenuThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamContextMenuThemeData); + final _other = (other as StreamContextMenuThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamContextMenuThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamContextMenuStyle { + bool get canMerge => true; + + static StreamContextMenuStyle? lerp( + StreamContextMenuStyle? a, + StreamContextMenuStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamContextMenuStyle( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + shape: OutlinedBorder.lerp(a.shape, b.shape, t), + side: a.side == null + ? b.side + : b.side == null + ? a.side + : BorderSide.lerp(a.side!, b.side!, t), + boxShadow: t < 0.5 ? a.boxShadow : b.boxShadow, + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + ); + } + + StreamContextMenuStyle copyWith({ + Color? backgroundColor, + OutlinedBorder? shape, + BorderSide? side, + List? boxShadow, + EdgeInsetsGeometry? padding, + }) { + final _this = (this as StreamContextMenuStyle); + + return StreamContextMenuStyle( + backgroundColor: backgroundColor ?? _this.backgroundColor, + shape: shape ?? _this.shape, + side: side ?? _this.side, + boxShadow: boxShadow ?? _this.boxShadow, + padding: padding ?? _this.padding, + ); + } + + StreamContextMenuStyle merge(StreamContextMenuStyle? other) { + final _this = (this as StreamContextMenuStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + shape: other.shape, + side: _this.side != null && other.side != null + ? BorderSide.merge(_this.side!, other.side!) + : other.side, + boxShadow: other.boxShadow, + padding: other.padding, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamContextMenuStyle); + final _other = (other as StreamContextMenuStyle); + + return _other.backgroundColor == _this.backgroundColor && + _other.shape == _this.shape && + _other.side == _this.side && + _other.boxShadow == _this.boxShadow && + _other.padding == _this.padding; + } + + @override + int get hashCode { + final _this = (this as StreamContextMenuStyle); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.shape, + _this.side, + _this.boxShadow, + _this.padding, + ); + } +} 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 d2fed3e..5790b5a 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -7,6 +7,8 @@ import 'package:theme_extensions_builder_annotation/theme_extensions_builder_ann import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; import 'components/stream_button_theme.dart'; +import 'components/stream_context_menu_item_theme.dart'; +import 'components/stream_context_menu_theme.dart'; import 'components/stream_emoji_button_theme.dart'; import 'components/stream_input_theme.dart'; import 'components/stream_message_theme.dart'; @@ -88,6 +90,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, StreamButtonThemeData? buttonTheme, + StreamContextMenuThemeData? contextMenuTheme, + StreamContextMenuItemThemeData? contextMenuItemTheme, StreamEmojiButtonThemeData? emojiButtonTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, @@ -111,6 +115,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { avatarTheme ??= const StreamAvatarThemeData(); badgeCountTheme ??= const StreamBadgeCountThemeData(); buttonTheme ??= const StreamButtonThemeData(); + contextMenuTheme ??= const StreamContextMenuThemeData(); + contextMenuItemTheme ??= const StreamContextMenuItemThemeData(); emojiButtonTheme ??= const StreamEmojiButtonThemeData(); messageTheme ??= const StreamMessageThemeData(); inputTheme ??= const StreamInputThemeData(); @@ -128,6 +134,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { avatarTheme: avatarTheme, badgeCountTheme: badgeCountTheme, buttonTheme: buttonTheme, + contextMenuTheme: contextMenuTheme, + contextMenuItemTheme: contextMenuItemTheme, emojiButtonTheme: emojiButtonTheme, messageTheme: messageTheme, inputTheme: inputTheme, @@ -159,6 +167,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.avatarTheme, required this.badgeCountTheme, required this.buttonTheme, + required this.contextMenuTheme, + required this.contextMenuItemTheme, required this.emojiButtonTheme, required this.messageTheme, required this.inputTheme, @@ -232,6 +242,12 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The button theme for this theme. final StreamButtonThemeData buttonTheme; + /// The context menu theme for this theme. + final StreamContextMenuThemeData contextMenuTheme; + + /// The context menu item theme for this theme. + final StreamContextMenuItemThemeData contextMenuItemTheme; + /// The emoji button theme for this theme. final StreamEmojiButtonThemeData emojiButtonTheme; 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 5de1b43..27a65fb 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 @@ -23,6 +23,8 @@ mixin _$StreamTheme on ThemeExtension { StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, StreamButtonThemeData? buttonTheme, + StreamContextMenuThemeData? contextMenuTheme, + StreamContextMenuItemThemeData? contextMenuItemTheme, StreamEmojiButtonThemeData? emojiButtonTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, @@ -42,6 +44,8 @@ mixin _$StreamTheme on ThemeExtension { avatarTheme: avatarTheme ?? _this.avatarTheme, badgeCountTheme: badgeCountTheme ?? _this.badgeCountTheme, buttonTheme: buttonTheme ?? _this.buttonTheme, + contextMenuTheme: contextMenuTheme ?? _this.contextMenuTheme, + contextMenuItemTheme: contextMenuItemTheme ?? _this.contextMenuItemTheme, emojiButtonTheme: emojiButtonTheme ?? _this.emojiButtonTheme, messageTheme: messageTheme ?? _this.messageTheme, inputTheme: inputTheme ?? _this.inputTheme, @@ -81,6 +85,16 @@ mixin _$StreamTheme on ThemeExtension { t, )!, buttonTheme: t < 0.5 ? _this.buttonTheme : other.buttonTheme, + contextMenuTheme: StreamContextMenuThemeData.lerp( + _this.contextMenuTheme, + other.contextMenuTheme, + t, + )!, + contextMenuItemTheme: StreamContextMenuItemThemeData.lerp( + _this.contextMenuItemTheme, + other.contextMenuItemTheme, + t, + )!, emojiButtonTheme: StreamEmojiButtonThemeData.lerp( _this.emojiButtonTheme, other.emojiButtonTheme, @@ -120,6 +134,8 @@ mixin _$StreamTheme on ThemeExtension { _other.avatarTheme == _this.avatarTheme && _other.badgeCountTheme == _this.badgeCountTheme && _other.buttonTheme == _this.buttonTheme && + _other.contextMenuTheme == _this.contextMenuTheme && + _other.contextMenuItemTheme == _this.contextMenuItemTheme && _other.emojiButtonTheme == _this.emojiButtonTheme && _other.messageTheme == _this.messageTheme && _other.inputTheme == _this.inputTheme && @@ -143,6 +159,8 @@ mixin _$StreamTheme on ThemeExtension { _this.avatarTheme, _this.badgeCountTheme, _this.buttonTheme, + _this.contextMenuTheme, + _this.contextMenuItemTheme, _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 6f71715..232fc22 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 @@ -3,6 +3,8 @@ import 'package:flutter/widgets.dart'; import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; import 'components/stream_button_theme.dart'; +import 'components/stream_context_menu_item_theme.dart'; +import 'components/stream_context_menu_theme.dart'; import 'components/stream_emoji_button_theme.dart'; import 'components/stream_input_theme.dart'; import 'components/stream_message_theme.dart'; @@ -70,6 +72,12 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamButtonThemeData] from the nearest ancestor. StreamButtonThemeData get streamButtonTheme => StreamButtonTheme.of(this); + /// 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 [StreamEmojiButtonThemeData] from the nearest ancestor. StreamEmojiButtonThemeData get streamEmojiButtonTheme => StreamEmojiButtonTheme.of(this); From 2be8def6dd4b3424c645674ffa79262d52bd5032 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 18 Feb 2026 00:50:10 +0530 Subject: [PATCH 5/5] feat: add applyPlatform to StreamTheme and update gallery platform override --- .../lib/core/preview_wrapper.dart | 32 ++++----------- .../lib/src/theme/stream_theme.dart | 41 +++++++++++++++++++ 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/apps/design_system_gallery/lib/core/preview_wrapper.dart b/apps/design_system_gallery/lib/core/preview_wrapper.dart index 956fff4..9b9c254 100644 --- a/apps/design_system_gallery/lib/core/preview_wrapper.dart +++ b/apps/design_system_gallery/lib/core/preview_wrapper.dart @@ -30,8 +30,6 @@ class PreviewWrapper extends StatelessWidget { final radius = context.streamRadius; final spacing = context.streamSpacing; - final targetPlatform = previewConfig.targetPlatform; - Widget content = Builder( builder: (context) => MediaQuery( data: MediaQuery.of(context).copyWith( @@ -58,11 +56,8 @@ class PreviewWrapper extends StatelessWidget { // Apply platform override to both Material theme and Stream theme so // that platform-aware primitives (e.g. StreamRadius, StreamTypography) // resolve correctly for the selected platform. - if (targetPlatform != null) { - content = _PlatformOverride( - platform: targetPlatform, - child: content, - ); + if (previewConfig.targetPlatform case final targetPlatform?) { + content = _PlatformOverride(platform: targetPlatform, child: content); } if (previewConfig.showDeviceFrame) { @@ -98,9 +93,8 @@ class PreviewWrapper extends StatelessWidget { /// Overrides the target platform for both Material and Stream themes. /// -/// Rebuilds [StreamTheme] with the given [platform] so that platform-aware -/// primitives like [StreamRadius] and [StreamTypography] use the correct -/// platform-specific values. +/// Uses [StreamTheme.applyPlatform] to recompute platform-dependent +/// primitives while preserving all other theme customizations. class _PlatformOverride extends StatelessWidget { const _PlatformOverride({ required this.platform, @@ -112,23 +106,15 @@ class _PlatformOverride extends StatelessWidget { @override Widget build(BuildContext context) { - final currentTheme = Theme.of(context); - final currentStreamTheme = context.streamTheme; - - // Rebuild StreamTheme with the overridden platform so that - // platform-aware values (radius, typography) are recalculated. - final overriddenStreamTheme = StreamTheme( - brightness: currentStreamTheme.brightness, - platform: platform, - colorScheme: currentStreamTheme.colorScheme, - ); + final theme = Theme.of(context); + final streamTheme = context.streamTheme; return Theme( - data: currentTheme.copyWith( + data: theme.copyWith( platform: platform, extensions: { - ...currentTheme.extensions.values, - overriddenStreamTheme, + ...theme.extensions.values, + streamTheme.applyPlatform(platform), }, ), child: child, 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 5790b5a..301b00e 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -259,4 +259,45 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The online indicator theme for this theme. final StreamOnlineIndicatorThemeData onlineIndicatorTheme; + + /// Creates a copy of this theme but with platform-dependent primitives + /// recomputed for the given [platform]. + /// + /// All other values including component-level theme customizations are + /// preserved. + /// + /// {@tool snippet} + /// + /// Apply iOS platform to an existing theme: + /// + /// ```dart + /// final theme = StreamTheme.light(); + /// final iosTheme = theme.applyPlatform(TargetPlatform.iOS); + /// ``` + /// {@end-tool} + StreamTheme applyPlatform(TargetPlatform platform) { + final newRadius = StreamRadius(platform: platform); + final newTypography = StreamTypography(platform: platform); + final newTextTheme = StreamTextTheme(typography: newTypography).apply(color: colorScheme.systemText); + + return StreamTheme.raw( + brightness: brightness, + icons: icons, + radius: newRadius, + spacing: spacing, + typography: newTypography, + colorScheme: colorScheme, + textTheme: newTextTheme, + boxShadow: boxShadow, + avatarTheme: avatarTheme, + badgeCountTheme: badgeCountTheme, + buttonTheme: buttonTheme, + contextMenuTheme: contextMenuTheme, + contextMenuItemTheme: contextMenuItemTheme, + emojiButtonTheme: emojiButtonTheme, + messageTheme: messageTheme, + inputTheme: inputTheme, + onlineIndicatorTheme: onlineIndicatorTheme, + ); + } }