From 226389ad533276d8daf2e03b90ed4cb3bfeecde3 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 19 Mar 2026 20:59:34 +0530 Subject: [PATCH 1/9] feat(ui): add StreamSkeletonLoading and StreamSkeletonBox components Add shimmer-based skeleton loading components using the `shimmer` package. StreamSkeletonLoading wraps children with an animated shimmer effect, while StreamSkeletonBox provides shaped placeholder boxes (rectangle or circular). - Add StreamSkeletonLoadingThemeData for theming base/highlight colors and period - Derive shimmer direction from ambient Directionality (LTR/RTL) - Use brightness-aware defaults (backgroundSurfaceStrong base, white20 highlight for light, chrome[0] for dark) instead of Figma overlay tokens - Remove unused skeletonLoadingBase/Highlight from StreamColorScheme and tokens - Add StreamSkeletonBox with .circular factory constructor - Add Widgetbook playground, showcase (message, list, image, enabled/disabled) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/app/gallery_app.directories.g.dart | 19 + .../common/stream_skeleton_loading.dart | 498 ++++++++++++++++++ melos.yaml | 4 + .../lib/src/components.dart | 1 + .../common/stream_skeleton_loading.dart | 279 ++++++++++ .../src/factory/stream_component_factory.dart | 8 + .../stream_component_factory.g.theme.dart | 8 +- .../stream_skeleton_loading_theme.dart | 120 +++++ ...stream_skeleton_loading_theme.g.theme.dart | 100 ++++ .../primitives/tokens/dark/stream_tokens.dart | 2 - .../tokens/light/stream_tokens.dart | 2 - .../theme/semantics/stream_color_scheme.dart | 29 - .../stream_color_scheme.g.theme.dart | 21 - .../lib/src/theme/stream_theme.dart | 9 + .../lib/src/theme/stream_theme.g.theme.dart | 11 +- .../src/theme/stream_theme_extensions.dart | 4 + packages/stream_core_flutter/pubspec.yaml | 1 + 17 files changed, 1060 insertions(+), 56 deletions(-) create mode 100644 apps/design_system_gallery/lib/components/common/stream_skeleton_loading.dart create mode 100644 packages/stream_core_flutter/lib/src/components/common/stream_skeleton_loading.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_skeleton_loading_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_skeleton_loading_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 6cda8a02..681c566f 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 @@ -40,6 +40,8 @@ import 'package:design_system_gallery/components/common/stream_loading_spinner.d as _design_system_gallery_components_common_stream_loading_spinner; import 'package:design_system_gallery/components/common/stream_progress_bar.dart' as _design_system_gallery_components_common_stream_progress_bar; +import 'package:design_system_gallery/components/common/stream_skeleton_loading.dart' + as _design_system_gallery_components_common_stream_skeleton_loading; 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/controls/stream_command_chip.dart' @@ -471,6 +473,23 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamSkeletonLoading', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_common_stream_skeleton_loading + .buildStreamSkeletonLoadingPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_common_stream_skeleton_loading + .buildStreamSkeletonLoadingShowcase, + ), + ], + ), ], ), _widgetbook.WidgetbookFolder( diff --git a/apps/design_system_gallery/lib/components/common/stream_skeleton_loading.dart b/apps/design_system_gallery/lib/components/common/stream_skeleton_loading.dart new file mode 100644 index 00000000..4e842ca5 --- /dev/null +++ b/apps/design_system_gallery/lib/components/common/stream_skeleton_loading.dart @@ -0,0 +1,498 @@ +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: StreamSkeletonLoading, + path: '[Components]/Common', +) +Widget buildStreamSkeletonLoadingPlayground(BuildContext context) { + final enabled = context.knobs.boolean( + label: 'Enabled', + initialValue: true, + description: 'Whether the shimmer animation is running.', + ); + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: StreamSkeletonLoading( + enabled: enabled, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + text row + Row( + children: [ + const StreamSkeletonBox.circular(radius: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamSkeletonBox(width: 120, height: 12, borderRadius: BorderRadius.circular(4)), + const SizedBox(height: 8), + StreamSkeletonBox(width: 80, height: 10, borderRadius: BorderRadius.circular(4)), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + // Content lines + StreamSkeletonBox( + width: double.infinity, + height: 12, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(height: 8), + StreamSkeletonBox( + width: double.infinity, + height: 12, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(height: 8), + StreamSkeletonBox(width: 200, height: 12, borderRadius: BorderRadius.circular(4)), + ], + ), + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamSkeletonLoading, + path: '[Components]/Common', +) +Widget buildStreamSkeletonLoadingShowcase(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 _MessagePlaceholderSection(), + SizedBox(height: spacing.xl), + const _ListPlaceholderSection(), + SizedBox(height: spacing.xl), + const _ImagePlaceholderSection(), + SizedBox(height: spacing.xl), + const _EnabledStateSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Message Placeholder Section +// ============================================================================= + +class _MessagePlaceholderSection extends StatelessWidget { + const _MessagePlaceholderSection(); + + @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: 'MESSAGE PLACEHOLDER'), + 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( + 'Simulates a loading message bubble', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + StreamSkeletonLoading( + child: Row( + children: [ + const StreamSkeletonBox.circular(radius: 18), + SizedBox(width: spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamSkeletonBox(width: 100, height: 10, borderRadius: BorderRadius.circular(4)), + const SizedBox(height: 8), + StreamSkeletonBox( + width: double.infinity, + height: 10, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(height: 8), + StreamSkeletonBox(width: 160, height: 10, borderRadius: BorderRadius.circular(4)), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// List Placeholder Section +// ============================================================================= + +class _ListPlaceholderSection extends StatelessWidget { + const _ListPlaceholderSection(); + + @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: 'LIST PLACEHOLDER'), + 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( + 'Simulates a loading list of items', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + StreamSkeletonLoading( + child: Column( + children: [ + for (var i = 0; i < 3; i++) ...[ + if (i > 0) SizedBox(height: spacing.md), + Row( + children: [ + const StreamSkeletonBox.circular(radius: 22), + SizedBox(width: spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamSkeletonBox( + width: [140.0, 100.0, 120.0][i], + height: 12, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(height: 6), + StreamSkeletonBox( + width: double.infinity, + height: 10, + borderRadius: BorderRadius.circular(4), + ), + ], + ), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Image Placeholder Section +// ============================================================================= + +class _ImagePlaceholderSection extends StatelessWidget { + const _ImagePlaceholderSection(); + + @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: 'IMAGE PLACEHOLDER'), + 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( + 'Simulates an image gallery loading state', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + StreamSkeletonLoading( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Large hero image placeholder + StreamSkeletonBox( + width: double.infinity, + height: 180, + borderRadius: BorderRadius.all(radius.md), + ), + SizedBox(height: spacing.sm), + // Thumbnail row + Row( + children: [ + Expanded( + child: StreamSkeletonBox( + height: 80, + borderRadius: BorderRadius.all(radius.sm), + ), + ), + SizedBox(width: spacing.sm), + Expanded( + child: StreamSkeletonBox( + height: 80, + borderRadius: BorderRadius.all(radius.sm), + ), + ), + SizedBox(width: spacing.sm), + Expanded( + child: StreamSkeletonBox( + height: 80, + borderRadius: BorderRadius.all(radius.sm), + ), + ), + ], + ), + SizedBox(height: spacing.md), + // Caption lines below images + StreamSkeletonBox( + width: 160, + height: 12, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(height: 6), + StreamSkeletonBox( + width: 100, + height: 10, + borderRadius: BorderRadius.circular(4), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Enabled State Section +// ============================================================================= + +class _EnabledStateSection extends StatelessWidget { + const _EnabledStateSection(); + + @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: 'ENABLED VS DISABLED'), + 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( + 'Animation can be paused with enabled: false', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Row( + children: [ + const Expanded( + child: _EnabledDemo(label: 'Enabled', enabled: true), + ), + SizedBox(width: spacing.md), + const Expanded( + child: _EnabledDemo(label: 'Disabled', enabled: false), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +class _EnabledDemo extends StatelessWidget { + const _EnabledDemo({required this.label, required this.enabled}); + + final String label; + final bool enabled; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + StreamSkeletonLoading( + enabled: enabled, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamSkeletonBox(width: double.infinity, height: 12, borderRadius: BorderRadius.circular(4)), + const SizedBox(height: 8), + StreamSkeletonBox(width: 100, height: 12, borderRadius: BorderRadius.circular(4)), + ], + ), + ), + SizedBox(height: spacing.sm), + Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + ], + ); + } +} + +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/melos.yaml b/melos.yaml index 1ba84374..5152c275 100644 --- a/melos.yaml +++ b/melos.yaml @@ -23,14 +23,17 @@ command: cross_file: ^0.3.4+2 dio: ^5.8.0+1 equatable: ^2.0.7 + flutter_markdown_plus: ^1.0.7 flutter_svg: ^2.2.3 http_parser: ^4.1.2 intl: ">=0.19.0 <=0.21.0" jose: ^0.3.4 json_annotation: ^4.9.0 + markdown: ^7.3.0 meta: ^1.15.0 mime: ^2.0.0 rxdart: ^0.28.0 + shimmer: ^3.0.0 stream_core: ^0.4.0 svg_icon_widget: ^0.0.1+1 synchronized: ^3.3.0 @@ -42,6 +45,7 @@ command: # List of all the dev_dependencies used in the project. dev_dependencies: + alchemist: ^0.13.0 build_runner: ^2.10.5 json_serializable: ^6.9.5 melos: ^6.2.0 diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index fc71b9c9..ee89ddb0 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -14,6 +14,7 @@ export 'components/common/stream_checkbox.dart' hide DefaultStreamCheckbox; export 'components/common/stream_flex.dart'; export 'components/common/stream_loading_spinner.dart' hide DefaultStreamLoadingSpinner; export 'components/common/stream_progress_bar.dart' hide DefaultStreamProgressBar; +export 'components/common/stream_skeleton_loading.dart' hide DefaultStreamSkeletonLoading; export 'components/common/stream_visibility.dart'; export 'components/context_menu/stream_context_menu.dart'; export 'components/context_menu/stream_context_menu_action.dart' hide DefaultStreamContextMenuAction; diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_skeleton_loading.dart b/packages/stream_core_flutter/lib/src/components/common/stream_skeleton_loading.dart new file mode 100644 index 00000000..beafc905 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/common/stream_skeleton_loading.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_skeleton_loading_theme.dart'; +import '../../theme/primitives/stream_colors.dart'; +import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/stream_theme_extensions.dart'; + +/// A skeleton shimmer loading component. +/// +/// [StreamSkeletonLoading] displays a shimmer animation effect over its +/// [child], typically used as a placeholder while content is loading. +/// +/// The shimmer colors, animation period, and direction can be customized +/// via direct properties or through [StreamSkeletonLoadingTheme]. +/// +/// {@tool snippet} +/// +/// Basic usage with a placeholder container: +/// +/// ```dart +/// StreamSkeletonLoading( +/// child: Container( +/// width: double.infinity, +/// height: 20, +/// decoration: BoxDecoration( +/// color: Colors.white, +/// borderRadius: BorderRadius.circular(4), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamSkeletonLoadingTheme], for theming skeleton widgets in a subtree. +/// * [StreamSkeletonLoadingThemeData], which describes the skeleton theme. +class StreamSkeletonLoading extends StatelessWidget { + /// Creates a [StreamSkeletonLoading]. + StreamSkeletonLoading({ + super.key, + Color? baseColor, + Color? highlightColor, + Duration? period, + ShimmerDirection? direction, + bool enabled = true, + required Widget child, + }) : props = .new( + baseColor: baseColor, + highlightColor: highlightColor, + period: period, + direction: direction, + enabled: enabled, + child: child, + ); + + /// The props controlling the appearance of this skeleton loading. + final StreamSkeletonLoadingProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).skeletonLoading; + if (builder != null) return builder(context, props); + return DefaultStreamSkeletonLoading(props: props); + } +} + +/// Properties for configuring a [StreamSkeletonLoading]. +/// +/// This class holds all the configuration options for a skeleton shimmer, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamSkeletonLoading], which uses these properties. +/// * [DefaultStreamSkeletonLoading], the default implementation. +class StreamSkeletonLoadingProps { + /// Creates properties for a skeleton shimmer. + const StreamSkeletonLoadingProps({ + this.enabled = true, + this.baseColor, + this.highlightColor, + this.period, + this.direction, + required this.child, + }); + + /// Whether the shimmer animation is running. + /// + /// Defaults to true. + final bool enabled; + + /// The base color of the shimmer effect. + /// + /// If null, uses the theme's base color. + final Color? baseColor; + + /// The highlight color of the shimmer effect. + /// + /// If null, uses the theme's highlight color. + final Color? highlightColor; + + /// The duration of one shimmer animation cycle. + /// + /// If null, uses the theme's period. + final Duration? period; + + /// The direction of the shimmer animation. + /// + /// If null, follows the ambient [Directionality]. + final ShimmerDirection? direction; + + /// The widget to display the shimmer effect over. + final Widget child; +} + +/// Default implementation of [StreamSkeletonLoading]. +/// +/// Renders a shimmer effect using [Shimmer.fromColors] from the `shimmer` +/// package. Styling is resolved from widget props, the current +/// [StreamSkeletonLoadingTheme], and falls back to [StreamColorScheme] tokens. +/// +/// See also: +/// +/// * [StreamSkeletonLoading], the public API widget. +class DefaultStreamSkeletonLoading extends StatelessWidget { + /// Creates a default Stream skeleton loading shimmer. + const DefaultStreamSkeletonLoading({super.key, required this.props}); + + /// The props controlling the appearance of this skeleton loading. + final StreamSkeletonLoadingProps props; + + @override + Widget build(BuildContext context) { + final theme = context.streamSkeletonLoadingTheme; + final defaults = _StreamSkeletonLoadingThemeDefaults(context); + + final effectiveBaseColor = props.baseColor ?? theme.baseColor ?? defaults.baseColor; + final effectiveHighlightColor = props.highlightColor ?? theme.highlightColor ?? defaults.highlightColor; + final effectivePeriod = props.period ?? theme.period ?? defaults.period; + + final textDirection = Directionality.of(context); + final defaultDirection = textDirection == .ltr ? ShimmerDirection.ltr : ShimmerDirection.rtl; + final effectiveDirection = props.direction ?? defaultDirection; + + return Shimmer.fromColors( + enabled: props.enabled, + baseColor: effectiveBaseColor, + highlightColor: effectiveHighlightColor, + period: effectivePeriod, + direction: effectiveDirection, + child: props.child, + ); + } +} + +/// A simple shaped placeholder box intended for use inside a +/// [StreamSkeletonLoading]. +/// +/// Renders a filled rectangle (or circle) using +/// [StreamColorScheme.backgroundSurfaceStrong] as its default color. +/// +/// {@tool snippet} +/// +/// Compose with [StreamSkeletonLoading] to create loading placeholders: +/// +/// ```dart +/// StreamSkeletonLoading( +/// child: Column( +/// children: [ +/// StreamSkeletonBox(width: double.infinity, height: 16), +/// SizedBox(height: 8), +/// StreamSkeletonBox(width: 120, height: 16), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamSkeletonLoading], which wraps children with a shimmer effect. +class StreamSkeletonBox extends StatelessWidget { + /// Creates a skeleton placeholder box. + const StreamSkeletonBox({ + super.key, + required this.height, + this.width, + this.borderRadius, + this.shape = .rectangle, + this.color, + }); + + /// Creates a circular skeleton placeholder. + /// + /// The resulting box has a diameter of `radius * 2`. + const StreamSkeletonBox.circular({ + super.key, + required double radius, + this.color, + }) : shape = .circle, + width = radius * 2, + height = radius * 2, + borderRadius = null; + + /// The height of the placeholder. + final double height; + + /// The width of the placeholder. + /// + /// If null, the box sizes itself based on incoming constraints. + final double? width; + + /// The border radius of the placeholder. + /// + /// Ignored when [shape] is [BoxShape.circle]. + final BorderRadiusGeometry? borderRadius; + + /// The shape of the placeholder. + /// + /// Defaults to [BoxShape.rectangle]. + final BoxShape shape; + + /// The fill color of the placeholder. + /// + /// If null, uses [StreamColorScheme.backgroundSurfaceStrong]. + final Color? color; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final effectiveColor = color ?? colorScheme.backgroundSurfaceStrong; + + return Container( + width: width, + height: height, + decoration: BoxDecoration( + shape: shape, + color: effectiveColor, + borderRadius: borderRadius, + ), + ); + } +} + +// Provides default values for [StreamSkeletonLoadingThemeData] based on the +// current [StreamColorScheme]. +class _StreamSkeletonLoadingThemeDefaults extends StreamSkeletonLoadingThemeData { + _StreamSkeletonLoadingThemeDefaults(this.context); + + final BuildContext context; + + late final StreamColorScheme _colorScheme = context.streamColorScheme; + + // The shimmer package uses BlendMode.srcATop which replaces child pixels + // with the gradient. The highlight color is brightness-aware: light mode + // uses a semi-transparent white overlay, dark mode uses the deepest chrome + // shade to create a subtle darkening sweep. + // + // NOTE: Component theme defaults should generally avoid depending on + // [Theme.brightnessOf] and instead rely solely on [StreamColorScheme] + // semantics. This is an exception because the chrome surface hierarchy + // inverts between light and dark modes, so no single semantic color pair + // produces the correct base→highlight contrast in both brightnesses. + + @override + Color get baseColor => _colorScheme.backgroundSurfaceStrong; + + @override + Color get highlightColor { + final brightness = Theme.brightnessOf(context); + return brightness == .light ? StreamColors.white20 : _colorScheme.chrome[0]!; + } + + @override + Duration get period => const Duration(milliseconds: 1500); +} 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 42381e62..421c3982 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 @@ -157,6 +157,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? progressBar, StreamComponentBuilder? reactionPicker, StreamComponentBuilder? reactions, + StreamComponentBuilder? skeletonLoading, Iterable>? extensions, }) { extensions ??= >[]; @@ -188,6 +189,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { progressBar: progressBar, reactionPicker: reactionPicker, reactions: reactions, + skeletonLoading: skeletonLoading, extensions: _extensionIterableToMap(extensions), ); } @@ -220,6 +222,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.progressBar, required this.reactionPicker, required this.reactions, + required this.skeletonLoading, required this.extensions, }); @@ -373,6 +376,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamReactions] uses [DefaultStreamReactions]. final StreamComponentBuilder? reactions; + /// Custom builder for skeleton loading shimmer widgets. + /// + /// When null, [StreamSkeletonLoading] uses [DefaultStreamSkeletonLoading]. + final StreamComponentBuilder? skeletonLoading; + // Convert the [extensionsIterable] passed to [StreamComponentBuilders.new] // to the stored [extensions] map, where each entry's key consists of the extension's type. static Map> _extensionIterableToMap( 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 4dd9b244..9bd03e9a 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 @@ -57,6 +57,7 @@ mixin _$StreamComponentBuilders { progressBar: t < 0.5 ? a.progressBar : b.progressBar, reactionPicker: t < 0.5 ? a.reactionPicker : b.reactionPicker, reactions: t < 0.5 ? a.reactions : b.reactions, + skeletonLoading: t < 0.5 ? a.skeletonLoading : b.skeletonLoading, ); } @@ -92,6 +93,7 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamProgressBarProps)? progressBar, Widget Function(BuildContext, StreamReactionPickerProps)? reactionPicker, Widget Function(BuildContext, StreamReactionsProps)? reactions, + Widget Function(BuildContext, StreamSkeletonLoadingProps)? skeletonLoading, }) { final _this = (this as StreamComponentBuilders); @@ -123,6 +125,7 @@ mixin _$StreamComponentBuilders { progressBar: progressBar ?? _this.progressBar, reactionPicker: reactionPicker ?? _this.reactionPicker, reactions: reactions ?? _this.reactions, + skeletonLoading: skeletonLoading ?? _this.skeletonLoading, ); } @@ -165,6 +168,7 @@ mixin _$StreamComponentBuilders { progressBar: other.progressBar, reactionPicker: other.reactionPicker, reactions: other.reactions, + skeletonLoading: other.skeletonLoading, ); } @@ -207,7 +211,8 @@ mixin _$StreamComponentBuilders { _other.onlineIndicator == _this.onlineIndicator && _other.progressBar == _this.progressBar && _other.reactionPicker == _this.reactionPicker && - _other.reactions == _this.reactions; + _other.reactions == _this.reactions && + _other.skeletonLoading == _this.skeletonLoading; } @override @@ -243,6 +248,7 @@ mixin _$StreamComponentBuilders { _this.progressBar, _this.reactionPicker, _this.reactions, + _this.skeletonLoading, ]); } } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_skeleton_loading_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_skeleton_loading_theme.dart new file mode 100644 index 00000000..76e5a44f --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_skeleton_loading_theme.dart @@ -0,0 +1,120 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_skeleton_loading_theme.g.theme.dart'; + +/// Applies a skeleton loading theme to descendant [StreamSkeletonLoading] +/// widgets. +/// +/// Wrap a subtree with [StreamSkeletonLoadingTheme] to override skeleton +/// shimmer styling. Access the merged theme using +/// [BuildContext.streamSkeletonLoadingTheme]. +/// +/// {@tool snippet} +/// +/// Override skeleton colors for a specific section: +/// +/// ```dart +/// StreamSkeletonLoadingTheme( +/// data: StreamSkeletonLoadingThemeData( +/// baseColor: Colors.grey.shade300, +/// highlightColor: Colors.grey.shade100, +/// ), +/// child: Column( +/// children: [ +/// StreamSkeletonLoading(child: Container(height: 20)), +/// StreamSkeletonLoading(child: Container(height: 40)), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamSkeletonLoadingThemeData], which describes the skeleton theme. +/// * [StreamSkeletonLoading], the widget affected by this theme. +class StreamSkeletonLoadingTheme extends InheritedTheme { + /// Creates a skeleton loading theme that controls descendant skeleton + /// widgets. + const StreamSkeletonLoadingTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The skeleton loading theme data for descendant widgets. + final StreamSkeletonLoadingThemeData data; + + /// Returns the [StreamSkeletonLoadingThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamSkeletonLoadingTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + static StreamSkeletonLoadingThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).skeletonLoadingTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamSkeletonLoadingTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamSkeletonLoadingTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamSkeletonLoading] widgets. +/// +/// {@tool snippet} +/// +/// Customize skeleton loading appearance globally: +/// +/// ```dart +/// StreamTheme( +/// skeletonLoadingTheme: StreamSkeletonLoadingThemeData( +/// baseColor: Colors.grey.shade300, +/// highlightColor: Colors.grey.shade100, +/// period: Duration(milliseconds: 1200), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamSkeletonLoading], the widget that uses this theme data. +/// * [StreamSkeletonLoadingTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamSkeletonLoadingThemeData with _$StreamSkeletonLoadingThemeData { + /// Creates skeleton loading theme data with optional style overrides. + const StreamSkeletonLoadingThemeData({ + this.baseColor, + this.highlightColor, + this.period, + }); + + /// The base color of the shimmer effect. + /// + /// Falls back to [StreamColorScheme.backgroundSurfaceStrong]. + final Color? baseColor; + + /// The highlight color of the shimmer effect. + final Color? highlightColor; + + /// The duration of one shimmer animation cycle. + /// + /// Falls back to 1500 milliseconds. + final Duration? period; + + /// Linearly interpolate between two [StreamSkeletonLoadingThemeData] objects. + static StreamSkeletonLoadingThemeData? lerp( + StreamSkeletonLoadingThemeData? a, + StreamSkeletonLoadingThemeData? b, + double t, + ) => _$StreamSkeletonLoadingThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_skeleton_loading_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_skeleton_loading_theme.g.theme.dart new file mode 100644 index 00000000..8f86d599 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_skeleton_loading_theme.g.theme.dart @@ -0,0 +1,100 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_skeleton_loading_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamSkeletonLoadingThemeData { + bool get canMerge => true; + + static StreamSkeletonLoadingThemeData? lerp( + StreamSkeletonLoadingThemeData? a, + StreamSkeletonLoadingThemeData? 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 StreamSkeletonLoadingThemeData( + baseColor: Color.lerp(a.baseColor, b.baseColor, t), + highlightColor: Color.lerp(a.highlightColor, b.highlightColor, t), + period: lerpDuration$(a.period, b.period, t), + ); + } + + StreamSkeletonLoadingThemeData copyWith({ + Color? baseColor, + Color? highlightColor, + Duration? period, + }) { + final _this = (this as StreamSkeletonLoadingThemeData); + + return StreamSkeletonLoadingThemeData( + baseColor: baseColor ?? _this.baseColor, + highlightColor: highlightColor ?? _this.highlightColor, + period: period ?? _this.period, + ); + } + + StreamSkeletonLoadingThemeData merge(StreamSkeletonLoadingThemeData? other) { + final _this = (this as StreamSkeletonLoadingThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + baseColor: other.baseColor, + highlightColor: other.highlightColor, + period: other.period, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamSkeletonLoadingThemeData); + final _other = (other as StreamSkeletonLoadingThemeData); + + return _other.baseColor == _this.baseColor && + _other.highlightColor == _this.highlightColor && + _other.period == _this.period; + } + + @override + int get hashCode { + final _this = (this as StreamSkeletonLoadingThemeData); + + return Object.hash( + runtimeType, + _this.baseColor, + _this.highlightColor, + _this.period, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/tokens/dark/stream_tokens.dart b/packages/stream_core_flutter/lib/src/theme/primitives/tokens/dark/stream_tokens.dart index 08652b9f..538bd62f 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/tokens/dark/stream_tokens.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/tokens/dark/stream_tokens.dart @@ -603,8 +603,6 @@ class StreamTokens { static const brand700 = Color(0xFFC3D9FF); static const brand800 = Color(0xFFE3EDFF); static const brand900 = Color(0xFFF3F7FF); - static const skeletonLoadingBase = Color(0x00FFFFFF); - static const skeletonLoadingHighlight = Color(0x33FFFFFF); static const chrome0 = Color(0xFF000000); static const chrome50 = Color(0xFF1C1C1C); static const chrome100 = Color(0xFF323232); diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/tokens/light/stream_tokens.dart b/packages/stream_core_flutter/lib/src/theme/primitives/tokens/light/stream_tokens.dart index ee07e22b..2b705d1c 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/tokens/light/stream_tokens.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/tokens/light/stream_tokens.dart @@ -603,8 +603,6 @@ class StreamTokens { static const brand700 = Color(0xFF19418D); static const brand800 = Color(0xFF142F63); static const brand900 = Color(0xFF091A3B); - static const skeletonLoadingBase = Color(0x00FFFFFF); - static const skeletonLoadingHighlight = Color(0xFFFFFFFF); static const chrome0 = Color(0xFFFFFFFF); static const chrome50 = Color(0xFFF6F8FA); static const chrome100 = Color(0xFFEBEEF1); diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart index 3a36477f..8a626927 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -110,9 +110,6 @@ class StreamColorScheme with _$StreamColorScheme { // System Color? systemText, Color? systemScrollbar, - // Skeleton Loading - Color? skeletonLoadingBase, - Color? skeletonLoadingHighlight, // Avatar List? avatarPalette, }) { @@ -189,10 +186,6 @@ class StreamColorScheme with _$StreamColorScheme { systemText ??= light_tokens.StreamTokens.systemText; systemScrollbar ??= light_tokens.StreamTokens.systemScrollbar; - // Skeleton Loading - skeletonLoadingBase ??= light_tokens.StreamTokens.skeletonLoadingBase; - skeletonLoadingHighlight ??= light_tokens.StreamTokens.skeletonLoadingHighlight; - // Avatar avatarPalette ??= [ StreamAvatarColorPair( @@ -273,8 +266,6 @@ class StreamColorScheme with _$StreamColorScheme { stateDisabled: stateDisabled, systemText: systemText, systemScrollbar: systemScrollbar, - skeletonLoadingBase: skeletonLoadingBase, - skeletonLoadingHighlight: skeletonLoadingHighlight, avatarPalette: avatarPalette, ); } @@ -345,9 +336,6 @@ class StreamColorScheme with _$StreamColorScheme { // System Color? systemText, Color? systemScrollbar, - // Skeleton Loading - Color? skeletonLoadingBase, - Color? skeletonLoadingHighlight, // Avatar List? avatarPalette, }) { @@ -424,10 +412,6 @@ class StreamColorScheme with _$StreamColorScheme { systemText ??= dark_tokens.StreamTokens.systemText; systemScrollbar ??= dark_tokens.StreamTokens.systemScrollbar; - // Skeleton Loading - skeletonLoadingBase ??= dark_tokens.StreamTokens.skeletonLoadingBase; - skeletonLoadingHighlight ??= dark_tokens.StreamTokens.skeletonLoadingHighlight; - // Avatar avatarPalette ??= [ StreamAvatarColorPair( @@ -508,8 +492,6 @@ class StreamColorScheme with _$StreamColorScheme { stateDisabled: stateDisabled, systemText: systemText, systemScrollbar: systemScrollbar, - skeletonLoadingBase: skeletonLoadingBase, - skeletonLoadingHighlight: skeletonLoadingHighlight, avatarPalette: avatarPalette, ); } @@ -578,9 +560,6 @@ class StreamColorScheme with _$StreamColorScheme { // System required this.systemText, required this.systemScrollbar, - // Skeleton Loading - required this.skeletonLoadingBase, - required this.skeletonLoadingHighlight, // Avatar required this.avatarPalette, }); @@ -768,14 +747,6 @@ class StreamColorScheme with _$StreamColorScheme { /// The system scrollbar color. final Color systemScrollbar; - // ---- Skeleton Loading colors ---- - - /// The base color for skeleton loading shimmer. - final Color skeletonLoadingBase; - - /// The highlight color for skeleton loading shimmer. - final Color skeletonLoadingHighlight; - // ---- Avatar colors ---- /// The color palette for generating avatar colors based on user identity. diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart index 3b62f652..c464c59d 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart @@ -153,16 +153,6 @@ mixin _$StreamColorScheme { stateDisabled: Color.lerp(a.stateDisabled, b.stateDisabled, t)!, systemText: Color.lerp(a.systemText, b.systemText, t)!, systemScrollbar: Color.lerp(a.systemScrollbar, b.systemScrollbar, t)!, - skeletonLoadingBase: Color.lerp( - a.skeletonLoadingBase, - b.skeletonLoadingBase, - t, - )!, - skeletonLoadingHighlight: Color.lerp( - a.skeletonLoadingHighlight, - b.skeletonLoadingHighlight, - t, - )!, avatarPalette: t < 0.5 ? a.avatarPalette : b.avatarPalette, ); } @@ -223,8 +213,6 @@ mixin _$StreamColorScheme { Color? stateDisabled, Color? systemText, Color? systemScrollbar, - Color? skeletonLoadingBase, - Color? skeletonLoadingHighlight, List? avatarPalette, }) { final _this = (this as StreamColorScheme); @@ -290,9 +278,6 @@ mixin _$StreamColorScheme { stateDisabled: stateDisabled ?? _this.stateDisabled, systemText: systemText ?? _this.systemText, systemScrollbar: systemScrollbar ?? _this.systemScrollbar, - skeletonLoadingBase: skeletonLoadingBase ?? _this.skeletonLoadingBase, - skeletonLoadingHighlight: - skeletonLoadingHighlight ?? _this.skeletonLoadingHighlight, avatarPalette: avatarPalette ?? _this.avatarPalette, ); } @@ -364,8 +349,6 @@ mixin _$StreamColorScheme { stateDisabled: other.stateDisabled, systemText: other.systemText, systemScrollbar: other.systemScrollbar, - skeletonLoadingBase: other.skeletonLoadingBase, - skeletonLoadingHighlight: other.skeletonLoadingHighlight, avatarPalette: other.avatarPalette, ); } @@ -438,8 +421,6 @@ mixin _$StreamColorScheme { _other.stateDisabled == _this.stateDisabled && _other.systemText == _this.systemText && _other.systemScrollbar == _this.systemScrollbar && - _other.skeletonLoadingBase == _this.skeletonLoadingBase && - _other.skeletonLoadingHighlight == _this.skeletonLoadingHighlight && _other.avatarPalette == _this.avatarPalette; } @@ -504,8 +485,6 @@ mixin _$StreamColorScheme { _this.stateDisabled, _this.systemText, _this.systemScrollbar, - _this.skeletonLoadingBase, - _this.skeletonLoadingHighlight, _this.avatarPalette, ]); } 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 04cffc6a..b8f2a820 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -23,6 +23,7 @@ import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; import 'components/stream_reaction_picker_theme.dart'; import 'components/stream_reactions_theme.dart'; +import 'components/stream_skeleton_loading_theme.dart'; import 'primitives/stream_icons.dart'; import 'primitives/stream_radius.dart'; import 'primitives/stream_spacing.dart'; @@ -116,6 +117,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamProgressBarThemeData? progressBarTheme, StreamReactionPickerThemeData? reactionPickerTheme, StreamReactionsThemeData? reactionsTheme, + StreamSkeletonLoadingThemeData? skeletonLoadingTheme, }) { platform ??= defaultTargetPlatform; final isDark = brightness == Brightness.dark; @@ -151,6 +153,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { progressBarTheme ??= const StreamProgressBarThemeData(); reactionPickerTheme ??= const StreamReactionPickerThemeData(); reactionsTheme ??= const StreamReactionsThemeData(); + skeletonLoadingTheme ??= const StreamSkeletonLoadingThemeData(); return .raw( brightness: brightness, @@ -180,6 +183,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { progressBarTheme: progressBarTheme, reactionPickerTheme: reactionPickerTheme, reactionsTheme: reactionsTheme, + skeletonLoadingTheme: skeletonLoadingTheme, ); } @@ -223,6 +227,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.progressBarTheme, required this.reactionPickerTheme, required this.reactionsTheme, + required this.skeletonLoadingTheme, }); /// Returns the [StreamTheme] from the closest [Theme] ancestor. @@ -342,6 +347,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The reaction theme for this theme. final StreamReactionsThemeData reactionsTheme; + /// The skeleton theme for this theme. + final StreamSkeletonLoadingThemeData skeletonLoadingTheme; + /// Creates a copy of this theme but with platform-dependent primitives /// recomputed for the given [platform]. /// @@ -390,6 +398,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { progressBarTheme: progressBarTheme, reactionPickerTheme: reactionPickerTheme, reactionsTheme: reactionsTheme, + skeletonLoadingTheme: skeletonLoadingTheme, ); } } 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 18c78cd7..cab1b2bd 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 @@ -39,6 +39,7 @@ mixin _$StreamTheme on ThemeExtension { StreamProgressBarThemeData? progressBarTheme, StreamReactionPickerThemeData? reactionPickerTheme, StreamReactionsThemeData? reactionsTheme, + StreamSkeletonLoadingThemeData? skeletonLoadingTheme, }) { final _this = (this as StreamTheme); @@ -72,6 +73,7 @@ mixin _$StreamTheme on ThemeExtension { progressBarTheme: progressBarTheme ?? _this.progressBarTheme, reactionPickerTheme: reactionPickerTheme ?? _this.reactionPickerTheme, reactionsTheme: reactionsTheme ?? _this.reactionsTheme, + skeletonLoadingTheme: skeletonLoadingTheme ?? _this.skeletonLoadingTheme, ); } @@ -183,6 +185,11 @@ mixin _$StreamTheme on ThemeExtension { other.reactionsTheme, t, )!, + skeletonLoadingTheme: StreamSkeletonLoadingThemeData.lerp( + _this.skeletonLoadingTheme, + other.skeletonLoadingTheme, + t, + )!, ); } @@ -225,7 +232,8 @@ mixin _$StreamTheme on ThemeExtension { _other.onlineIndicatorTheme == _this.onlineIndicatorTheme && _other.progressBarTheme == _this.progressBarTheme && _other.reactionPickerTheme == _this.reactionPickerTheme && - _other.reactionsTheme == _this.reactionsTheme; + _other.reactionsTheme == _this.reactionsTheme && + _other.skeletonLoadingTheme == _this.skeletonLoadingTheme; } @override @@ -261,6 +269,7 @@ mixin _$StreamTheme on ThemeExtension { _this.progressBarTheme, _this.reactionPickerTheme, _this.reactionsTheme, + _this.skeletonLoadingTheme, ]); } } 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 b3050e07..67b86b7a 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 @@ -19,6 +19,7 @@ import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; import 'components/stream_reaction_picker_theme.dart'; import 'components/stream_reactions_theme.dart'; +import 'components/stream_skeleton_loading_theme.dart'; import 'primitives/stream_icons.dart'; import 'primitives/stream_radius.dart'; import 'primitives/stream_spacing.dart'; @@ -129,4 +130,7 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamReactionsThemeData] from the nearest ancestor. StreamReactionsThemeData get streamReactionsTheme => StreamReactionsTheme.of(this); + + /// Returns the [StreamSkeletonLoadingThemeData] from the nearest ancestor. + StreamSkeletonLoadingThemeData get streamSkeletonLoadingTheme => StreamSkeletonLoadingTheme.of(this); } diff --git a/packages/stream_core_flutter/pubspec.yaml b/packages/stream_core_flutter/pubspec.yaml index 9f1f8171..55410bca 100644 --- a/packages/stream_core_flutter/pubspec.yaml +++ b/packages/stream_core_flutter/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: flutter_markdown_plus: ^1.0.7 flutter_svg: ^2.2.3 markdown: ^7.3.0 + shimmer: ^3.0.0 stream_core: ^0.4.0 theme_extensions_builder_annotation: ^7.1.0 From 1accfb62bba83b9c71a07bb5eddd02a20bfc84f3 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 20 Mar 2026 02:18:41 +0530 Subject: [PATCH 2/9] feat(ui): update StreamLoadingSpinner to use predefined sizes and remove stroke width customization --- .../common/stream_loading_spinner.dart | 114 +++--------------- .../common/stream_loading_spinner.dart | 75 +++++++----- 2 files changed, 63 insertions(+), 126 deletions(-) diff --git a/apps/design_system_gallery/lib/components/common/stream_loading_spinner.dart b/apps/design_system_gallery/lib/components/common/stream_loading_spinner.dart index cfda77a7..44f757d5 100644 --- a/apps/design_system_gallery/lib/components/common/stream_loading_spinner.dart +++ b/apps/design_system_gallery/lib/components/common/stream_loading_spinner.dart @@ -13,26 +13,17 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; path: '[Components]/Common', ) Widget buildStreamLoadingSpinnerPlayground(BuildContext context) { - final size = context.knobs.double.slider( + final size = context.knobs.object.dropdown( label: 'Size', - initialValue: 20, - min: 12, - max: 64, + options: StreamLoadingSpinnerSize.values, + initialOption: StreamLoadingSpinnerSize.sm, + labelBuilder: (option) => '${option.name} (${option.value.toInt()}px)', description: 'The diameter of the spinner.', ); - final strokeWidth = context.knobs.double.slider( - label: 'Stroke Width', - initialValue: 2, - min: 1, - max: 8, - description: 'The width of the track and arc.', - ); - return Center( child: StreamLoadingSpinner( size: size, - strokeWidth: strokeWidth, ), ); } @@ -60,8 +51,6 @@ Widget buildStreamLoadingSpinnerShowcase(BuildContext context) { children: [ const _SizeVariantsSection(), SizedBox(height: spacing.xl), - const _StrokeVariantsSection(), - SizedBox(height: spacing.xl), const _ColorVariantsSection(), ], ), @@ -114,78 +103,13 @@ class _SizeVariantsSection extends StatelessWidget { SizedBox(height: spacing.md), Row( children: [ - for (final (label, size) in [ - ('16px', 16.0), - ('20px', 20.0), - ('32px', 32.0), - ('48px', 48.0), - ]) ...[ - _SpinnerDemo(label: label, size: size), - if (size != 48.0) SizedBox(width: spacing.xl), - ], - ], - ), - ], - ), - ), - ], - ); - } -} - -// ============================================================================= -// Stroke Width Variants Section -// ============================================================================= - -class _StrokeVariantsSection extends StatelessWidget { - const _StrokeVariantsSection(); - - @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: 'STROKE WIDTH 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( - 'Different stroke widths at 32px size', - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, - ), - ), - SizedBox(height: spacing.md), - Row( - children: [ - for (final (label, strokeWidth) in [ - ('1px', 1.0), - ('2px', 2.0), - ('4px', 4.0), - ('6px', 6.0), - ]) ...[ - _SpinnerDemo(label: label, size: 32, strokeWidth: strokeWidth), - if (strokeWidth != 6.0) SizedBox(width: spacing.xl), + for (final (index, size) in StreamLoadingSpinnerSize.values.indexed) ...[ + _SpinnerDemo( + label: '${size.name} (${size.value.toInt()}px)', + size: size, + ), + if (index < StreamLoadingSpinnerSize.values.length - 1) + SizedBox(width: spacing.xl), ], ], ), @@ -242,13 +166,13 @@ class _ColorVariantsSection extends StatelessWidget { SizedBox(height: spacing.md), Row( children: [ - const _ColorDemo(label: 'Default', size: 32), + const _ColorDemo(label: 'Default', size: StreamLoadingSpinnerSize.lg), SizedBox(width: spacing.xl), - _ColorDemo(label: 'Success', size: 32, color: colorScheme.accentSuccess), + _ColorDemo(label: 'Success', size: StreamLoadingSpinnerSize.lg, color: colorScheme.accentSuccess), SizedBox(width: spacing.xl), - _ColorDemo(label: 'Warning', size: 32, color: colorScheme.accentWarning), + _ColorDemo(label: 'Warning', size: StreamLoadingSpinnerSize.lg, color: colorScheme.accentWarning), SizedBox(width: spacing.xl), - _ColorDemo(label: 'Error', size: 32, color: colorScheme.accentError), + _ColorDemo(label: 'Error', size: StreamLoadingSpinnerSize.lg, color: colorScheme.accentError), ], ), ], @@ -264,11 +188,10 @@ class _ColorVariantsSection extends StatelessWidget { // ============================================================================= class _SpinnerDemo extends StatelessWidget { - const _SpinnerDemo({required this.label, required this.size, this.strokeWidth}); + const _SpinnerDemo({required this.label, required this.size}); final String label; - final double size; - final double? strokeWidth; + final StreamLoadingSpinnerSize size; @override Widget build(BuildContext context) { @@ -284,7 +207,6 @@ class _SpinnerDemo extends StatelessWidget { child: Center( child: StreamLoadingSpinner( size: size, - strokeWidth: strokeWidth, ), ), ), @@ -305,7 +227,7 @@ class _ColorDemo extends StatelessWidget { const _ColorDemo({required this.label, required this.size, this.color}); final String label; - final double size; + final StreamLoadingSpinnerSize size; final Color? color; @override diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_loading_spinner.dart b/packages/stream_core_flutter/lib/src/components/common/stream_loading_spinner.dart index dc551886..690a6e22 100644 --- a/packages/stream_core_flutter/lib/src/components/common/stream_loading_spinner.dart +++ b/packages/stream_core_flutter/lib/src/components/common/stream_loading_spinner.dart @@ -6,24 +6,44 @@ import '../../factory/stream_component_factory.dart'; import '../../theme/semantics/stream_color_scheme.dart'; import '../../theme/stream_theme_extensions.dart'; +/// Predefined sizes for [StreamLoadingSpinner]. +/// +/// Each size corresponds to a specific diameter in logical pixels. +enum StreamLoadingSpinnerSize { + /// Large spinner (32px diameter, 3px stroke). + lg(32, 3), + + /// Medium spinner (24px diameter, 2.5px stroke). + md(24, 2.5), + + /// Small spinner (20px diameter, 2px stroke). + sm(20, 2), + + /// Extra small spinner (16px diameter, 2px stroke). + xs(16, 2) + ; + + const StreamLoadingSpinnerSize(this.value, this.strokeWidth); + + /// The diameter of the spinner in logical pixels. + final double value; + + /// The default stroke width for this size. + final double strokeWidth; +} + /// A circular loading spinner component. /// /// [StreamLoadingSpinner] displays a circular loading spinner that rotates -/// continuously. It supports customizing the size, stroke width, and colors. +/// continuously. It supports customizing the [size] and colors. class StreamLoadingSpinner extends StatelessWidget { /// Creates a [StreamLoadingSpinner]. StreamLoadingSpinner({ super.key, - double? size, - double? strokeWidth, + StreamLoadingSpinnerSize? size, Color? color, Color? trackColor, - }) : props = StreamLoadingSpinnerProps( - size: size, - strokeWidth: strokeWidth, - color: color, - trackColor: trackColor, - ); + }) : props = .new(size: size, color: color, trackColor: trackColor); /// The props controlling the appearance of this spinner. final StreamLoadingSpinnerProps props; @@ -49,20 +69,14 @@ class StreamLoadingSpinnerProps { /// Creates properties for a loading spinner. const StreamLoadingSpinnerProps({ this.size, - this.strokeWidth, this.color, this.trackColor, }); - /// The diameter of the spinner. + /// The size of the spinner. /// - /// If null, defaults to 20. - final double? size; - - /// The width of both the track and the animated arc. - /// - /// If null, defaults to 2. - final double? strokeWidth; + /// If null, defaults to [StreamLoadingSpinnerSize.sm]. + final StreamLoadingSpinnerSize? size; /// The color of the animated arc. /// @@ -116,8 +130,10 @@ class _DefaultStreamLoadingSpinnerState extends State CustomPaint( + willChange: true, + painter: _SpinnerPainter( + progress: _controller.value, + color: effectiveColor, + trackColor: effectiveTrackColor, + strokeWidth: effectiveStrokeWidth, + ), + ), ), ); } From 7a9d735b7c6d78fe1aebf19452bdae3ee337e29c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 20 Mar 2026 02:19:01 +0530 Subject: [PATCH 3/9] feat(ui): simplify StadiumBorder usage and enhance StreamBadgeNotification layout --- .../components/badge/stream_badge_count.dart | 7 +---- .../badge/stream_badge_notification.dart | 29 +++++++++---------- 2 files changed, 15 insertions(+), 21 deletions(-) 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 e783f96a..f367a4e7 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 @@ -163,12 +163,7 @@ class DefaultStreamBadgeCount extends StatelessWidget { shadows: boxShadow.elevation2, ), foregroundDecoration: ShapeDecoration( - shape: StadiumBorder( - side: BorderSide( - color: effectiveBorderColor, - strokeAlign: BorderSide.strokeAlignOutside, - ), - ), + shape: StadiumBorder(side: .new(color: effectiveBorderColor)), ), child: DefaultTextStyle( style: textStyle, 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 e51f1107..5d14661a 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 @@ -144,29 +144,28 @@ class DefaultStreamBadgeNotification extends StatelessWidget { final effectiveType = props.type ?? StreamBadgeNotificationType.primary; final effectiveTextColor = theme.textColor ?? defaults.textColor; final effectiveBorderColor = theme.borderColor ?? defaults.borderColor; - final effectiveBackgroundColor = _resolveBackgroundColor( - effectiveType, - theme, - defaults, - ); + final effectiveBackgroundColor = _resolveBackgroundColor(effectiveType, theme, defaults); final padding = _paddingForSize(effectiveSize, spacing); final textStyle = _textStyleForSize(effectiveSize, textTheme).copyWith(color: effectiveTextColor); return IntrinsicWidth( - child: Container( - constraints: BoxConstraints( - minWidth: effectiveSize.value, - minHeight: effectiveSize.value, - ), + child: AnimatedContainer( + height: effectiveSize.value, + constraints: BoxConstraints(minWidth: effectiveSize.value), padding: padding, alignment: Alignment.center, + clipBehavior: Clip.antiAlias, + duration: kThemeChangeDuration, decoration: ShapeDecoration( color: effectiveBackgroundColor, + shape: const StadiumBorder(), + ), + foregroundDecoration: ShapeDecoration( shape: StadiumBorder( side: BorderSide( - color: effectiveBorderColor, width: 2, + color: effectiveBorderColor, strokeAlign: BorderSide.strokeAlignOutside, ), ), @@ -184,9 +183,9 @@ class DefaultStreamBadgeNotification extends StatelessWidget { StreamBadgeNotificationThemeData theme, _StreamBadgeNotificationThemeDefaults defaults, ) => switch (type) { - StreamBadgeNotificationType.primary => theme.primaryBackgroundColor ?? defaults.primaryBackgroundColor, - StreamBadgeNotificationType.error => theme.errorBackgroundColor ?? defaults.errorBackgroundColor, - StreamBadgeNotificationType.neutral => theme.neutralBackgroundColor ?? defaults.neutralBackgroundColor, + .primary => theme.primaryBackgroundColor ?? defaults.primaryBackgroundColor, + .error => theme.errorBackgroundColor ?? defaults.errorBackgroundColor, + .neutral => theme.neutralBackgroundColor ?? defaults.neutralBackgroundColor, }; TextStyle _textStyleForSize( @@ -214,7 +213,7 @@ class _StreamBadgeNotificationThemeDefaults extends StreamBadgeNotificationTheme late final _colorScheme = _context.streamColorScheme; @override - StreamBadgeNotificationSize get size => StreamBadgeNotificationSize.sm; + StreamBadgeNotificationSize get size => .sm; @override Color get primaryBackgroundColor => _colorScheme.accentPrimary; From e7fa53953241ed3f641d881584c63e96c2054a0d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 20 Mar 2026 02:19:31 +0530 Subject: [PATCH 4/9] feat(ui): make height parameter optional in StreamSkeletonBox for flexible sizing --- .../lib/src/components/common/stream_skeleton_loading.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_skeleton_loading.dart b/packages/stream_core_flutter/lib/src/components/common/stream_skeleton_loading.dart index beafc905..144d3ac4 100644 --- a/packages/stream_core_flutter/lib/src/components/common/stream_skeleton_loading.dart +++ b/packages/stream_core_flutter/lib/src/components/common/stream_skeleton_loading.dart @@ -186,7 +186,7 @@ class StreamSkeletonBox extends StatelessWidget { /// Creates a skeleton placeholder box. const StreamSkeletonBox({ super.key, - required this.height, + this.height, this.width, this.borderRadius, this.shape = .rectangle, @@ -206,7 +206,9 @@ class StreamSkeletonBox extends StatelessWidget { borderRadius = null; /// The height of the placeholder. - final double height; + /// + /// If null, the box sizes itself based on incoming constraints. + final double? height; /// The width of the placeholder. /// From 2b3d178f939d3aa9dff840d35a69cd8e3d54e248 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 20 Mar 2026 03:16:26 +0530 Subject: [PATCH 5/9] feat(ui): simplify size property access in StreamBadgeCount --- .../lib/src/components/badge/stream_badge_count.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f367a4e7..092a4f02 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 @@ -208,7 +208,7 @@ class _StreamBadgeCountThemeDefaults extends StreamBadgeCountThemeData { late final _colorScheme = _context.streamColorScheme; @override - StreamBadgeCountSize get size => StreamBadgeCountSize.xs; + StreamBadgeCountSize get size => .xs; @override Color get backgroundColor => _colorScheme.backgroundElevation3; From db3f371e0768876917dfd83842635df3a51cdf29 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 20 Mar 2026 03:16:54 +0530 Subject: [PATCH 6/9] feat(ui): add StreamNetworkImage and StreamRetryBadge components with customizable error handling --- .../lib/app/gallery_app.directories.g.dart | 38 ++ .../components/badge/stream_retry_badge.dart | 189 ++++++++ .../common/stream_network_image.dart | 441 ++++++++++++++++++ .../lib/src/components.dart | 2 + .../src/components/avatar/stream_avatar.dart | 10 +- .../components/badge/stream_retry_badge.dart | 142 ++++++ .../common/stream_network_image.dart | 399 ++++++++++++++++ .../src/factory/stream_component_factory.dart | 16 + .../stream_component_factory.g.theme.dart | 12 + packages/stream_core_flutter/pubspec.yaml | 2 +- 10 files changed, 1245 insertions(+), 6 deletions(-) create mode 100644 apps/design_system_gallery/lib/components/badge/stream_retry_badge.dart create mode 100644 apps/design_system_gallery/lib/components/common/stream_network_image.dart create mode 100644 packages/stream_core_flutter/lib/src/components/badge/stream_retry_badge.dart create mode 100644 packages/stream_core_flutter/lib/src/components/common/stream_network_image.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 681c566f..0003f869 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/badge/stream_badge_notification as _design_system_gallery_components_badge_stream_badge_notification; import 'package:design_system_gallery/components/badge/stream_online_indicator.dart' as _design_system_gallery_components_badge_stream_online_indicator; +import 'package:design_system_gallery/components/badge/stream_retry_badge.dart' + as _design_system_gallery_components_badge_stream_retry_badge; 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' @@ -38,6 +40,8 @@ import 'package:design_system_gallery/components/common/stream_flex.dart' as _design_system_gallery_components_common_stream_flex; import 'package:design_system_gallery/components/common/stream_loading_spinner.dart' as _design_system_gallery_components_common_stream_loading_spinner; +import 'package:design_system_gallery/components/common/stream_network_image.dart' + as _design_system_gallery_components_common_stream_network_image; import 'package:design_system_gallery/components/common/stream_progress_bar.dart' as _design_system_gallery_components_common_stream_progress_bar; import 'package:design_system_gallery/components/common/stream_skeleton_loading.dart' @@ -365,6 +369,23 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamRetryBadge', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_badge_stream_retry_badge + .buildStreamRetryBadgePlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_badge_stream_retry_badge + .buildStreamRetryBadgeShowcase, + ), + ], + ), ], ), _widgetbook.WidgetbookFolder( @@ -456,6 +477,23 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamNetworkImage', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_common_stream_network_image + .buildStreamNetworkImagePlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_common_stream_network_image + .buildStreamNetworkImageShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'StreamProgressBar', useCases: [ diff --git a/apps/design_system_gallery/lib/components/badge/stream_retry_badge.dart b/apps/design_system_gallery/lib/components/badge/stream_retry_badge.dart new file mode 100644 index 00000000..5d3af9e8 --- /dev/null +++ b/apps/design_system_gallery/lib/components/badge/stream_retry_badge.dart @@ -0,0 +1,189 @@ +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: StreamRetryBadge, + path: '[Components]/Badge', +) +Widget buildStreamRetryBadgePlayground(BuildContext context) { + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamRetryBadgeSize.values, + initialOption: StreamRetryBadgeSize.md, + labelBuilder: (option) => '${option.name.toUpperCase()} (${option.value.toInt()}px)', + description: 'The diameter of the badge.', + ); + + return Center( + child: StreamRetryBadge(size: size), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamRetryBadge, + path: '[Components]/Badge', +) +Widget buildStreamRetryBadgeShowcase(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: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SizeVariantsSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Size Variants Section +// ============================================================================= + +class _SizeVariantsSection extends StatelessWidget { + const _SizeVariantsSection(); + + @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: 'SIZE 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( + 'Two sizes for different contexts', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Row( + children: [ + for (final (index, size) in StreamRetryBadgeSize.values.indexed) ...[ + _SizeDemo(size: size), + if (index < StreamRetryBadgeSize.values.length - 1) SizedBox(width: spacing.xl), + ], + ], + ), + ], + ), + ), + ], + ); + } +} + +class _SizeDemo extends StatelessWidget { + const _SizeDemo({required this.size}); + + final StreamRetryBadgeSize size; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + SizedBox( + width: 48, + height: 48, + child: Center( + child: StreamRetryBadge(size: size), + ), + ), + SizedBox(height: spacing.sm), + Text( + size.name.toUpperCase(), + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + Text( + '${size.value.toInt()}px', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + fontFamily: 'monospace', + fontSize: 10, + ), + ), + ], + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +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/apps/design_system_gallery/lib/components/common/stream_network_image.dart b/apps/design_system_gallery/lib/components/common/stream_network_image.dart new file mode 100644 index 00000000..b56b14f9 --- /dev/null +++ b/apps/design_system_gallery/lib/components/common/stream_network_image.dart @@ -0,0 +1,441 @@ +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; + +const _kValidUrl = 'https://picsum.photos/seed/stream/400/300'; +const _kInvalidUrl = 'https://invalid.test/does-not-exist.jpg'; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamNetworkImage, + path: '[Components]/Common', +) +Widget buildStreamNetworkImagePlayground(BuildContext context) { + final fit = context.knobs.object.dropdown( + label: 'Fit', + options: BoxFit.values, + initialOption: BoxFit.cover, + labelBuilder: (option) => option.name, + description: 'How the image should fill its bounds.', + ); + + final width = context.knobs.double.slider( + label: 'Width', + initialValue: 300, + min: 100, + max: 500, + description: 'The width of the image.', + ); + + final height = context.knobs.double.slider( + label: 'Height', + initialValue: 200, + min: 100, + max: 500, + description: 'The height of the image.', + ); + + final clearCache = context.knobs.boolean( + label: 'Clear Cache', + description: 'Evict the demo image from cache.', + ); + + if (clearCache) { + // ignore: invalid_use_of_visible_for_testing_member + StreamNetworkImage.evictFromCache(_kValidUrl); + } + + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Center( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.all(radius.md), + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.backgroundElevation2, + borderRadius: BorderRadius.all(radius.md), + ), + child: StreamNetworkImage( + _kValidUrl, + width: width, + height: height, + fit: fit, + ), + ), + ), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamNetworkImage, + path: '[Components]/Common', +) +Widget buildStreamNetworkImageShowcase(BuildContext context) { + final spacing = context.streamSpacing; + + return DefaultTextStyle( + style: context.streamTextTheme.bodyDefault.copyWith( + color: context.streamColorScheme.textPrimary, + ), + child: SingleChildScrollView( + padding: EdgeInsets.all(spacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _FitVariantsSection(), + SizedBox(height: spacing.xl), + const _CustomBuildersSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Fit Variants Section +// ============================================================================= + +class _FitVariantsSection extends StatelessWidget { + const _FitVariantsSection(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + const fits = [ + BoxFit.cover, + BoxFit.contain, + BoxFit.fill, + BoxFit.fitWidth, + BoxFit.fitHeight, + BoxFit.scaleDown, + ]; + + return _ShowcaseCard( + title: 'FIT VARIANTS', + description: + 'A tall source image (200x400) in a wide container ' + 'to highlight differences between each BoxFit mode.', + child: Wrap( + spacing: spacing.md, + runSpacing: spacing.lg, + children: [ + for (final fit in fits) + _ImageDemo( + label: fit.name, + child: Container( + width: 140, + height: 100, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + color: colorScheme.backgroundElevation2, + ), + child: StreamNetworkImage( + 'https://picsum.photos/seed/fit-demo/200/400', + width: 140, + height: 100, + fit: fit, + ), + ), + ), + ], + ), + ); + } +} + +// ============================================================================= +// Custom Builders Section +// ============================================================================= + +class _CustomBuildersSection extends StatelessWidget { + const _CustomBuildersSection(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return _ShowcaseCard( + title: 'CUSTOM BUILDERS', + description: + 'Custom placeholder and error builders. ' + 'Tap the error widget to retry.', + child: Wrap( + spacing: spacing.md, + runSpacing: spacing.md, + children: [ + _ImageDemo( + label: 'Custom error', + child: _TapToReloadDemo( + width: 140, + height: 100, + errorBuilder: (context, error, retry) => Container( + width: 140, + height: 100, + color: colorScheme.backgroundElevation2, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.broken_image_outlined, + size: 24, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.xs), + GestureDetector( + onTap: retry, + child: Text( + 'Tap to retry', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + ), + ), + ), + ], + ), + ), + ), + ), + ), + _ImageDemo( + label: 'Custom placeholder', + child: ClipRRect( + borderRadius: BorderRadius.all(radius.md), + child: StreamNetworkImage( + 'https://picsum.photos/seed/custom-placeholder/300/200', + width: 140, + height: 100, + fit: BoxFit.cover, + placeholderBuilder: (context) => Container( + width: 140, + height: 100, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.backgroundElevation2, + colorScheme.backgroundElevation3, + ], + ), + ), + child: Center( + child: Icon( + Icons.image_outlined, + size: 28, + color: colorScheme.textTertiary, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +// ============================================================================= +// Tap to Reload Demo +// ============================================================================= + +/// Shows an invalid URL to demonstrate error state and tap-to-reload. +/// +/// When a custom [errorBuilder] is provided, it wraps the retry callback +/// to switch to a valid URL on tap, demonstrating the full +/// error -> retry -> success flow. +/// +/// When no [errorBuilder] is provided, the default error widget is used +/// and retry simply re-fetches the same URL. +class _TapToReloadDemo extends StatefulWidget { + const _TapToReloadDemo({ + required this.width, + required this.height, + this.errorBuilder, + }); + + final double width; + final double height; + final StreamNetworkImageErrorBuilder? errorBuilder; + + @override + State<_TapToReloadDemo> createState() => _TapToReloadDemoState(); +} + +class _TapToReloadDemoState extends State<_TapToReloadDemo> { + var _useValidUrl = false; + + String get _url => _useValidUrl ? 'https://picsum.photos/seed/retry-success/300/200' : _kInvalidUrl; + + void _onRetry(VoidCallback retry) { + setState(() => _useValidUrl = true); + retry(); + } + + @override + Widget build(BuildContext context) { + final radius = context.streamRadius; + + return ClipRRect( + borderRadius: BorderRadius.all(radius.md), + child: StreamNetworkImage( + _url, + width: widget.width, + height: widget.height, + fit: BoxFit.cover, + errorBuilder: widget.errorBuilder != null + ? (context, error, retry) => widget.errorBuilder!(context, error, () => _onRetry(retry)) + : null, + ), + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +class _ShowcaseCard extends StatelessWidget { + const _ShowcaseCard({ + 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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel(label: title), + 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( + description, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + child, + ], + ), + ), + ], + ); + } +} + +class _ImageDemo extends StatelessWidget { + const _ImageDemo({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + child, + SizedBox(height: spacing.sm), + Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + ], + ); + } +} + +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 ee89ddb0..ead688f8 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -8,11 +8,13 @@ export 'components/badge/stream_badge_count.dart' hide DefaultStreamBadgeCount; export 'components/badge/stream_badge_notification.dart' hide DefaultStreamBadgeNotification; export 'components/badge/stream_media_badge.dart'; export 'components/badge/stream_online_indicator.dart' hide DefaultStreamOnlineIndicator; +export 'components/badge/stream_retry_badge.dart' hide DefaultStreamRetryBadge; export 'components/buttons/stream_button.dart' hide DefaultStreamButton; export 'components/buttons/stream_emoji_button.dart' hide DefaultStreamEmojiButton; export 'components/common/stream_checkbox.dart' hide DefaultStreamCheckbox; export 'components/common/stream_flex.dart'; export 'components/common/stream_loading_spinner.dart' hide DefaultStreamLoadingSpinner; +export 'components/common/stream_network_image.dart' hide DefaultStreamNetworkImage; export 'components/common/stream_progress_bar.dart' hide DefaultStreamProgressBar; export 'components/common/stream_skeleton_loading.dart' hide DefaultStreamSkeletonLoading; export 'components/common/stream_visibility.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart index 75399589..7b218063 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../factory/stream_component_factory.dart'; @@ -7,6 +6,7 @@ import '../../theme/primitives/stream_colors.dart'; import '../../theme/semantics/stream_color_scheme.dart'; import '../../theme/semantics/stream_text_theme.dart'; import '../../theme/stream_theme_extensions.dart'; +import '../common/stream_network_image.dart'; /// A circular avatar component for the Stream design system. /// @@ -217,13 +217,13 @@ class DefaultStreamAvatar extends StatelessWidget { child: DefaultTextStyle( style: textStyle, child: switch (props.imageUrl) { - final imageUrl? => CachedNetworkImage( + final imageUrl? => StreamNetworkImage( + imageUrl, fit: .cover, - imageUrl: imageUrl, width: effectiveSize.value, height: effectiveSize.value, - placeholder: (context, _) => Center(child: props.placeholder.call(context)), - errorWidget: (context, _, _) => Center(child: props.placeholder.call(context)), + placeholderBuilder: (context) => Center(child: props.placeholder.call(context)), + errorBuilder: (context, _, _) => Center(child: props.placeholder.call(context)), ), _ => props.placeholder.call(context), }, diff --git a/packages/stream_core_flutter/lib/src/components/badge/stream_retry_badge.dart b/packages/stream_core_flutter/lib/src/components/badge/stream_retry_badge.dart new file mode 100644 index 00000000..33b499e4 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/badge/stream_retry_badge.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/primitives/stream_icons.dart'; +import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/stream_theme_extensions.dart'; + +/// Predefined sizes for [StreamRetryBadge]. +/// +/// Each size corresponds to a specific diameter and icon size in logical pixels. +enum StreamRetryBadgeSize { + /// Large badge (32px diameter, 16px icon). + lg(32, 16), + + /// Medium badge (24px diameter, 12px icon). + md(24, 12) + ; + + const StreamRetryBadgeSize(this.value, this.iconSize); + + /// The diameter of the badge in logical pixels. + final double value; + + /// The icon size for this badge size. + final double iconSize; +} + +/// A circular retry badge that displays a clockwise arrow icon. +/// +/// [StreamRetryBadge] is used to indicate that an action can be retried, +/// such as reloading a failed image or re-sending a message. It renders as +/// a fixed-size circle with an error-colored background and a retry icon. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamRetryBadge() +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Large variant: +/// +/// ```dart +/// StreamRetryBadge(size: StreamRetryBadgeSize.lg) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamRetryBadgeSize], the available size variants. +/// * [StreamBadgeNotification], a badge for displaying notification counts. +class StreamRetryBadge extends StatelessWidget { + /// Creates a retry badge. + StreamRetryBadge({ + super.key, + StreamRetryBadgeSize? size, + }) : props = .new(size: size); + + /// The properties that configure this retry badge. + final StreamRetryBadgeProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).retryBadge; + if (builder != null) return builder(context, props); + return DefaultStreamRetryBadge(props: props); + } +} + +/// Properties for configuring a [StreamRetryBadge]. +/// +/// This class holds all the configuration options for a retry badge, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamRetryBadge], which uses these properties. +/// * [DefaultStreamRetryBadge], the default implementation. +class StreamRetryBadgeProps { + /// Creates properties for a retry badge. + const StreamRetryBadgeProps({this.size}); + + /// The size of the badge. + /// + /// If null, defaults to [StreamRetryBadgeSize.md]. + final StreamRetryBadgeSize? size; +} + +/// The default implementation of [StreamRetryBadge]. +/// +/// Renders a circular badge with a retry icon. Styling is resolved from +/// the current [StreamColorScheme] and [StreamIcons]. +/// +/// See also: +/// +/// * [StreamRetryBadge], the public API widget. +/// * [StreamRetryBadgeProps], which configures this widget. +class DefaultStreamRetryBadge extends StatelessWidget { + /// Creates a default retry badge with the given [props]. + const DefaultStreamRetryBadge({super.key, required this.props}); + + /// The properties that configure this retry badge. + final StreamRetryBadgeProps props; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + + final effectiveSize = props.size ?? StreamRetryBadgeSize.md; + + return AnimatedContainer( + width: effectiveSize.value, + height: effectiveSize.value, + duration: kThemeChangeDuration, + decoration: ShapeDecoration( + color: colorScheme.accentError, + shape: const CircleBorder(), + ), + foregroundDecoration: ShapeDecoration( + shape: CircleBorder( + side: BorderSide( + width: 2, + color: colorScheme.borderInverse, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + ), + child: Center( + child: Icon( + icons.arrowRotateClockwise, + size: effectiveSize.iconSize, + color: colorScheme.textOnAccent, + ), + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart b/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart new file mode 100644 index 00000000..c67f5f08 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart @@ -0,0 +1,399 @@ +import 'package:cached_network_image_ce/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../components.dart'; +import '../../theme/stream_theme_extensions.dart'; + +/// Signature for a function that builds a placeholder widget while a +/// [StreamNetworkImage] is loading. +typedef StreamNetworkImagePlaceholderBuilder = WidgetBuilder; + +/// Signature for a function that builds an error widget when a +/// [StreamNetworkImage] fails to load. +/// +/// The [retry] callback can be invoked to retry loading the image. +typedef StreamNetworkImageErrorBuilder = Widget Function(BuildContext context, Object error, VoidCallback retry); + +/// A network image component with automatic caching, error handling, +/// and tap-to-reload support. +/// +/// [StreamNetworkImage] loads and displays an image from a URL with built-in +/// caching. If the image fails to load, it shows an error widget that can be +/// tapped to retry the request. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamNetworkImage('https://example.com/photo.jpg') +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With custom placeholder and error handling: +/// +/// ```dart +/// StreamNetworkImage( +/// 'https://example.com/photo.jpg', +/// width: 200, +/// height: 200, +/// fit: BoxFit.cover, +/// placeholderBuilder: (context) => const CircularProgressIndicator(), +/// errorBuilder: (context, error, retry) => TextButton( +/// onPressed: retry, +/// child: const Text('Retry'), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Auto-retry on external events (e.g. network reconnection): +/// +/// ```dart +/// StreamNetworkImage( +/// 'https://example.com/photo.jpg', +/// retryListenable: myConnectivityNotifier, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamNetworkImagePlaceholderBuilder], the placeholder builder typedef. +/// * [StreamNetworkImageErrorBuilder], the error builder typedef. +class StreamNetworkImage extends StatelessWidget { + /// Creates a network image with automatic caching and error handling. + /// + /// The [url] is the network location of the image to load. + StreamNetworkImage( + String url, { + super.key, + Map? httpHeaders, + double? width, + double? height, + int? cacheWidth, + int? cacheHeight, + BoxFit? fit, + Alignment alignment = Alignment.center, + Color? color, + Animation? opacity, + BlendMode? colorBlendMode, + FilterQuality filterQuality = FilterQuality.low, + String? cacheKey, + String? semanticLabel, + bool excludeFromSemantics = false, + StreamNetworkImagePlaceholderBuilder? placeholderBuilder, + StreamNetworkImageErrorBuilder? errorBuilder, + Listenable? retryListenable, + }) : props = .new( + url: url, + httpHeaders: httpHeaders, + width: width, + height: height, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + fit: fit, + alignment: alignment, + color: color, + opacity: opacity, + colorBlendMode: colorBlendMode, + filterQuality: filterQuality, + cacheKey: cacheKey, + semanticLabel: semanticLabel, + excludeFromSemantics: excludeFromSemantics, + placeholderBuilder: placeholderBuilder, + errorBuilder: errorBuilder, + retryListenable: retryListenable, + ); + + /// The properties that configure this network image. + final StreamNetworkImageProps props; + + /// Evicts a single image from both disk and in-memory caches. + /// + /// This is intended for development and testing purposes only. + @visibleForTesting + static Future evictFromCache(String url, {String? cacheKey}) { + return CachedNetworkImage.evictFromCache(url, cacheKey: cacheKey); + } + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).networkImage; + if (builder != null) return builder(context, props); + return DefaultStreamNetworkImage(props: props); + } +} + +/// Properties for configuring a [StreamNetworkImage]. +/// +/// This class holds all the configuration options for a network image, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamNetworkImage], which uses these properties. +/// * [DefaultStreamNetworkImage], the default implementation. +class StreamNetworkImageProps { + /// Creates properties for a network image. + const StreamNetworkImageProps({ + required this.url, + this.httpHeaders, + this.width, + this.height, + this.cacheWidth, + this.cacheHeight, + this.fit, + this.alignment = Alignment.center, + this.color, + this.opacity, + this.colorBlendMode, + this.filterQuality = FilterQuality.low, + this.cacheKey, + this.semanticLabel, + this.excludeFromSemantics = false, + this.placeholderBuilder, + this.errorBuilder, + this.retryListenable, + }); + + /// The URL of the image to load. + final String url; + + /// Optional HTTP headers to send with the image request. + /// + /// Useful for authenticated image URLs that require authorization headers. + final Map? httpHeaders; + + /// The width to use for layout. + /// + /// If null, the image's intrinsic width is used. + final double? width; + + /// The height to use for layout. + /// + /// If null, the image's intrinsic height is used. + final double? height; + + /// The target width for caching the image in memory. + /// + /// The image will be decoded at this width to save memory. If null, + /// the image is decoded at its full resolution. + final int? cacheWidth; + + /// The target height for caching the image in memory. + /// + /// The image will be decoded at this height to save memory. If null, + /// the image is decoded at its full resolution. + final int? cacheHeight; + + /// How the image should be inscribed into the space allocated for it. + /// + /// If null, uses [BoxFit.contain] (the Flutter default). + final BoxFit? fit; + + /// How to align the image within its bounds. + /// + /// Defaults to [Alignment.center]. + final Alignment alignment; + + /// A color to blend with the image. + /// + /// If non-null, the color is applied using [colorBlendMode]. + final Color? color; + + /// An opacity animation to apply to the image. + final Animation? opacity; + + /// The blend mode used to apply [color] to the image. + /// + /// If null, defaults to [BlendMode.srcIn] when [color] is set. + final BlendMode? colorBlendMode; + + /// The quality with which to filter the image. + /// + /// Defaults to [FilterQuality.low]. + final FilterQuality filterQuality; + + /// An alternate key to use for caching. + /// + /// Useful when the same URL serves different images (e.g. when query + /// parameters change the response). + final String? cacheKey; + + /// A semantic description of the image for accessibility. + final String? semanticLabel; + + /// Whether to exclude this image from the semantics tree. + /// + /// Defaults to false. + final bool excludeFromSemantics; + + /// A builder for the placeholder widget shown while loading. + /// + /// If null, an empty [SizedBox] is shown. + final StreamNetworkImagePlaceholderBuilder? placeholderBuilder; + + /// A builder for the error widget shown when loading fails. + /// + /// The builder receives the error and a [retry] callback that can be + /// invoked to retry loading the image. If null, a default error icon + /// is shown. + final StreamNetworkImageErrorBuilder? errorBuilder; + + /// An optional [Listenable] that triggers a retry when notified. + /// + /// When this listenable fires and the image is in an error state, the + /// image will automatically retry loading. This is useful for reacting + /// to external events like network reconnection without adding a network + /// dependency to the component. + /// + /// Example: pass a [ChangeNotifier] that is notified when connectivity + /// is restored. + final Listenable? retryListenable; +} + +/// The default implementation of [StreamNetworkImage]. +/// +/// This widget handles image loading, caching, error states, and +/// tap-to-reload behavior. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamNetworkImage], the public API widget. +/// * [StreamNetworkImageProps], which configures this widget. +class DefaultStreamNetworkImage extends StatefulWidget { + /// Creates a default network image with the given [props]. + const DefaultStreamNetworkImage({super.key, required this.props}); + + /// The properties that configure this network image. + final StreamNetworkImageProps props; + + @override + State createState() => _DefaultStreamNetworkImageState(); +} + +class _DefaultStreamNetworkImageState extends State { + var _retryKey = 0; + var _hasError = false; + + @override + void initState() { + super.initState(); + widget.props.retryListenable?.addListener(_onRetryListenableNotified); + } + + @override + void didUpdateWidget(DefaultStreamNetworkImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.props.retryListenable != widget.props.retryListenable) { + oldWidget.props.retryListenable?.removeListener(_onRetryListenableNotified); + widget.props.retryListenable?.addListener(_onRetryListenableNotified); + } + } + + @override + void dispose() { + widget.props.retryListenable?.removeListener(_onRetryListenableNotified); + super.dispose(); + } + + void _onRetryListenableNotified() { + if (_hasError) _retry(); + } + + void _retry() { + final props = widget.props; + StreamNetworkImage.evictFromCache(props.url, cacheKey: props.cacheKey); + + setState(() { + _retryKey++; + _hasError = false; + }); + } + + @override + Widget build(BuildContext context) { + final props = widget.props; + + return CachedNetworkImage( + key: ValueKey(_retryKey), + imageUrl: props.url, + httpHeaders: props.httpHeaders, + width: props.width, + height: props.height, + memCacheWidth: props.cacheWidth, + memCacheHeight: props.cacheHeight, + fit: props.fit, + alignment: props.alignment, + color: props.color, + colorBlendMode: props.colorBlendMode, + filterQuality: props.filterQuality, + cacheKey: props.cacheKey, + fadeOutDuration: Duration.zero, + fadeInDuration: Duration.zero, + errorListener: (_) => _hasError = true, + placeholder: (context, _) { + if (props.placeholderBuilder case final builder?) return builder(context); + return _DefaultPlaceholderWidget(props: props); + }, + errorBuilder: (context, error, _) { + if (props.errorBuilder case final builder?) return builder(context, error, _retry); + return _DefaultErrorWidget(props: props, onRetry: _retry); + }, + ); + } +} + +class _DefaultPlaceholderWidget extends StatelessWidget { + const _DefaultPlaceholderWidget({required this.props}); + + final StreamNetworkImageProps props; + + @override + Widget build(BuildContext context) { + return Stack( + alignment: .center, + children: [ + StreamSkeletonLoading( + child: StreamSkeletonBox( + width: props.width, + height: props.height, + ), + ), + StreamLoadingSpinner(size: .md), + ], + ); + } +} + +class _DefaultErrorWidget extends StatelessWidget { + const _DefaultErrorWidget({required this.props, required this.onRetry}); + + final StreamNetworkImageProps props; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return GestureDetector( + onTap: onRetry, + child: ColoredBox( + color: colorScheme.backgroundOverlayLight, + child: SizedBox( + width: props.width, + height: props.height, + child: Center( + child: StreamRetryBadge(size: .lg), + ), + ), + ), + ); + } +} 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 421c3982..16e75d34 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 @@ -153,10 +153,12 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? messageMetadata, StreamComponentBuilder? messageReplies, StreamComponentBuilder? messageText, + StreamComponentBuilder? networkImage, StreamComponentBuilder? onlineIndicator, StreamComponentBuilder? progressBar, StreamComponentBuilder? reactionPicker, StreamComponentBuilder? reactions, + StreamComponentBuilder? retryBadge, StreamComponentBuilder? skeletonLoading, Iterable>? extensions, }) { @@ -185,10 +187,12 @@ class StreamComponentBuilders with _$StreamComponentBuilders { messageMetadata: messageMetadata, messageReplies: messageReplies, messageText: messageText, + networkImage: networkImage, onlineIndicator: onlineIndicator, progressBar: progressBar, reactionPicker: reactionPicker, reactions: reactions, + retryBadge: retryBadge, skeletonLoading: skeletonLoading, extensions: _extensionIterableToMap(extensions), ); @@ -218,10 +222,12 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.messageMetadata, required this.messageReplies, required this.messageText, + required this.networkImage, required this.onlineIndicator, required this.progressBar, required this.reactionPicker, required this.reactions, + required this.retryBadge, required this.skeletonLoading, required this.extensions, }); @@ -356,6 +362,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamMessageText] uses [DefaultStreamMessageText]. final StreamComponentBuilder? messageText; + /// Custom builder for network image widgets. + /// + /// When null, [StreamNetworkImage] uses [DefaultStreamNetworkImage]. + final StreamComponentBuilder? networkImage; + /// Custom builder for online indicator widgets. /// /// When null, [StreamOnlineIndicator] uses [DefaultStreamOnlineIndicator]. @@ -376,6 +387,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamReactions] uses [DefaultStreamReactions]. final StreamComponentBuilder? reactions; + /// Custom builder for retry badge widgets. + /// + /// When null, [StreamRetryBadge] uses [DefaultStreamRetryBadge]. + final StreamComponentBuilder? retryBadge; + /// Custom builder for skeleton loading shimmer widgets. /// /// When null, [StreamSkeletonLoading] uses [DefaultStreamSkeletonLoading]. 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 9bd03e9a..a3b31ce7 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 @@ -53,10 +53,12 @@ mixin _$StreamComponentBuilders { messageMetadata: t < 0.5 ? a.messageMetadata : b.messageMetadata, messageReplies: t < 0.5 ? a.messageReplies : b.messageReplies, messageText: t < 0.5 ? a.messageText : b.messageText, + networkImage: t < 0.5 ? a.networkImage : b.networkImage, onlineIndicator: t < 0.5 ? a.onlineIndicator : b.onlineIndicator, progressBar: t < 0.5 ? a.progressBar : b.progressBar, reactionPicker: t < 0.5 ? a.reactionPicker : b.reactionPicker, reactions: t < 0.5 ? a.reactions : b.reactions, + retryBadge: t < 0.5 ? a.retryBadge : b.retryBadge, skeletonLoading: t < 0.5 ? a.skeletonLoading : b.skeletonLoading, ); } @@ -89,10 +91,12 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamMessageMetadataProps)? messageMetadata, Widget Function(BuildContext, StreamMessageRepliesProps)? messageReplies, Widget Function(BuildContext, StreamMessageTextProps)? messageText, + Widget Function(BuildContext, StreamNetworkImageProps)? networkImage, Widget Function(BuildContext, StreamOnlineIndicatorProps)? onlineIndicator, Widget Function(BuildContext, StreamProgressBarProps)? progressBar, Widget Function(BuildContext, StreamReactionPickerProps)? reactionPicker, Widget Function(BuildContext, StreamReactionsProps)? reactions, + Widget Function(BuildContext, StreamRetryBadgeProps)? retryBadge, Widget Function(BuildContext, StreamSkeletonLoadingProps)? skeletonLoading, }) { final _this = (this as StreamComponentBuilders); @@ -121,10 +125,12 @@ mixin _$StreamComponentBuilders { messageMetadata: messageMetadata ?? _this.messageMetadata, messageReplies: messageReplies ?? _this.messageReplies, messageText: messageText ?? _this.messageText, + networkImage: networkImage ?? _this.networkImage, onlineIndicator: onlineIndicator ?? _this.onlineIndicator, progressBar: progressBar ?? _this.progressBar, reactionPicker: reactionPicker ?? _this.reactionPicker, reactions: reactions ?? _this.reactions, + retryBadge: retryBadge ?? _this.retryBadge, skeletonLoading: skeletonLoading ?? _this.skeletonLoading, ); } @@ -164,10 +170,12 @@ mixin _$StreamComponentBuilders { messageMetadata: other.messageMetadata, messageReplies: other.messageReplies, messageText: other.messageText, + networkImage: other.networkImage, onlineIndicator: other.onlineIndicator, progressBar: other.progressBar, reactionPicker: other.reactionPicker, reactions: other.reactions, + retryBadge: other.retryBadge, skeletonLoading: other.skeletonLoading, ); } @@ -208,10 +216,12 @@ mixin _$StreamComponentBuilders { _other.messageMetadata == _this.messageMetadata && _other.messageReplies == _this.messageReplies && _other.messageText == _this.messageText && + _other.networkImage == _this.networkImage && _other.onlineIndicator == _this.onlineIndicator && _other.progressBar == _this.progressBar && _other.reactionPicker == _this.reactionPicker && _other.reactions == _this.reactions && + _other.retryBadge == _this.retryBadge && _other.skeletonLoading == _this.skeletonLoading; } @@ -244,10 +254,12 @@ mixin _$StreamComponentBuilders { _this.messageMetadata, _this.messageReplies, _this.messageText, + _this.networkImage, _this.onlineIndicator, _this.progressBar, _this.reactionPicker, _this.reactions, + _this.retryBadge, _this.skeletonLoading, ]); } diff --git a/packages/stream_core_flutter/pubspec.yaml b/packages/stream_core_flutter/pubspec.yaml index 55410bca..9cca5de3 100644 --- a/packages/stream_core_flutter/pubspec.yaml +++ b/packages/stream_core_flutter/pubspec.yaml @@ -8,7 +8,7 @@ environment: flutter: ">=3.38.1" dependencies: - cached_network_image: ^3.4.1 + cached_network_image_ce: ^4.6.3 collection: ^1.19.0 flutter: sdk: flutter From 328b43bf086423f9bb9bac5e3cdad7b2f5e6bd1a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 20 Mar 2026 03:18:27 +0530 Subject: [PATCH 7/9] chore: run formatter --- .../lib/components/common/stream_loading_spinner.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/design_system_gallery/lib/components/common/stream_loading_spinner.dart b/apps/design_system_gallery/lib/components/common/stream_loading_spinner.dart index 44f757d5..233cd6eb 100644 --- a/apps/design_system_gallery/lib/components/common/stream_loading_spinner.dart +++ b/apps/design_system_gallery/lib/components/common/stream_loading_spinner.dart @@ -108,8 +108,7 @@ class _SizeVariantsSection extends StatelessWidget { label: '${size.name} (${size.value.toInt()}px)', size: size, ), - if (index < StreamLoadingSpinnerSize.values.length - 1) - SizedBox(width: spacing.xl), + if (index < StreamLoadingSpinnerSize.values.length - 1) SizedBox(width: spacing.xl), ], ], ), From 301ff78968b6c0ae3dd18511c72289490756f7fa Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 20 Mar 2026 03:25:23 +0530 Subject: [PATCH 8/9] feat(ui): refactor StreamNetworkImage to use Container for improved layout --- .../src/components/common/stream_network_image.dart | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart b/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart index c67f5f08..02b9775e 100644 --- a/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart +++ b/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart @@ -384,15 +384,11 @@ class _DefaultErrorWidget extends StatelessWidget { return GestureDetector( onTap: onRetry, - child: ColoredBox( + child: Container( + width: props.width, + height: props.height, color: colorScheme.backgroundOverlayLight, - child: SizedBox( - width: props.width, - height: props.height, - child: Center( - child: StreamRetryBadge(size: .lg), - ), - ), + child: Center(child: StreamRetryBadge(size: .lg)), ), ); } From 44f08e8448d1be9f4b26f511dad6a57e1e1c928b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 20 Mar 2026 13:54:41 +0530 Subject: [PATCH 9/9] feat(ui): add default placeholders for loading and error states in StreamNetworkImage --- .../common/stream_network_image.dart | 76 ++++++++++++++- .../common/stream_network_image.dart | 92 +++++++++++++++---- 2 files changed, 150 insertions(+), 18 deletions(-) diff --git a/apps/design_system_gallery/lib/components/common/stream_network_image.dart b/apps/design_system_gallery/lib/components/common/stream_network_image.dart index b56b14f9..8b497be6 100644 --- a/apps/design_system_gallery/lib/components/common/stream_network_image.dart +++ b/apps/design_system_gallery/lib/components/common/stream_network_image.dart @@ -100,6 +100,8 @@ Widget buildStreamNetworkImageShowcase(BuildContext context) { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + const _DefaultPlaceholdersSection(), + SizedBox(height: spacing.xl), const _FitVariantsSection(), SizedBox(height: spacing.xl), const _CustomBuildersSection(), @@ -109,6 +111,76 @@ Widget buildStreamNetworkImageShowcase(BuildContext context) { ); } +// ============================================================================= +// Default placeholders (standalone) +// ============================================================================= + +class _DefaultPlaceholdersSection extends StatelessWidget { + const _DefaultPlaceholdersSection(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return _ShowcaseCard( + title: 'DEFAULT PLACEHOLDERS', + description: + 'Reusable loading and error slots — the same widgets ' + '[StreamNetworkImage] uses when placeholderBuilder and errorBuilder are null.', + child: Wrap( + spacing: spacing.md, + runSpacing: spacing.md, + children: [ + _ImageDemo( + label: 'StreamImageLoadingPlaceholder', + child: Container( + width: 140, + height: 100, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + color: colorScheme.backgroundElevation2, + ), + child: const StreamImageLoadingPlaceholder( + width: 140, + height: 100, + ), + ), + ), + _ImageDemo( + label: 'StreamImageErrorPlaceholder', + child: Builder( + builder: (context) { + return Container( + width: 140, + height: 100, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: StreamImageErrorPlaceholder( + width: 140, + height: 100, + onRetry: () { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar(content: Text('Retry tapped')), + ); + }, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + // ============================================================================= // Fit Variants Section // ============================================================================= @@ -183,8 +255,8 @@ class _CustomBuildersSection extends StatelessWidget { return _ShowcaseCard( title: 'CUSTOM BUILDERS', description: - 'Custom placeholder and error builders. ' - 'Tap the error widget to retry.', + 'Override [StreamImageLoadingPlaceholder] / [StreamImageErrorPlaceholder] ' + 'with custom builders. Tap the custom error demo to retry and load the image.', child: Wrap( spacing: spacing.md, runSpacing: spacing.md, diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart b/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart index 02b9775e..850ada9a 100644 --- a/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart +++ b/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart @@ -65,6 +65,8 @@ typedef StreamNetworkImageErrorBuilder = Widget Function(BuildContext context, O /// /// * [StreamNetworkImagePlaceholderBuilder], the placeholder builder typedef. /// * [StreamNetworkImageErrorBuilder], the error builder typedef. +/// * [StreamImageLoadingPlaceholder], the default loading placeholder. +/// * [StreamImageErrorPlaceholder], the default error surface with retry. class StreamNetworkImage extends StatelessWidget { /// Creates a network image with automatic caching and error handling. /// @@ -235,14 +237,14 @@ class StreamNetworkImageProps { /// A builder for the placeholder widget shown while loading. /// - /// If null, an empty [SizedBox] is shown. + /// If null, [StreamImageLoadingPlaceholder] is used. final StreamNetworkImagePlaceholderBuilder? placeholderBuilder; /// A builder for the error widget shown when loading fails. /// /// The builder receives the error and a [retry] callback that can be - /// invoked to retry loading the image. If null, a default error icon - /// is shown. + /// invoked to retry loading the image. If null, [StreamImageErrorPlaceholder] + /// is used. final StreamNetworkImageErrorBuilder? errorBuilder; /// An optional [Listenable] that triggers a retry when notified. @@ -340,20 +342,42 @@ class _DefaultStreamNetworkImageState extends State { errorListener: (_) => _hasError = true, placeholder: (context, _) { if (props.placeholderBuilder case final builder?) return builder(context); - return _DefaultPlaceholderWidget(props: props); + return StreamImageLoadingPlaceholder(width: props.width, height: props.height); }, errorBuilder: (context, error, _) { if (props.errorBuilder case final builder?) return builder(context, error, _retry); - return _DefaultErrorWidget(props: props, onRetry: _retry); + return StreamImageErrorPlaceholder(width: props.width, height: props.height, onRetry: _retry); }, ); } } -class _DefaultPlaceholderWidget extends StatelessWidget { - const _DefaultPlaceholderWidget({required this.props}); +/// A loading placeholder for image-sized areas. +/// +/// [StreamImageLoadingPlaceholder] is used as the default loading state for +/// image slots to indicate that content is being fetched. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamImageLoadingPlaceholder(width: 200, height: 150) +/// ``` +/// {@end-tool} +class StreamImageLoadingPlaceholder extends StatelessWidget { + /// Creates a [StreamImageLoadingPlaceholder]. + const StreamImageLoadingPlaceholder({super.key, this.width, this.height}); - final StreamNetworkImageProps props; + /// The width of the placeholder area. + /// + /// If null, the widget sizes itself to the parent's constraints. + final double? width; + + /// The height of the placeholder area. + /// + /// If null, the widget sizes itself to the parent's constraints. + final double? height; @override Widget build(BuildContext context) { @@ -362,8 +386,8 @@ class _DefaultPlaceholderWidget extends StatelessWidget { children: [ StreamSkeletonLoading( child: StreamSkeletonBox( - width: props.width, - height: props.height, + width: width, + height: height, ), ), StreamLoadingSpinner(size: .md), @@ -372,11 +396,47 @@ class _DefaultPlaceholderWidget extends StatelessWidget { } } -class _DefaultErrorWidget extends StatelessWidget { - const _DefaultErrorWidget({required this.props, required this.onRetry}); +/// An error placeholder for image-sized areas. +/// +/// [StreamImageErrorPlaceholder] is used as the default error state for image +/// slots. When [onRetry] is provided, the surface becomes tappable so the +/// user can retry the failed operation. +/// +/// {@tool snippet} +/// +/// Basic usage with retry: +/// +/// ```dart +/// StreamImageErrorPlaceholder( +/// width: 200, +/// height: 150, +/// onRetry: () => reloadImage(), +/// ) +/// ``` +/// {@end-tool} +class StreamImageErrorPlaceholder extends StatelessWidget { + /// Creates a [StreamImageErrorPlaceholder]. + const StreamImageErrorPlaceholder({ + super.key, + this.width, + this.height, + this.onRetry, + }); - final StreamNetworkImageProps props; - final VoidCallback onRetry; + /// The width of the placeholder area. + /// + /// If null, the widget sizes itself to the parent's constraints. + final double? width; + + /// The height of the placeholder area. + /// + /// If null, the widget sizes itself to the parent's constraints. + final double? height; + + /// Called when the user taps the error surface to retry. + /// + /// If null, the surface is not interactive. + final VoidCallback? onRetry; @override Widget build(BuildContext context) { @@ -385,8 +445,8 @@ class _DefaultErrorWidget extends StatelessWidget { return GestureDetector( onTap: onRetry, child: Container( - width: props.width, - height: props.height, + width: width, + height: height, color: colorScheme.backgroundOverlayLight, child: Center(child: StreamRetryBadge(size: .lg)), ),