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 681c566..0003f86 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 0000000..5d3af9e --- /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_loading_spinner.dart b/apps/design_system_gallery/lib/components/common/stream_loading_spinner.dart index cfda77a..233cd6e 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,12 @@ 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 +165,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 +187,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 +206,6 @@ class _SpinnerDemo extends StatelessWidget { child: Center( child: StreamLoadingSpinner( size: size, - strokeWidth: strokeWidth, ), ), ), @@ -305,7 +226,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/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 0000000..8b497be --- /dev/null +++ b/apps/design_system_gallery/lib/components/common/stream_network_image.dart @@ -0,0 +1,513 @@ +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 _DefaultPlaceholdersSection(), + SizedBox(height: spacing.xl), + const _FitVariantsSection(), + SizedBox(height: spacing.xl), + const _CustomBuildersSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// 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 +// ============================================================================= + +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: + '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, + 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 ee89ddb..ead688f 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 7539958..7b21806 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_badge_count.dart b/packages/stream_core_flutter/lib/src/components/badge/stream_badge_count.dart index e783f96..092a4f0 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, @@ -213,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; 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 e51f110..5d14661 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; 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 0000000..33b499e --- /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_loading_spinner.dart b/packages/stream_core_flutter/lib/src/components/common/stream_loading_spinner.dart index dc55188..690a6e2 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, + ), + ), ), ); } 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 0000000..850ada9 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/common/stream_network_image.dart @@ -0,0 +1,455 @@ +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. +/// * [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. + /// + /// 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, [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, [StreamImageErrorPlaceholder] + /// is used. + 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 StreamImageLoadingPlaceholder(width: props.width, height: props.height); + }, + errorBuilder: (context, error, _) { + if (props.errorBuilder case final builder?) return builder(context, error, _retry); + return StreamImageErrorPlaceholder(width: props.width, height: props.height, onRetry: _retry); + }, + ); + } +} + +/// 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}); + + /// 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) { + return Stack( + alignment: .center, + children: [ + StreamSkeletonLoading( + child: StreamSkeletonBox( + width: width, + height: height, + ), + ), + StreamLoadingSpinner(size: .md), + ], + ); + } +} + +/// 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, + }); + + /// 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) { + final colorScheme = context.streamColorScheme; + + return GestureDetector( + onTap: onRetry, + child: Container( + width: width, + height: height, + color: colorScheme.backgroundOverlayLight, + child: Center(child: StreamRetryBadge(size: .lg)), + ), + ); + } +} 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 beafc90..144d3ac 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. /// 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 421c398..16e75d3 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 9bd03e9..a3b31ce 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 55410bc..9cca5de 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