diff --git a/apps/design_system_gallery/lib/components/badge/stream_badge_count.dart b/apps/design_system_gallery/lib/components/badge/stream_badge_count.dart index 9220cfd..7805e75 100644 --- a/apps/design_system_gallery/lib/components/badge/stream_badge_count.dart +++ b/apps/design_system_gallery/lib/components/badge/stream_badge_count.dart @@ -27,6 +27,66 @@ Widget buildStreamBadgeCountPlayground(BuildContext context) { description: 'The size of the badge.', ); + final withChild = context.knobs.boolean( + label: 'With Child', + description: 'Wrap an icon as child (Badge-like behavior).', + ); + + if (withChild) { + final alignment = context.knobs.object.dropdown( + label: 'Alignment', + options: const [ + AlignmentDirectional.topStart, + AlignmentDirectional.topCenter, + AlignmentDirectional.topEnd, + AlignmentDirectional.centerStart, + AlignmentDirectional.center, + AlignmentDirectional.centerEnd, + AlignmentDirectional.bottomStart, + AlignmentDirectional.bottomCenter, + AlignmentDirectional.bottomEnd, + ], + initialOption: AlignmentDirectional.topEnd, + labelBuilder: (option) => switch (option) { + AlignmentDirectional.topStart => 'Top Start', + AlignmentDirectional.topCenter => 'Top Center', + AlignmentDirectional.topEnd => 'Top End', + AlignmentDirectional.centerStart => 'Center Start', + AlignmentDirectional.center => 'Center', + AlignmentDirectional.centerEnd => 'Center End', + AlignmentDirectional.bottomStart => 'Bottom Start', + AlignmentDirectional.bottomCenter => 'Bottom Center', + AlignmentDirectional.bottomEnd => 'Bottom End', + _ => option.toString(), + }, + description: 'Alignment of badge relative to child (directional for RTL support).', + ); + + final offsetX = context.knobs.double.slider( + label: 'Offset X', + min: -10, + max: 10, + description: 'Horizontal offset for fine-tuning position.', + ); + + final offsetY = context.knobs.double.slider( + label: 'Offset Y', + min: -10, + max: 10, + description: 'Vertical offset for fine-tuning position.', + ); + + return Center( + child: StreamBadgeCount( + label: label, + size: size, + alignment: alignment, + offset: Offset(offsetX, offsetY), + child: const Icon(Icons.chat_bubble_outline, size: 36), + ), + ); + } + return Center( child: StreamBadgeCount( label: label, @@ -64,6 +124,10 @@ Widget buildStreamBadgeCountShowcase(BuildContext context) { const _CountVariantsSection(), SizedBox(height: spacing.xl), + // Alignment variants + const _AlignmentVariantsSection(), + SizedBox(height: spacing.xl), + // Usage patterns const _UsagePatternsSection(), ], @@ -275,6 +339,116 @@ class _CountDemo extends StatelessWidget { } } +// ============================================================================= +// Alignment Variants Section +// ============================================================================= + +class _AlignmentVariantsSection extends StatelessWidget { + const _AlignmentVariantsSection(); + + @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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'ALIGNMENT VARIANTS'), + SizedBox(height: spacing.md), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + 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: [ + Text( + 'Badge-like positioning with child', + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + SizedBox(height: spacing.md), + Wrap( + spacing: spacing.xl, + runSpacing: spacing.lg, + children: const [ + _AlignmentDemo( + alignment: AlignmentDirectional.topStart, + label: 'topStart', + ), + _AlignmentDemo( + alignment: AlignmentDirectional.topEnd, + label: 'topEnd', + ), + _AlignmentDemo( + alignment: AlignmentDirectional.bottomStart, + label: 'bottomStart', + ), + _AlignmentDemo( + alignment: AlignmentDirectional.bottomEnd, + label: 'bottomEnd', + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _AlignmentDemo extends StatelessWidget { + const _AlignmentDemo({required this.alignment, required this.label}); + + final AlignmentGeometry alignment; + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + StreamBadgeCount( + label: '3', + size: StreamBadgeCountSize.xs, + alignment: alignment, + child: Icon( + Icons.chat_bubble_outline, + size: 36, + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.sm), + Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + ], + ); + } +} + // ============================================================================= // Usage Patterns Section // ============================================================================= @@ -349,22 +523,13 @@ class _AvatarWithBadge extends StatelessWidget { return Column( children: [ - Stack( - clipBehavior: Clip.none, - children: [ - StreamAvatar( - size: StreamAvatarSize.lg, - placeholder: (context) => Text(name[0]), - ), - Positioned( - right: -4, - top: -4, - child: StreamBadgeCount( - label: count > 99 ? '99+' : '$count', - size: StreamBadgeCountSize.xs, - ), - ), - ], + StreamBadgeCount( + label: count > 99 ? '99+' : '$count', + size: StreamBadgeCountSize.xs, + child: StreamAvatar( + size: StreamAvatarSize.lg, + placeholder: (context) => Text(name[0]), + ), ), SizedBox(height: spacing.sm), Text( diff --git a/apps/design_system_gallery/lib/components/badge/stream_badge_notification.dart b/apps/design_system_gallery/lib/components/badge/stream_badge_notification.dart index 8e64506..c08845e 100644 --- a/apps/design_system_gallery/lib/components/badge/stream_badge_notification.dart +++ b/apps/design_system_gallery/lib/components/badge/stream_badge_notification.dart @@ -35,6 +35,67 @@ Widget buildStreamBadgeNotificationPlayground(BuildContext context) { description: 'The size of the badge.', ); + final withChild = context.knobs.boolean( + label: 'With Child', + description: 'Wrap an icon as child (Badge-like behavior).', + ); + + if (withChild) { + final alignment = context.knobs.object.dropdown( + label: 'Alignment', + options: const [ + AlignmentDirectional.topStart, + AlignmentDirectional.topCenter, + AlignmentDirectional.topEnd, + AlignmentDirectional.centerStart, + AlignmentDirectional.center, + AlignmentDirectional.centerEnd, + AlignmentDirectional.bottomStart, + AlignmentDirectional.bottomCenter, + AlignmentDirectional.bottomEnd, + ], + initialOption: AlignmentDirectional.topEnd, + labelBuilder: (option) => switch (option) { + AlignmentDirectional.topStart => 'Top Start', + AlignmentDirectional.topCenter => 'Top Center', + AlignmentDirectional.topEnd => 'Top End', + AlignmentDirectional.centerStart => 'Center Start', + AlignmentDirectional.center => 'Center', + AlignmentDirectional.centerEnd => 'Center End', + AlignmentDirectional.bottomStart => 'Bottom Start', + AlignmentDirectional.bottomCenter => 'Bottom Center', + AlignmentDirectional.bottomEnd => 'Bottom End', + _ => option.toString(), + }, + description: 'Alignment of badge relative to child (directional for RTL support).', + ); + + final offsetX = context.knobs.double.slider( + label: 'Offset X', + min: -10, + max: 10, + description: 'Horizontal offset for fine-tuning position.', + ); + + final offsetY = context.knobs.double.slider( + label: 'Offset Y', + min: -10, + max: 10, + description: 'Vertical offset for fine-tuning position.', + ); + + return Center( + child: StreamBadgeNotification( + label: label, + type: type, + size: size, + alignment: alignment, + offset: Offset(offsetX, offsetY), + child: const Icon(Icons.chat_bubble_outline, size: 36), + ), + ); + } + return Center( child: StreamBadgeNotification( label: label, @@ -69,7 +130,11 @@ Widget buildStreamBadgeNotificationShowcase(BuildContext context) { SizedBox(height: spacing.xl), const _SizeVariantsSection(), SizedBox(height: spacing.xl), + const _AlignmentVariantsSection(), + SizedBox(height: spacing.xl), const _CountVariantsSection(), + SizedBox(height: spacing.xl), + const _UsagePatternsSection(), ], ), ), @@ -365,6 +430,293 @@ class _CountDemo extends StatelessWidget { } } +// ============================================================================= +// Alignment Variants Section +// ============================================================================= + +class _AlignmentVariantsSection extends StatelessWidget { + const _AlignmentVariantsSection(); + + @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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'ALIGNMENT VARIANTS'), + SizedBox(height: spacing.md), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + 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: [ + Text( + 'Badge-like positioning with child', + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + SizedBox(height: spacing.md), + Wrap( + spacing: spacing.xl, + runSpacing: spacing.lg, + children: const [ + _AlignmentDemo( + alignment: AlignmentDirectional.topStart, + label: 'topStart', + ), + _AlignmentDemo( + alignment: AlignmentDirectional.topEnd, + label: 'topEnd', + ), + _AlignmentDemo( + alignment: AlignmentDirectional.bottomStart, + label: 'bottomStart', + ), + _AlignmentDemo( + alignment: AlignmentDirectional.bottomEnd, + label: 'bottomEnd', + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _AlignmentDemo extends StatelessWidget { + const _AlignmentDemo({required this.alignment, required this.label}); + + final AlignmentGeometry alignment; + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + StreamBadgeNotification( + label: '3', + size: StreamBadgeNotificationSize.xs, + alignment: alignment, + child: Icon( + Icons.chat_bubble_outline, + size: 36, + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.sm), + Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + ], + ); + } +} + +// ============================================================================= +// Usage Patterns Section +// ============================================================================= + +class _UsagePatternsSection extends StatelessWidget { + const _UsagePatternsSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'USAGE PATTERNS'), + SizedBox(height: spacing.md), + + const _ExampleCard( + title: 'Icon with Notification', + description: 'Badge positioned on icon using child', + child: _IconWithNotificationGroup(), + ), + ], + ); + } +} + +class _IconWithNotificationGroup extends StatelessWidget { + const _IconWithNotificationGroup(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final spacing = context.streamSpacing; + + return Row( + children: [ + _IconWithNotification( + icon: Icons.chat_bubble_outline, + label: 'Chat', + count: 3, + color: colorScheme.textSecondary, + ), + SizedBox(width: spacing.xl), + _IconWithNotification( + icon: Icons.notifications_outlined, + label: 'Alerts', + count: 12, + color: colorScheme.textSecondary, + ), + SizedBox(width: spacing.xl), + _IconWithNotification( + icon: Icons.inbox_outlined, + label: 'Inbox', + count: 99, + color: colorScheme.textSecondary, + ), + SizedBox(width: spacing.xl), + _IconWithNotification( + icon: Icons.mail_outline, + label: 'Mail', + count: 150, + color: colorScheme.textSecondary, + ), + ], + ); + } +} + +class _IconWithNotification extends StatelessWidget { + const _IconWithNotification({ + required this.icon, + required this.label, + required this.count, + required this.color, + }); + + final IconData icon; + final String label; + final int count; + final Color color; + + @override + Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + final spacing = context.streamSpacing; + final displayText = count > 99 ? '99+' : '$count'; + + return Column( + children: [ + StreamBadgeNotification( + label: displayText, + size: StreamBadgeNotificationSize.xs, + child: Icon(icon, size: 36, color: color), + ), + SizedBox(height: spacing.sm), + Text( + label, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textPrimary, + ), + ), + ], + ); + } +} + +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( + padding: EdgeInsets.all(spacing.md), + color: colorScheme.backgroundSurface, + child: child, + ), + ], + ), + ); + } +} + // ============================================================================= // Shared Widgets // ============================================================================= diff --git a/apps/design_system_gallery/lib/components/badge/stream_online_indicator.dart b/apps/design_system_gallery/lib/components/badge/stream_online_indicator.dart index 1dee61b..8bbf38a 100644 --- a/apps/design_system_gallery/lib/components/badge/stream_online_indicator.dart +++ b/apps/design_system_gallery/lib/components/badge/stream_online_indicator.dart @@ -32,50 +32,50 @@ Widget buildStreamOnlineIndicatorPlayground(BuildContext context) { description: 'Wrap an avatar as child (Badge-like behavior).', ); - final alignment = context.knobs.object.dropdown( - label: 'Alignment', - options: const [ - AlignmentDirectional.topStart, - AlignmentDirectional.topCenter, - AlignmentDirectional.topEnd, - AlignmentDirectional.centerStart, - AlignmentDirectional.center, - AlignmentDirectional.centerEnd, - AlignmentDirectional.bottomStart, - AlignmentDirectional.bottomCenter, - AlignmentDirectional.bottomEnd, - ], - initialOption: AlignmentDirectional.topEnd, - labelBuilder: (option) => switch (option) { - AlignmentDirectional.topStart => 'Top Start', - AlignmentDirectional.topCenter => 'Top Center', - AlignmentDirectional.topEnd => 'Top End', - AlignmentDirectional.centerStart => 'Center Start', - AlignmentDirectional.center => 'Center', - AlignmentDirectional.centerEnd => 'Center End', - AlignmentDirectional.bottomStart => 'Bottom Start', - AlignmentDirectional.bottomCenter => 'Bottom Center', - AlignmentDirectional.bottomEnd => 'Bottom End', - _ => option.toString(), - }, - description: 'Alignment of indicator relative to child (directional for RTL support).', - ); + if (withChild) { + final alignment = context.knobs.object.dropdown( + label: 'Alignment', + options: const [ + AlignmentDirectional.topStart, + AlignmentDirectional.topCenter, + AlignmentDirectional.topEnd, + AlignmentDirectional.centerStart, + AlignmentDirectional.center, + AlignmentDirectional.centerEnd, + AlignmentDirectional.bottomStart, + AlignmentDirectional.bottomCenter, + AlignmentDirectional.bottomEnd, + ], + initialOption: AlignmentDirectional.topEnd, + labelBuilder: (option) => switch (option) { + AlignmentDirectional.topStart => 'Top Start', + AlignmentDirectional.topCenter => 'Top Center', + AlignmentDirectional.topEnd => 'Top End', + AlignmentDirectional.centerStart => 'Center Start', + AlignmentDirectional.center => 'Center', + AlignmentDirectional.centerEnd => 'Center End', + AlignmentDirectional.bottomStart => 'Bottom Start', + AlignmentDirectional.bottomCenter => 'Bottom Center', + AlignmentDirectional.bottomEnd => 'Bottom End', + _ => option.toString(), + }, + description: 'Alignment of indicator relative to child (directional for RTL support).', + ); - final offsetX = context.knobs.double.slider( - label: 'Offset X', - min: -10, - max: 10, - description: 'Horizontal offset for fine-tuning position.', - ); + final offsetX = context.knobs.double.slider( + label: 'Offset X', + min: -10, + max: 10, + description: 'Horizontal offset for fine-tuning position.', + ); - final offsetY = context.knobs.double.slider( - label: 'Offset Y', - min: -10, - max: 10, - description: 'Vertical offset for fine-tuning position.', - ); + final offsetY = context.knobs.double.slider( + label: 'Offset Y', + min: -10, + max: 10, + description: 'Vertical offset for fine-tuning position.', + ); - if (withChild) { return Center( child: StreamOnlineIndicator( isOnline: isOnline, diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index b4b3a60..b630dee 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -4,9 +4,12 @@ - Renamed `StreamInputTheme`/`StreamInputThemeData` to `StreamTextInputTheme`/`StreamTextInputThemeData` with a redesigned API - Renamed `StreamTheme.inputTheme` to `StreamTheme.textInputTheme` +- Removed `alignment` and `offset` from `StreamOnlineIndicatorThemeData` (these are layout concerns, not theme) ### ✨ Features +- Added `child`, `alignment`, and `offset` parameters to `StreamBadgeNotification` for badge-over-child positioning +- Added `child`, `alignment`, and `offset` parameters to `StreamBadgeCount` for badge-over-child positioning - Added `StreamSwitch` component with platform-aware styling and `style` prop - Added `StreamTextInput` component with configurable helper text, icons, and validation states - Added `StreamStepper` component for numeric value adjustment with customizable bounds and `style` prop diff --git a/packages/stream_core_flutter/lib/src/components/badge/stream_badge_count.dart b/packages/stream_core_flutter/lib/src/components/badge/stream_badge_count.dart index 5bcffc9..e79c0fb 100644 --- a/packages/stream_core_flutter/lib/src/components/badge/stream_badge_count.dart +++ b/packages/stream_core_flutter/lib/src/components/badge/stream_badge_count.dart @@ -13,6 +13,9 @@ import '../../theme/stream_theme_extensions.dart'; /// It's typically positioned on avatars, icons, or list items to indicate /// new messages, notifications, overflow counts, or other information. /// +/// When [child] is provided, the badge is automatically positioned relative +/// to the child using a [Stack], similar to Flutter's [Badge] widget. +/// /// The badge automatically handles: /// - Adapting width based on the label length /// - Consistent styling across size variants @@ -20,7 +23,7 @@ import '../../theme/stream_theme_extensions.dart'; /// /// {@tool snippet} /// -/// Basic usage with a count: +/// Basic usage (standalone badge): /// /// ```dart /// StreamBadgeCount(label: '5') @@ -29,18 +32,26 @@ import '../../theme/stream_theme_extensions.dart'; /// /// {@tool snippet} /// -/// Positioned on an avatar: +/// With a child widget (automatically positioned): +/// +/// ```dart +/// StreamBadgeCount( +/// label: '3', +/// child: StreamAvatar(placeholder: (context) => Text('AB')), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Custom positioning: /// /// ```dart -/// Stack( -/// children: [ -/// StreamAvatar(placeholder: (context) => Text('AB')), -/// Positioned( -/// right: 0, -/// top: 0, -/// child: StreamBadgeCount(label: '3'), -/// ), -/// ], +/// StreamBadgeCount( +/// label: '3', +/// alignment: Alignment.topRight, +/// offset: Offset(2, -2), +/// child: StreamAvatar(placeholder: (context) => Text('AB')), /// ) /// ``` /// {@end-tool} @@ -70,11 +81,24 @@ import '../../theme/stream_theme_extensions.dart'; /// * [StreamAvatar], which often displays this badge. class StreamBadgeCount extends StatelessWidget { /// Creates a badge count indicator. + /// + /// If [child] is provided, the badge is automatically positioned relative + /// to the child using a [Stack], similar to Flutter's [Badge] widget. + /// Use [alignment] and [offset] to fine-tune placement. StreamBadgeCount({ super.key, StreamBadgeCountSize? size, required String label, - }) : props = .new(size: size, label: label); + Widget? child, + AlignmentGeometry? alignment, + Offset? offset, + }) : props = .new( + size: size, + label: label, + child: child, + alignment: alignment, + offset: offset, + ); /// The properties that configure this badge count. final StreamBadgeCountProps props; @@ -101,6 +125,9 @@ class StreamBadgeCountProps { const StreamBadgeCountProps({ this.size, required this.label, + this.child, + this.alignment, + this.offset, }); /// The text label to display in the badge. @@ -114,6 +141,24 @@ class StreamBadgeCountProps { /// If null, uses [StreamBadgeCountThemeData.size], or falls back to /// [StreamBadgeCountSize.xs]. final StreamBadgeCountSize? size; + + /// The widget below this widget in the tree. + /// + /// When provided, the badge is positioned relative to this child + /// using a [Stack]. When null, only the badge is displayed. + final Widget? child; + + /// The alignment of the badge relative to [child]. + /// + /// Only used when [child] is provided. + /// Defaults to [AlignmentDirectional.topEnd]. + final AlignmentGeometry? alignment; + + /// The offset for fine-tuning badge position. + /// + /// Applied after [alignment] to adjust the badge's final position. + /// Defaults to [Offset.zero]. + final Offset? offset; } /// The default implementation of [StreamBadgeCount]. @@ -149,7 +194,7 @@ class DefaultStreamBadgeCount extends StatelessWidget { final padding = _paddingForSize(effectiveSize, spacing); final textStyle = _textStyleForSize(effectiveSize, textTheme).copyWith(color: effectiveTextColor); - return IntrinsicWidth( + final badge = IntrinsicWidth( child: AnimatedContainer( height: effectiveSize.value, constraints: BoxConstraints(minWidth: effectiveSize.value), @@ -173,6 +218,29 @@ class DefaultStreamBadgeCount extends StatelessWidget { ), ), ); + + // If no child, just return the badge. + if (props.child == null) return badge; + + // Otherwise, wrap in Stack like Badge. + final effectiveAlignment = props.alignment ?? AlignmentDirectional.topEnd; + final effectiveOffset = props.offset ?? Offset.zero; + + return Stack( + clipBehavior: Clip.none, + children: [ + props.child!, + Positioned.fill( + child: Align( + alignment: effectiveAlignment, + child: Transform.translate( + offset: effectiveOffset, + child: badge, + ), + ), + ), + ], + ); } // Returns the appropriate text style for the given badge size. diff --git a/packages/stream_core_flutter/lib/src/components/badge/stream_badge_notification.dart b/packages/stream_core_flutter/lib/src/components/badge/stream_badge_notification.dart index aed5006..773f523 100644 --- a/packages/stream_core_flutter/lib/src/components/badge/stream_badge_notification.dart +++ b/packages/stream_core_flutter/lib/src/components/badge/stream_badge_notification.dart @@ -16,6 +16,9 @@ import '../../theme/stream_theme_extensions.dart'; /// Unlike [StreamBadgeCount], which uses neutral colors, this badge uses /// prominent colored backgrounds (primary, error, neutral) to draw attention. /// +/// When [child] is provided, the badge is automatically positioned relative +/// to the child using a [Stack], similar to Flutter's [Badge] widget. +/// /// The badge has three visual types controlled by /// [StreamBadgeNotificationType]: /// @@ -25,7 +28,7 @@ import '../../theme/stream_theme_extensions.dart'; /// /// {@tool snippet} /// -/// Basic usage with unread count: +/// Basic usage (standalone badge): /// /// ```dart /// StreamBadgeNotification(label: '3') @@ -34,6 +37,32 @@ import '../../theme/stream_theme_extensions.dart'; /// /// {@tool snippet} /// +/// With a child widget (automatically positioned): +/// +/// ```dart +/// StreamBadgeNotification( +/// label: '5', +/// child: Icon(Icons.chat_bubble_outline), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Custom positioning: +/// +/// ```dart +/// StreamBadgeNotification( +/// label: '3', +/// alignment: Alignment.topRight, +/// offset: Offset(2, -2), +/// child: StreamAvatar(placeholder: (context) => Text('AB')), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// /// Error variant: /// /// ```dart @@ -58,15 +87,25 @@ import '../../theme/stream_theme_extensions.dart'; /// * [StreamBadgeCount], a neutral count badge without colored backgrounds. class StreamBadgeNotification extends StatelessWidget { /// Creates a badge notification indicator. + /// + /// If [child] is provided, the badge is automatically positioned relative + /// to the child using a [Stack], similar to Flutter's [Badge] widget. + /// Use [alignment] and [offset] to fine-tune placement. StreamBadgeNotification({ super.key, StreamBadgeNotificationType? type, StreamBadgeNotificationSize? size, required String label, + Widget? child, + AlignmentGeometry? alignment, + Offset? offset, }) : props = StreamBadgeNotificationProps( type: type, size: size, label: label, + child: child, + alignment: alignment, + offset: offset, ); /// The properties that configure this badge notification. @@ -95,6 +134,9 @@ class StreamBadgeNotificationProps { this.type, this.size, required this.label, + this.child, + this.alignment, + this.offset, }); /// The visual type determining the badge background color. @@ -113,6 +155,24 @@ class StreamBadgeNotificationProps { /// Typically a numeric count (e.g., "5") or an overflow indicator /// (e.g., "99+"). final String label; + + /// The widget below this widget in the tree. + /// + /// When provided, the badge is positioned relative to this child + /// using a [Stack]. When null, only the badge is displayed. + final Widget? child; + + /// The alignment of the badge relative to [child]. + /// + /// Only used when [child] is provided. + /// Defaults to [AlignmentDirectional.topEnd]. + final AlignmentGeometry? alignment; + + /// The offset for fine-tuning badge position. + /// + /// Applied after [alignment] to adjust the badge's final position. + /// Defaults to [Offset.zero]. + final Offset? offset; } /// The default implementation of [StreamBadgeNotification]. @@ -149,7 +209,7 @@ class DefaultStreamBadgeNotification extends StatelessWidget { final padding = _paddingForSize(effectiveSize, spacing); final textStyle = _textStyleForSize(effectiveSize, textTheme).copyWith(color: effectiveTextColor); - return IntrinsicWidth( + final badge = IntrinsicWidth( child: AnimatedContainer( height: effectiveSize.value, constraints: BoxConstraints(minWidth: effectiveSize.value), @@ -176,6 +236,29 @@ class DefaultStreamBadgeNotification extends StatelessWidget { ), ), ); + + // If no child, just return the badge. + if (props.child == null) return badge; + + // Otherwise, wrap in Stack like Badge. + final effectiveAlignment = props.alignment ?? AlignmentDirectional.topEnd; + final effectiveOffset = props.offset ?? Offset.zero; + + return Stack( + clipBehavior: Clip.none, + children: [ + props.child!, + Positioned.fill( + child: Align( + alignment: effectiveAlignment, + child: Transform.translate( + offset: effectiveOffset, + child: badge, + ), + ), + ), + ], + ); } Color _resolveBackgroundColor( diff --git a/packages/stream_core_flutter/lib/src/components/badge/stream_online_indicator.dart b/packages/stream_core_flutter/lib/src/components/badge/stream_online_indicator.dart index 563c5da..64a2688 100644 --- a/packages/stream_core_flutter/lib/src/components/badge/stream_online_indicator.dart +++ b/packages/stream_core_flutter/lib/src/components/badge/stream_online_indicator.dart @@ -191,8 +191,8 @@ class DefaultStreamOnlineIndicator extends StatelessWidget { if (props.child == null) return indicator; // Otherwise, wrap in Stack like Badge. - final effectiveAlignment = props.alignment ?? onlineIndicatorTheme.alignment ?? defaults.alignment; - final effectiveOffset = props.offset ?? onlineIndicatorTheme.offset ?? defaults.offset; + final effectiveAlignment = props.alignment ?? AlignmentDirectional.topEnd; + final effectiveOffset = props.offset ?? Offset.zero; return Stack( clipBehavior: Clip.none, @@ -241,10 +241,4 @@ class _StreamOnlineIndicatorThemeDefaults extends StreamOnlineIndicatorThemeData @override Color get borderColor => _colorScheme.borderOnInverse; - - @override - AlignmentGeometry get alignment => AlignmentDirectional.topEnd; - - @override - Offset get offset => Offset.zero; } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart index b273e9c..145abfb 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart @@ -127,8 +127,6 @@ class StreamOnlineIndicatorThemeData with _$StreamOnlineIndicatorThemeData { this.backgroundOnline, this.backgroundOffline, this.borderColor, - this.alignment, - this.offset, }); /// The default size for online indicators. @@ -152,18 +150,6 @@ class StreamOnlineIndicatorThemeData with _$StreamOnlineIndicatorThemeData { /// the avatar. final Color? borderColor; - /// The alignment of the indicator relative to the child widget. - /// - /// Only used when [StreamOnlineIndicator.child] is provided. - /// Falls back to [AlignmentDirectional.topEnd]. - final AlignmentGeometry? alignment; - - /// The offset for fine-tuning indicator position. - /// - /// Applied after alignment to adjust the indicator's final position. - /// Falls back to [Offset.zero]. - final Offset? offset; - /// Linearly interpolate between two [StreamOnlineIndicatorThemeData] objects. static StreamOnlineIndicatorThemeData? lerp( StreamOnlineIndicatorThemeData? a, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart index a2b96d9..648cc59 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart @@ -38,8 +38,6 @@ mixin _$StreamOnlineIndicatorThemeData { t, ), borderColor: Color.lerp(a.borderColor, b.borderColor, t), - alignment: AlignmentGeometry.lerp(a.alignment, b.alignment, t), - offset: Offset.lerp(a.offset, b.offset, t), ); } @@ -48,8 +46,6 @@ mixin _$StreamOnlineIndicatorThemeData { Color? backgroundOnline, Color? backgroundOffline, Color? borderColor, - AlignmentGeometry? alignment, - Offset? offset, }) { final _this = (this as StreamOnlineIndicatorThemeData); @@ -58,8 +54,6 @@ mixin _$StreamOnlineIndicatorThemeData { backgroundOnline: backgroundOnline ?? _this.backgroundOnline, backgroundOffline: backgroundOffline ?? _this.backgroundOffline, borderColor: borderColor ?? _this.borderColor, - alignment: alignment ?? _this.alignment, - offset: offset ?? _this.offset, ); } @@ -79,8 +73,6 @@ mixin _$StreamOnlineIndicatorThemeData { backgroundOnline: other.backgroundOnline, backgroundOffline: other.backgroundOffline, borderColor: other.borderColor, - alignment: other.alignment, - offset: other.offset, ); } @@ -100,9 +92,7 @@ mixin _$StreamOnlineIndicatorThemeData { return _other.size == _this.size && _other.backgroundOnline == _this.backgroundOnline && _other.backgroundOffline == _this.backgroundOffline && - _other.borderColor == _this.borderColor && - _other.alignment == _this.alignment && - _other.offset == _this.offset; + _other.borderColor == _this.borderColor; } @override @@ -115,8 +105,6 @@ mixin _$StreamOnlineIndicatorThemeData { _this.backgroundOnline, _this.backgroundOffline, _this.borderColor, - _this.alignment, - _this.offset, ); } }