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 2e0c8f5d..6cda8a02 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 @@ -36,6 +36,8 @@ import 'package:design_system_gallery/components/common/stream_checkbox.dart' as _design_system_gallery_components_common_stream_checkbox; 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_progress_bar.dart' as _design_system_gallery_components_common_stream_progress_bar; import 'package:design_system_gallery/components/context_menu/stream_context_menu.dart' @@ -435,6 +437,23 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamLoadingSpinner', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_common_stream_loading_spinner + .buildStreamLoadingSpinnerPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_common_stream_loading_spinner + .buildStreamLoadingSpinnerShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'StreamProgressBar', useCases: [ 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 new file mode 100644 index 00000000..cfda77a7 --- /dev/null +++ b/apps/design_system_gallery/lib/components/common/stream_loading_spinner.dart @@ -0,0 +1,370 @@ +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: StreamLoadingSpinner, + path: '[Components]/Common', +) +Widget buildStreamLoadingSpinnerPlayground(BuildContext context) { + final size = context.knobs.double.slider( + label: 'Size', + initialValue: 20, + min: 12, + max: 64, + 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, + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamLoadingSpinner, + path: '[Components]/Common', +) +Widget buildStreamLoadingSpinnerShowcase(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 _SizeVariantsSection(), + SizedBox(height: spacing.xl), + const _StrokeVariantsSection(), + SizedBox(height: spacing.xl), + const _ColorVariantsSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// 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( + 'Spinners at different sizes', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + 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), + ], + ], + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Color Variants Section +// ============================================================================= + +class _ColorVariantsSection extends StatelessWidget { + const _ColorVariantsSection(); + + @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: 'COLOR 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( + 'Custom arc and track colors', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Row( + children: [ + const _ColorDemo(label: 'Default', size: 32), + SizedBox(width: spacing.xl), + _ColorDemo(label: 'Success', size: 32, color: colorScheme.accentSuccess), + SizedBox(width: spacing.xl), + _ColorDemo(label: 'Warning', size: 32, color: colorScheme.accentWarning), + SizedBox(width: spacing.xl), + _ColorDemo(label: 'Error', size: 32, color: colorScheme.accentError), + ], + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +class _SpinnerDemo extends StatelessWidget { + const _SpinnerDemo({required this.label, required this.size, this.strokeWidth}); + + final String label; + final double size; + final double? strokeWidth; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + SizedBox( + width: 56, + height: 56, + child: Center( + child: StreamLoadingSpinner( + size: size, + strokeWidth: strokeWidth, + ), + ), + ), + SizedBox(height: spacing.sm), + Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + ], + ); + } +} + +class _ColorDemo extends StatelessWidget { + const _ColorDemo({required this.label, required this.size, this.color}); + + final String label; + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + SizedBox( + width: 56, + height: 56, + child: Center( + child: StreamLoadingSpinner( + size: size, + color: color, + ), + ), + ), + 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 a392fcd9..fc71b9c9 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -12,6 +12,7 @@ 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_progress_bar.dart' hide DefaultStreamProgressBar; export 'components/common/stream_visibility.dart'; export 'components/context_menu/stream_context_menu.dart'; 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 new file mode 100644 index 00000000..dc551886 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/common/stream_loading_spinner.dart @@ -0,0 +1,196 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/stream_theme_extensions.dart'; + +/// A circular loading spinner component. +/// +/// [StreamLoadingSpinner] displays a circular loading spinner that rotates +/// continuously. It supports customizing the size, stroke width, and colors. +class StreamLoadingSpinner extends StatelessWidget { + /// Creates a [StreamLoadingSpinner]. + StreamLoadingSpinner({ + super.key, + double? size, + double? strokeWidth, + Color? color, + Color? trackColor, + }) : props = StreamLoadingSpinnerProps( + size: size, + strokeWidth: strokeWidth, + color: color, + trackColor: trackColor, + ); + + /// The props controlling the appearance of this spinner. + final StreamLoadingSpinnerProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).loadingSpinner; + if (builder != null) return builder(context, props); + return DefaultStreamLoadingSpinner(props: props); + } +} + +/// Properties for configuring a [StreamLoadingSpinner]. +/// +/// This class holds all the configuration options for a loading spinner, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamLoadingSpinner], which uses these properties. +/// * [DefaultStreamLoadingSpinner], the default implementation. +class StreamLoadingSpinnerProps { + /// Creates properties for a loading spinner. + const StreamLoadingSpinnerProps({ + this.size, + this.strokeWidth, + this.color, + this.trackColor, + }); + + /// The diameter 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; + + /// The color of the animated arc. + /// + /// If null, uses [StreamColorScheme.accentPrimary]. + final Color? color; + + /// The color of the background track circle. + /// + /// If null, uses [StreamColorScheme.borderDefault]. + final Color? trackColor; +} + +/// Default implementation of [StreamLoadingSpinner]. +/// +/// Renders a circular spinner with a background track and a rotating arc +/// using [CustomPaint]. Styling is resolved from widget props and the +/// current [StreamColorScheme]. +/// +/// See also: +/// +/// * [StreamLoadingSpinner], the public API widget. +class DefaultStreamLoadingSpinner extends StatefulWidget { + /// Creates a default Stream loading spinner. + const DefaultStreamLoadingSpinner({super.key, required this.props}); + + /// The props controlling the appearance of this spinner. + final StreamLoadingSpinnerProps props; + + @override + State createState() => _DefaultStreamLoadingSpinnerState(); +} + +class _DefaultStreamLoadingSpinnerState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final effectiveSize = widget.props.size ?? 20; + final effectiveStrokeWidth = widget.props.strokeWidth ?? 2; + final effectiveColor = widget.props.color ?? colorScheme.accentPrimary; + final effectiveTrackColor = widget.props.trackColor ?? colorScheme.borderDefault; + + return SizedBox.square( + dimension: effectiveSize, + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _SpinnerPainter( + progress: _controller.value, + color: effectiveColor, + trackColor: effectiveTrackColor, + strokeWidth: effectiveStrokeWidth, + ), + ); + }, + ), + ); + } +} + +class _SpinnerPainter extends CustomPainter { + _SpinnerPainter({ + required this.progress, + required this.color, + required this.trackColor, + required this.strokeWidth, + }); + + final double progress; + final Color color; + final Color trackColor; + final double strokeWidth; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width - strokeWidth) / 2; + + // Draw the background track. + final trackPaint = Paint() + ..color = trackColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + canvas.drawCircle(center, radius, trackPaint); + + // Draw the animated arc. + final arcPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + const sweepAngle = math.pi / 3; // 60 degrees + final startAngle = 2 * math.pi * progress - math.pi / 2; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngle, + false, + arcPaint, + ); + } + + @override + bool shouldRepaint(_SpinnerPainter oldDelegate) { + return oldDelegate.progress != progress || + oldDelegate.color != color || + oldDelegate.trackColor != trackColor || + oldDelegate.strokeWidth != strokeWidth; + } +} 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 6a185d13..42381e62 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 @@ -146,6 +146,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? emojiChipBar, StreamComponentBuilder? fileTypeIcon, StreamComponentBuilder? listTile, + StreamComponentBuilder? loadingSpinner, StreamComponentBuilder? messageAnnotation, StreamComponentBuilder? messageBubble, StreamComponentBuilder? messageContent, @@ -176,6 +177,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { emojiChipBar: emojiChipBar, fileTypeIcon: fileTypeIcon, listTile: listTile, + loadingSpinner: loadingSpinner, messageAnnotation: messageAnnotation, messageBubble: messageBubble, messageContent: messageContent, @@ -207,6 +209,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.emojiChipBar, required this.fileTypeIcon, required this.listTile, + required this.loadingSpinner, required this.messageAnnotation, required this.messageBubble, required this.messageContent, @@ -315,6 +318,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamListTile] uses [DefaultStreamListTile]. final StreamComponentBuilder? listTile; + /// Custom builder for loading spinner widgets. + /// + /// When null, [StreamLoadingSpinner] uses [DefaultStreamLoadingSpinner]. + final StreamComponentBuilder? loadingSpinner; + /// Custom builder for message annotation widgets. /// /// When null, [StreamMessageAnnotation] uses [DefaultStreamMessageAnnotation]. 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 c6f342e0..4dd9b244 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 @@ -46,6 +46,7 @@ mixin _$StreamComponentBuilders { emojiChipBar: t < 0.5 ? a.emojiChipBar : b.emojiChipBar, fileTypeIcon: t < 0.5 ? a.fileTypeIcon : b.fileTypeIcon, listTile: t < 0.5 ? a.listTile : b.listTile, + loadingSpinner: t < 0.5 ? a.loadingSpinner : b.loadingSpinner, messageAnnotation: t < 0.5 ? a.messageAnnotation : b.messageAnnotation, messageBubble: t < 0.5 ? a.messageBubble : b.messageBubble, messageContent: t < 0.5 ? a.messageContent : b.messageContent, @@ -79,6 +80,7 @@ mixin _$StreamComponentBuilders { emojiChipBar, Widget Function(BuildContext, StreamFileTypeIconProps)? fileTypeIcon, Widget Function(BuildContext, StreamListTileProps)? listTile, + Widget Function(BuildContext, StreamLoadingSpinnerProps)? loadingSpinner, Widget Function(BuildContext, StreamMessageAnnotationProps)? messageAnnotation, Widget Function(BuildContext, StreamMessageBubbleProps)? messageBubble, @@ -110,6 +112,7 @@ mixin _$StreamComponentBuilders { emojiChipBar: emojiChipBar ?? _this.emojiChipBar, fileTypeIcon: fileTypeIcon ?? _this.fileTypeIcon, listTile: listTile ?? _this.listTile, + loadingSpinner: loadingSpinner ?? _this.loadingSpinner, messageAnnotation: messageAnnotation ?? _this.messageAnnotation, messageBubble: messageBubble ?? _this.messageBubble, messageContent: messageContent ?? _this.messageContent, @@ -151,6 +154,7 @@ mixin _$StreamComponentBuilders { emojiChipBar: other.emojiChipBar, fileTypeIcon: other.fileTypeIcon, listTile: other.listTile, + loadingSpinner: other.loadingSpinner, messageAnnotation: other.messageAnnotation, messageBubble: other.messageBubble, messageContent: other.messageContent, @@ -193,6 +197,7 @@ mixin _$StreamComponentBuilders { _other.emojiChipBar == _this.emojiChipBar && _other.fileTypeIcon == _this.fileTypeIcon && _other.listTile == _this.listTile && + _other.loadingSpinner == _this.loadingSpinner && _other.messageAnnotation == _this.messageAnnotation && _other.messageBubble == _this.messageBubble && _other.messageContent == _this.messageContent && @@ -227,6 +232,7 @@ mixin _$StreamComponentBuilders { _this.emojiChipBar, _this.fileTypeIcon, _this.listTile, + _this.loadingSpinner, _this.messageAnnotation, _this.messageBubble, _this.messageContent,