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 acaa5b8..4fac9af 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 @@ -10,6 +10,8 @@ // ************************************************************************** // ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:design_system_gallery/components/accessories/stream_audio_waveform.dart' + as _design_system_gallery_components_accessories_stream_audio_waveform; import 'package:design_system_gallery/components/accessories/stream_emoji.dart' as _design_system_gallery_components_accessories_stream_emoji; import 'package:design_system_gallery/components/accessories/stream_file_type_icons.dart' @@ -170,6 +172,23 @@ final directories = <_widgetbook.WidgetbookNode>[ _widgetbook.WidgetbookFolder( name: 'Accessories', children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamAudioWaveformSlider', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_accessories_stream_audio_waveform + .buildStreamAudioWaveformSliderPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_accessories_stream_audio_waveform + .buildStreamAudioWaveformSliderShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'StreamEmoji', useCases: [ diff --git a/apps/design_system_gallery/lib/components/accessories/stream_audio_waveform.dart b/apps/design_system_gallery/lib/components/accessories/stream_audio_waveform.dart new file mode 100644 index 0000000..f26db0d --- /dev/null +++ b/apps/design_system_gallery/lib/components/accessories/stream_audio_waveform.dart @@ -0,0 +1,452 @@ +import 'dart:math' as math; + +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: StreamAudioWaveformSlider, + path: '[Components]/Accessories', +) +Widget buildStreamAudioWaveformSliderPlayground(BuildContext context) { + final isActive = context.knobs.boolean( + label: 'Is Active', + description: 'Whether the waveform is in the active (playing) state.', + ); + + final limit = context.knobs.double.slider( + label: 'Bar Limit', + min: 20, + max: 150, + initialValue: 100, + divisions: 13, + description: 'Maximum number of bars to display.', + ); + + final inverse = context.knobs.boolean( + label: 'Inverse', + initialValue: true, + description: 'If true, bars grow from right to left.', + ); + + return _SliderPlayground( + isActive: isActive, + limit: limit.toInt(), + inverse: inverse, + ); +} + +class _SliderPlayground extends StatefulWidget { + const _SliderPlayground({ + required this.isActive, + required this.limit, + required this.inverse, + }); + + final bool isActive; + final int limit; + final bool inverse; + + @override + State<_SliderPlayground> createState() => _SliderPlaygroundState(); +} + +class _SliderPlaygroundState extends State<_SliderPlayground> { + var _progress = 0.4; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Center( + child: Padding( + padding: EdgeInsets.all(spacing.lg), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: SizedBox( + height: 40, + child: StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: _progress, + isActive: widget.isActive, + limit: widget.limit, + inverse: widget.inverse, + onChanged: (value) => setState(() => _progress = value), + ), + ), + ), + ), + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamAudioWaveformSlider, + path: '[Components]/Accessories', +) +Widget buildStreamAudioWaveformSliderShowcase(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 _StatesSection(), + SizedBox(height: spacing.xl), + const _ProgressSection(), + SizedBox(height: spacing.xl), + const _WaveformOnlySection(), + ], + ), + ), + ); +} + +// ============================================================================= +// States Section +// ============================================================================= + +class _StatesSection extends StatelessWidget { + const _StatesSection(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final boxShadow = context.streamBoxShadow; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'STATES'), + 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, + spacing: spacing.md, + children: [ + _StateDemo( + label: 'Idle', + description: 'Not playing, thumb uses idle color', + child: SizedBox( + height: 36, + child: StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 0.3, + onChanged: (_) {}, + ), + ), + ), + Divider(height: 1, color: colorScheme.borderSubtle), + _StateDemo( + label: 'Active', + description: 'Playing, thumb uses active color', + child: SizedBox( + height: 36, + child: StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 0.5, + isActive: true, + onChanged: (_) {}, + ), + ), + ), + Divider(height: 1, color: colorScheme.borderSubtle), + _StateDemo( + label: 'Empty waveform', + description: 'No waveform data, bars show at minimum height', + child: SizedBox( + height: 36, + child: StreamAudioWaveformSlider( + waveform: const [], + onChanged: (_) {}, + ), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _StateDemo extends StatelessWidget { + const _StateDemo({ + required this.label, + required this.description, + required this.child, + }); + + final String label; + final String description; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.sm, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + description, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + child, + ], + ); + } +} + +// ============================================================================= +// Progress Section +// ============================================================================= + +class _ProgressSection extends StatelessWidget { + const _ProgressSection(); + + @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: 'PROGRESS SCALE'), + 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( + 'Progress fills bars from the leading edge', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + for (final percent in [0, 25, 50, 75, 100]) ...[ + _ProgressDemo(percentage: percent), + if (percent < 100) SizedBox(height: spacing.sm), + ], + ], + ), + ), + ], + ); + } +} + +class _ProgressDemo extends StatelessWidget { + const _ProgressDemo({required this.percentage}); + + final int percentage; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Row( + children: [ + SizedBox( + width: 40, + child: Text( + '$percentage%', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + ), + SizedBox(width: spacing.sm), + Expanded( + child: SizedBox( + height: 32, + child: StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: percentage / 100, + isActive: percentage > 0 && percentage < 100, + onChanged: (_) {}, + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Waveform Only Section +// ============================================================================= + +class _WaveformOnlySection extends StatelessWidget { + const _WaveformOnlySection(); + + @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: 'WAVEFORM ONLY'), + 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, + spacing: spacing.md, + children: [ + Text( + 'StreamAudioWaveform without the slider thumb', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox( + height: 32, + width: double.infinity, + child: StreamAudioWaveform( + waveform: _sampleWaveform, + progress: 0.6, + ), + ), + SizedBox( + height: 32, + width: double.infinity, + child: StreamAudioWaveform( + waveform: _sampleWaveform, + ), + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// 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, + ), + ), + ); + } +} + +// ============================================================================= +// Sample Data +// ============================================================================= + +final List _sampleWaveform = List.generate( + 120, + (i) => (math.sin(i * 0.15) * 0.3 + 0.5 + math.sin(i * 0.4) * 0.2).clamp(0.0, 1.0), +); diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 13fda19..23de79a 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -1,3 +1,4 @@ +export 'components/accessories/stream_audio_waveform.dart'; export 'components/accessories/stream_emoji.dart' hide DefaultStreamEmoji; export 'components/accessories/stream_file_type_icon.dart' hide DefaultStreamFileTypeIcon; export 'components/avatar/stream_avatar.dart' hide DefaultStreamAvatar; diff --git a/packages/stream_core_flutter/lib/src/components/accessories/stream_audio_waveform.dart b/packages/stream_core_flutter/lib/src/components/accessories/stream_audio_waveform.dart new file mode 100644 index 0000000..73e4e9b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/accessories/stream_audio_waveform.dart @@ -0,0 +1,496 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../../theme/components/stream_audio_waveform_theme.dart'; +import '../../theme/stream_theme_extensions.dart'; + +const _kAudioWaveformSliderThumbWidth = 12.0; + +/// {@template streamAudioWaveformSlider} +/// A widget that displays an audio waveform and allows the user to interact +/// with it using a slider. +/// {@endtemplate} +class StreamAudioWaveformSlider extends StatefulWidget { + /// {@macro streamAudioWaveformSlider} + const StreamAudioWaveformSlider({ + super.key, + required this.waveform, + this.onChangeStart, + required this.onChanged, + this.onChangeEnd, + this.limit = 100, + this.color, + this.progress = 0, + this.progressColor, + this.minBarHeight, + this.spacingRatio, + this.heightScale, + this.inverse = true, + this.isActive = false, + this.activeThumbColor, + this.idleThumbColor, + this.thumbBorderColor, + }); + + /// The waveform data to be drawn. + /// + /// Note: The values should be between 0 and 1. + final List waveform; + + /// Called when the thumb starts being dragged. + final ValueChanged? onChangeStart; + + /// Called while the thumb is being dragged. + final ValueChanged? onChanged; + + /// Called when the thumb stops being dragged. + final ValueChanged? onChangeEnd; + + /// The color of the wave bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.color]. + final Color? color; + + /// The number of wave bars that will be draw in the screen. When the length + /// of [waveform] is bigger than [limit] only the X last bars will be shown. + /// + /// Defaults to 100. + final int limit; + + /// The progress of the audio track. Used to show the progress of the audio. + /// + /// Defaults to 0. + final double progress; + + /// The color of the progressed wave bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.progressColor]. + final Color? progressColor; + + /// The minimum height of the bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.minBarHeight]. + final double? minBarHeight; + + /// The ratio of the spacing between the bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.spacingRatio]. + final double? spacingRatio; + + /// The scale of the height of the bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.heightScale]. + final double? heightScale; + + /// If true, the bars grow from right to left otherwise they grow from left + /// to right. + /// + /// Defaults to true. + final bool inverse; + + /// Whether the waveform slider is in an active (playing) state. + /// + /// When true, the thumb uses [activeThumbColor]. When false, the thumb + /// uses [idleThumbColor]. + /// + /// Defaults to false. + final bool isActive; + + /// The color of the slider thumb when [isActive] is true. + /// + /// Defaults to [StreamAudioWaveformThemeData.activeThumbColor]. + final Color? activeThumbColor; + + /// The color of the slider thumb when [isActive] is false. + /// + /// Defaults to [StreamAudioWaveformThemeData.idleThumbColor]. + final Color? idleThumbColor; + + /// The color of the slider thumb border. + /// + /// Defaults to [StreamAudioWaveformThemeData.thumbBorderColor]. + final Color? thumbBorderColor; + + @override + State createState() => _StreamAudioWaveformSliderState(); +} + +class _StreamAudioWaveformSliderState extends State { + @override + Widget build(BuildContext context) { + final theme = StreamAudioWaveformTheme.of(context); + final colorScheme = context.streamColorScheme; + + final activeThumbColor = widget.activeThumbColor ?? theme.activeThumbColor ?? colorScheme.accentPrimary; + final idleThumbColor = widget.idleThumbColor ?? theme.idleThumbColor ?? colorScheme.accentNeutral; + final thumbColor = widget.isActive ? activeThumbColor : idleThumbColor; + final thumbBorderColor = widget.thumbBorderColor ?? theme.thumbBorderColor ?? colorScheme.borderOnAccent; + + return HorizontalSlider( + onChangeStart: widget.onChangeStart, + onChanged: widget.onChanged, + onChangeEnd: widget.onChangeEnd, + child: LayoutBuilder( + builder: (context, constraints) => Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + StreamAudioWaveform( + waveform: widget.waveform, + limit: widget.limit, + color: widget.color, + progress: widget.progress, + progressColor: widget.progressColor, + minBarHeight: widget.minBarHeight, + spacingRatio: widget.spacingRatio, + heightScale: widget.heightScale, + inverse: widget.inverse, + ), + Builder( + // Just using it for the calculation of the thumb position. + builder: (context) { + final progressWidth = constraints.maxWidth * widget.progress; + return AnimatedPositioned( + curve: const ElasticOutCurve(1.05), + duration: const Duration(milliseconds: 300), + left: progressWidth - _kAudioWaveformSliderThumbWidth / 2, + child: StreamAudioWaveformSliderThumb( + color: thumbColor, + borderColor: thumbBorderColor, + ), + ); + }, + ), + ], + ), + ), + ); + } +} + +/// {@template streamAudioWaveformSliderThumb} +/// A widget that represents the thumb of the [StreamAudioWaveformSlider]. +/// {@endtemplate} +class StreamAudioWaveformSliderThumb extends StatelessWidget { + /// {@macro streamAudioWaveformSliderThumb} + const StreamAudioWaveformSliderThumb({ + super.key, + this.size = _kAudioWaveformSliderThumbWidth, + this.color = Colors.white, + this.borderColor = const Color(0xffecebeb), + }); + + /// The width of the thumb. + final double size; + + /// The color of the thumb. + final Color color; + + /// The border color of the thumb. + final Color borderColor; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + foregroundDecoration: BoxDecoration( + color: color, + border: Border.all( + color: borderColor, + strokeAlign: BorderSide.strokeAlignCenter, + width: 2, + ), + shape: BoxShape.circle, + ), + ); + } +} + +/// {@template streamAudioWaveform} +/// A widget that displays an audio waveform. +/// +/// The waveform is drawn using the [waveform] data. The waveform is drawn +/// horizontally and the bars grow from right to left. +/// {@endtemplate} +class StreamAudioWaveform extends StatelessWidget { + /// {@macro streamAudioWaveform} + const StreamAudioWaveform({ + super.key, + required this.waveform, + this.limit = 100, + this.color, + this.progress = 0, + this.progressColor, + this.minBarHeight, + this.spacingRatio, + this.heightScale, + this.inverse = true, + }); + + /// The waveform data to be drawn. + /// + /// Note: The values should be between 0 and 1. + final List waveform; + + /// The color of the wave bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.color]. + final Color? color; + + /// The number of wave bars that will be drawn on the screen. When the length + /// of [waveform] is bigger than [limit] only the last [limit] bars will be + /// shown. + /// + /// Defaults to 100. + final int limit; + + /// The progress of the audio track. Used to show the progress of the audio. + /// + /// Defaults to 0. + final double progress; + + /// The color of the progressed wave bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.progressColor]. + final Color? progressColor; + + /// The minimum height of the bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.minBarHeight]. + final double? minBarHeight; + + /// The ratio of the spacing between the bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.spacingRatio]. + final double? spacingRatio; + + /// The scale of the height of the bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.heightScale]. + final double? heightScale; + + /// If true, the bars grow from right to left otherwise they grow from left + /// to right. + /// + /// Defaults to true. + final bool inverse; + + @override + Widget build(BuildContext context) { + final theme = StreamAudioWaveformTheme.of(context); + final colorScheme = context.streamColorScheme; + + final color = this.color ?? theme.color ?? colorScheme.borderOpacity25; + final progressColor = this.progressColor ?? theme.progressColor ?? colorScheme.accentPrimary; + final minBarHeight = this.minBarHeight ?? theme.minBarHeight ?? 2.0; + final spacingRatio = this.spacingRatio ?? theme.spacingRatio ?? 0.3; + final heightScale = this.heightScale ?? theme.heightScale ?? 1.0; + + return CustomPaint( + willChange: true, + painter: _WaveformPainter( + waveform: waveform.reversed, + limit: limit, + color: color, + progress: progress, + progressColor: progressColor, + minBarHeight: minBarHeight, + spacingRatio: spacingRatio, + heightScale: heightScale, + inverse: inverse, + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + _WaveformPainter({ + required Iterable waveform, + this.limit = 100, + this.color = const Color(0xff7E828B), + this.progress = 0, + this.progressColor = const Color(0xff005FFF), + this.minBarHeight = 2, + double spacingRatio = 0.3, + this.heightScale = 1, + this.inverse = true, + }) : waveform = [ + ...waveform.take(limit), + if (waveform.length < limit) + // Fill the remaining bars with 0 value + ...List.filled(limit - waveform.length, 0), + ], + spacingRatio = spacingRatio.clamp(0, 1); + + final List waveform; + final Color color; + final int limit; + final double progress; + final Color progressColor; + final double minBarHeight; + final double spacingRatio; + final bool inverse; + final double heightScale; + + @override + void paint(Canvas canvas, Size size) { + final canvasWidth = size.width; + final canvasHeight = size.height; + + // The total spacing between the bars in the canvas. + final spacingWidth = canvasWidth * spacingRatio; + final barsWidth = canvasWidth - spacingWidth; + final barWidth = barsWidth / limit; + final barSpacing = spacingWidth / (limit - 1); + final progressWidth = progress * canvasWidth; + + void paintBar(int index, double barValue) { + var dx = index * (barWidth + barSpacing) + barWidth / 2; + if (inverse) dx = canvasWidth - dx; + final dy = canvasHeight / 2; + + final barHeight = math.max(barValue * canvasHeight, minBarHeight); + + final rect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(dx, dy), + width: barWidth, + height: barHeight, + ), + const Radius.circular(2), + ); + + final waveColor = switch (dx <= progressWidth) { + true => progressColor, + false => color, + }; + + final wavePaint = Paint() + ..color = waveColor + ..strokeCap = StrokeCap.round; + + canvas.drawRRect(rect, wavePaint); + } + + // Paint all the bars + waveform.forEachIndexed(paintBar); + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) => + !const ListEquality().equals(waveform, oldDelegate.waveform) || + color != oldDelegate.color || + limit != oldDelegate.limit || + progress != oldDelegate.progress || + progressColor != oldDelegate.progressColor || + minBarHeight != oldDelegate.minBarHeight || + spacingRatio != oldDelegate.spacingRatio || + heightScale != oldDelegate.heightScale || + inverse != oldDelegate.inverse; +} + +/// {@template horizontalSlider} +/// A widget that allows interactive horizontal sliding gestures. +/// +/// The `HorizontalSlider` widget wraps a child widget and allows users to +/// perform sliding gestures horizontally. It can be configured with callbacks +/// to notify the parent widget about the changes in the horizontal value. +/// {@endtemplate} +class HorizontalSlider extends StatefulWidget { + /// Creates a horizontal slider. + const HorizontalSlider({ + super.key, + required this.child, + required this.onChanged, + this.onChangeStart, + this.onChangeEnd, + }); + + /// The child widget wrapped by the slider. + final Widget child; + + /// Called when the horizontal value starts changing. + final ValueChanged? onChangeStart; + + /// Called when the horizontal value changes. + final ValueChanged? onChanged; + + /// Called when the horizontal value stops changing. + final ValueChanged? onChangeEnd; + + @override + State createState() => _HorizontalSliderState(); +} + +class _HorizontalSliderState extends State { + var _active = false; + + /// Returns true if the slider is interactive. + bool get isInteractive => widget.onChanged != null; + + /// Converts the visual position to a value based on the text direction. + double _getValueFromVisualPosition(double visualPosition) { + final textDirection = Directionality.of(context); + final value = switch (textDirection) { + TextDirection.rtl => 1.0 - visualPosition, + TextDirection.ltr => visualPosition, + }; + + return clampDouble(value, 0, 1); + } + + /// Converts the local position to a horizontal value. + double _getValueFromLocalPosition(Offset globalPosition) { + final box = context.findRenderObject()! as RenderBox; + final localPosition = box.globalToLocal(globalPosition); + final visualPosition = localPosition.dx / box.size.width; + return _getValueFromVisualPosition(visualPosition); + } + + void _handleDragStart(DragStartDetails details) { + if (!_active && isInteractive) { + _active = true; + final value = _getValueFromLocalPosition(details.globalPosition); + widget.onChangeStart?.call(value); + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + _handleHorizontalDrag(details.globalPosition); + } + + void _handleDragEnd(DragEndDetails details) { + if (!mounted) return; + + if (_active && mounted) { + final value = _getValueFromLocalPosition(details.globalPosition); + widget.onChangeEnd?.call(value); + _active = false; + } + } + + /// Handles the sliding gesture. + void _handleHorizontalDrag(Offset globalPosition) { + if (!mounted) return; + + if (isInteractive) { + final value = _getValueFromLocalPosition(globalPosition); + widget.onChanged?.call(value); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + child: widget.child, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer.dart b/packages/stream_core_flutter/lib/src/components/message_composer.dart index 5cf1e94..9a681bc 100644 --- a/packages/stream_core_flutter/lib/src/components/message_composer.dart +++ b/packages/stream_core_flutter/lib/src/components/message_composer.dart @@ -1,3 +1,4 @@ +export 'message_composer/attachment/message_composer_attachment_container.dart'; export 'message_composer/attachment/message_composer_file_attachment.dart'; export 'message_composer/attachment/message_composer_link_preview_attachment.dart'; export 'message_composer/attachment/message_composer_media_file_attachment.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_container.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_container.dart new file mode 100644 index 0000000..570bcbd --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_container.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../stream_core_flutter.dart'; + +class StreamMessageComposerAttachmentContainer extends StatelessWidget { + const StreamMessageComposerAttachmentContainer({ + super.key, + required this.child, + this.onRemovePressed, + this.backgroundColor, + this.borderColor, + this.padding, + }); + + final Widget child; + final VoidCallback? onRemovePressed; + final Color? backgroundColor; + final Color? borderColor; + final EdgeInsets? padding; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Stack( + children: [ + Container( + margin: EdgeInsets.all(spacing.xxs), + padding: padding ?? EdgeInsets.all(spacing.xs), + foregroundDecoration: borderColor != null + ? BoxDecoration( + borderRadius: BorderRadius.all(context.streamRadius.lg), + border: Border.all(color: borderColor!), + ) + : null, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.all(context.streamRadius.lg), + ), + child: child, + ), + if (onRemovePressed case final VoidCallback onRemovePressed?) + Align( + alignment: Alignment.topRight, + child: StreamRemoveControl(onPressed: onRemovePressed), + ), + ], + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_file_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_file_attachment.dart index f5f2ef0..e3798b5 100644 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_file_attachment.dart +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_file_attachment.dart @@ -19,53 +19,35 @@ class MessageComposerFileAttachment extends StatelessWidget { @override Widget build(BuildContext context) { final textColor = context.streamColorScheme.textPrimary; - final titleStyle = context.streamTextTheme.captionEmphasis.copyWith(color: textColor); - final spacing = context.streamSpacing; - return Stack( - children: [ - Container( - margin: EdgeInsets.all(spacing.xxs), - padding: EdgeInsets.fromLTRB(spacing.md, spacing.md, spacing.sm, spacing.md), - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.all(context.streamRadius.lg), - border: Border.all( - color: context.streamColorScheme.borderDefault, + + return StreamMessageComposerAttachmentContainer( + padding: EdgeInsets.fromLTRB(spacing.md, spacing.md, spacing.sm, spacing.md), + onRemovePressed: onRemovePressed, + borderColor: context.streamColorScheme.borderDefault, + child: Row( + children: [ + ?fileTypeIcon, + SizedBox(width: spacing.xs), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title case final title?) + Text( + title, + style: titleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ?subtitle, + ], ), ), - decoration: BoxDecoration( - borderRadius: BorderRadius.all(context.streamRadius.lg), - ), - child: Row( - children: [ - ?fileTypeIcon, - SizedBox(width: spacing.xs), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title case final title?) - Text( - title, - style: titleStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ?subtitle, - ], - ), - ), - ], - ), - ), - if (onRemovePressed case final VoidCallback onRemovePressed?) - Align( - alignment: Alignment.topRight, - child: StreamRemoveControl(onPressed: onRemovePressed), - ), - ], + ], + ), ); } } diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart index cde9dbc..13eac27 100644 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart @@ -28,75 +28,62 @@ class MessageComposerLinkPreviewAttachment extends StatelessWidget { final bodyStyle = context.streamTextTheme.metadataDefault.copyWith(color: textColor); final spacing = context.streamSpacing; - return Stack( - children: [ - Container( - margin: EdgeInsets.all(spacing.xxs), - padding: EdgeInsets.all(spacing.xs), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.all(context.streamRadius.lg), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (image != null) ...[ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: BorderRadius.all(context.streamRadius.md), - image: DecorationImage(image: image!, fit: BoxFit.cover), + return StreamMessageComposerAttachmentContainer( + onRemovePressed: onRemovePressed, + backgroundColor: backgroundColor, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (image != null) ...[ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(context.streamRadius.md), + image: DecorationImage(image: image!, fit: BoxFit.cover), + ), + ), + SizedBox(width: spacing.xs), + ], + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title case final title?) + Text( + title, + style: titleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - SizedBox(width: spacing.xs), - ], - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title case final title?) - Text( - title, - style: titleStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (subtitle case final subtitle?) - Text( - subtitle, - style: bodyStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (url case final url?) - Row( - children: [ - Icon(context.streamIcons.chainLink3, size: 12), - SizedBox(width: spacing.xxs), - Expanded( - child: Text( - url, - style: bodyStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], + if (subtitle case final subtitle?) + Text( + subtitle, + style: bodyStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (url case final url?) + Row( + children: [ + Icon(context.streamIcons.chainLink3, size: 12), + SizedBox(width: spacing.xxs), + Expanded( + child: Text( + url, + style: bodyStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - ], - ), - ), - ], - ), - ), - if (onRemovePressed case final VoidCallback onRemovePressed?) - Align( - alignment: Alignment.topRight, - child: StreamRemoveControl(onPressed: onRemovePressed), + ], + ), + ], + ), ), - ], + ], + ), ); } } diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart index 3a298b7..a6d7a31 100644 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart @@ -32,72 +32,59 @@ class MessageComposerReplyAttachment extends StatelessWidget { final textColor = messageStyle?.textColor; final spacing = context.streamSpacing; - return Stack( - children: [ - Container( - margin: EdgeInsets.all(spacing.xxs), - padding: EdgeInsets.all(spacing.xs), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.all(context.streamRadius.lg), - ), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - margin: const EdgeInsets.only(top: 2, bottom: 2), - color: indicatorColor, - child: const SizedBox( - width: 2, - height: double.infinity, - ), - ), - SizedBox(width: spacing.xs), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + return StreamMessageComposerAttachmentContainer( + onRemovePressed: onRemovePressed, + backgroundColor: backgroundColor, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2, bottom: 2), + color: indicatorColor, + child: const SizedBox( + width: 2, + height: double.infinity, + ), + ), + SizedBox(width: spacing.xs), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: context.streamTextTheme.metadataEmphasis.copyWith(color: textColor)), + Row( children: [ - Text(title, style: context.streamTextTheme.metadataEmphasis.copyWith(color: textColor)), - Row( - children: [ - if (image != null) ...[ - Icon(context.streamIcons.camera1, size: 12), - SizedBox(width: spacing.xxs), - ], - Expanded( - child: Text( - subtitle, - style: context.streamTextTheme.metadataDefault.copyWith(color: textColor), - ), - ), - ], + if (image != null) ...[ + Icon(context.streamIcons.camera1, size: 12), + SizedBox(width: spacing.xxs), + ], + Expanded( + child: Text( + subtitle, + style: context.streamTextTheme.metadataDefault.copyWith(color: textColor), + ), ), ], ), - ), - if (image != null) ...[ - SizedBox(width: spacing.xs), - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: BorderRadius.all(context.streamRadius.md), - image: DecorationImage(image: image!, fit: BoxFit.cover), - ), - ), ], - ], + ), ), - ), + if (image != null) ...[ + SizedBox(width: spacing.xs), + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(context.streamRadius.md), + image: DecorationImage(image: image!, fit: BoxFit.cover), + ), + ), + ], + ], ), - if (onRemovePressed case final VoidCallback onRemovePressed?) - Align( - alignment: Alignment.topRight, - child: StreamRemoveControl(onPressed: onRemovePressed), - ), - ], + ), ); } } diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index 78452e7..688f380 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -1,5 +1,6 @@ export 'factory/stream_component_factory.dart'; +export 'theme/components/stream_audio_waveform_theme.dart'; export 'theme/components/stream_avatar_theme.dart'; export 'theme/components/stream_badge_count_theme.dart'; export 'theme/components/stream_button_theme.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_audio_waveform_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_audio_waveform_theme.dart new file mode 100644 index 0000000..c74c4f5 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_audio_waveform_theme.dart @@ -0,0 +1,163 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_audio_waveform_theme.g.theme.dart'; + +/// Applies an audio waveform theme to descendant [StreamAudioWaveform] and +/// [StreamAudioWaveformSlider] widgets. +/// +/// Wrap a subtree with [StreamAudioWaveformTheme] to override waveform +/// styling. Access the merged theme using +/// [BuildContext.streamAudioWaveformTheme]. +/// +/// {@tool snippet} +/// +/// Override waveform colors for a specific section: +/// +/// ```dart +/// StreamAudioWaveformTheme( +/// data: StreamAudioWaveformThemeData( +/// color: Colors.grey, +/// progressColor: Colors.blue, +/// activeThumbColor: Colors.blue, +/// idleThumbColor: Colors.grey, +/// ), +/// child: StreamAudioWaveformSlider( +/// waveform: waveformData, +/// onChanged: (value) {}, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamAudioWaveformThemeData], which describes the waveform theme. +/// * [StreamAudioWaveform], the waveform widget affected by this theme. +/// * [StreamAudioWaveformSlider], the slider widget affected by this theme. +class StreamAudioWaveformTheme extends InheritedTheme { + /// Creates an audio waveform theme that controls descendant waveforms. + const StreamAudioWaveformTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The audio waveform theme data for descendant widgets. + final StreamAudioWaveformThemeData data; + + /// Returns the [StreamAudioWaveformThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamAudioWaveformTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + /// + /// This allows partial overrides - for example, overriding only + /// [StreamAudioWaveformThemeData.color] while inheriting other properties + /// from the global theme. + static StreamAudioWaveformThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).audioWaveformTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamAudioWaveformTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamAudioWaveformTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamAudioWaveform] and +/// [StreamAudioWaveformSlider] widgets. +/// +/// {@tool snippet} +/// +/// Customize waveform appearance globally: +/// +/// ```dart +/// StreamTheme( +/// audioWaveformTheme: StreamAudioWaveformThemeData( +/// color: Colors.grey, +/// progressColor: Colors.blue, +/// minBarHeight: 2, +/// spacingRatio: 0.3, +/// heightScale: 1, +/// activeThumbColor: Colors.blue, +/// idleThumbColor: Colors.grey, +/// thumbBorderColor: Colors.grey, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamAudioWaveform], the widget that uses this theme data. +/// * [StreamAudioWaveformSlider], the slider widget that uses this theme data. +/// * [StreamAudioWaveformTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamAudioWaveformThemeData with _$StreamAudioWaveformThemeData { + /// Creates audio waveform theme data with optional style overrides. + const StreamAudioWaveformThemeData({ + this.color, + this.progressColor, + this.minBarHeight, + this.spacingRatio, + this.heightScale, + this.activeThumbColor, + this.idleThumbColor, + this.thumbBorderColor, + }); + + /// The color of the waveform bars. + /// + /// Falls back to [StreamColorScheme.borderOpacity25]. + final Color? color; + + /// The color of the progressed waveform bars. + /// + /// Falls back to [StreamColorScheme.accentPrimary]. + final Color? progressColor; + + /// The minimum height of the waveform bars. + /// + /// Falls back to 2 logical pixels. + final double? minBarHeight; + + /// The ratio of the spacing between the waveform bars. + /// + /// Falls back to 0.3. + final double? spacingRatio; + + /// The scale of the height of the waveform bars. + /// + /// Falls back to 1. + final double? heightScale; + + /// The color of the slider thumb when the waveform is active (playing). + /// + /// Falls back to [StreamColorScheme.accentPrimary]. + final Color? activeThumbColor; + + /// The color of the slider thumb when the waveform is idle (not playing). + /// + /// Falls back to [StreamColorScheme.accentNeutral]. + final Color? idleThumbColor; + + /// The border color of the slider thumb. + /// + /// Falls back to [StreamColorScheme.borderOnAccent]. + final Color? thumbBorderColor; + + /// Linearly interpolate between two [StreamAudioWaveformThemeData] objects. + static StreamAudioWaveformThemeData? lerp( + StreamAudioWaveformThemeData? a, + StreamAudioWaveformThemeData? b, + double t, + ) => _$StreamAudioWaveformThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_audio_waveform_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_audio_waveform_theme.g.theme.dart new file mode 100644 index 0000000..c335d3c --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_audio_waveform_theme.g.theme.dart @@ -0,0 +1,130 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_audio_waveform_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamAudioWaveformThemeData { + bool get canMerge => true; + + static StreamAudioWaveformThemeData? lerp( + StreamAudioWaveformThemeData? a, + StreamAudioWaveformThemeData? 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 StreamAudioWaveformThemeData( + color: Color.lerp(a.color, b.color, t), + progressColor: Color.lerp(a.progressColor, b.progressColor, t), + minBarHeight: lerpDouble$(a.minBarHeight, b.minBarHeight, t), + spacingRatio: lerpDouble$(a.spacingRatio, b.spacingRatio, t), + heightScale: lerpDouble$(a.heightScale, b.heightScale, t), + activeThumbColor: Color.lerp(a.activeThumbColor, b.activeThumbColor, t), + idleThumbColor: Color.lerp(a.idleThumbColor, b.idleThumbColor, t), + thumbBorderColor: Color.lerp(a.thumbBorderColor, b.thumbBorderColor, t), + ); + } + + StreamAudioWaveformThemeData copyWith({ + Color? color, + Color? progressColor, + double? minBarHeight, + double? spacingRatio, + double? heightScale, + Color? activeThumbColor, + Color? idleThumbColor, + Color? thumbBorderColor, + }) { + final _this = (this as StreamAudioWaveformThemeData); + + return StreamAudioWaveformThemeData( + color: color ?? _this.color, + progressColor: progressColor ?? _this.progressColor, + minBarHeight: minBarHeight ?? _this.minBarHeight, + spacingRatio: spacingRatio ?? _this.spacingRatio, + heightScale: heightScale ?? _this.heightScale, + activeThumbColor: activeThumbColor ?? _this.activeThumbColor, + idleThumbColor: idleThumbColor ?? _this.idleThumbColor, + thumbBorderColor: thumbBorderColor ?? _this.thumbBorderColor, + ); + } + + StreamAudioWaveformThemeData merge(StreamAudioWaveformThemeData? other) { + final _this = (this as StreamAudioWaveformThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + color: other.color, + progressColor: other.progressColor, + minBarHeight: other.minBarHeight, + spacingRatio: other.spacingRatio, + heightScale: other.heightScale, + activeThumbColor: other.activeThumbColor, + idleThumbColor: other.idleThumbColor, + thumbBorderColor: other.thumbBorderColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamAudioWaveformThemeData); + final _other = (other as StreamAudioWaveformThemeData); + + return _other.color == _this.color && + _other.progressColor == _this.progressColor && + _other.minBarHeight == _this.minBarHeight && + _other.spacingRatio == _this.spacingRatio && + _other.heightScale == _this.heightScale && + _other.activeThumbColor == _this.activeThumbColor && + _other.idleThumbColor == _this.idleThumbColor && + _other.thumbBorderColor == _this.thumbBorderColor; + } + + @override + int get hashCode { + final _this = (this as StreamAudioWaveformThemeData); + + return Object.hash( + runtimeType, + _this.color, + _this.progressColor, + _this.minBarHeight, + _this.spacingRatio, + _this.heightScale, + _this.activeThumbColor, + _this.idleThumbColor, + _this.thumbBorderColor, + ); + } +} 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 3870084..ef74552 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import 'components/stream_audio_waveform_theme.dart'; import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; import 'components/stream_button_theme.dart'; @@ -89,6 +90,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamTextTheme? textTheme, StreamBoxShadow? boxShadow, // Components themes + StreamAudioWaveformThemeData? audioWaveformTheme, StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, StreamButtonThemeData? buttonTheme, @@ -116,6 +118,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { boxShadow ??= isDark ? StreamBoxShadow.dark() : StreamBoxShadow.light(); // Components + audioWaveformTheme ??= const StreamAudioWaveformThemeData(); avatarTheme ??= const StreamAvatarThemeData(); badgeCountTheme ??= const StreamBadgeCountThemeData(); buttonTheme ??= const StreamButtonThemeData(); @@ -137,6 +140,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { colorScheme: colorScheme, textTheme: textTheme, boxShadow: boxShadow, + audioWaveformTheme: audioWaveformTheme, avatarTheme: avatarTheme, badgeCountTheme: badgeCountTheme, buttonTheme: buttonTheme, @@ -172,6 +176,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.colorScheme, required this.textTheme, required this.boxShadow, + required this.audioWaveformTheme, required this.avatarTheme, required this.badgeCountTheme, required this.buttonTheme, @@ -243,6 +248,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The box shadow (elevation) values for this theme. final StreamBoxShadow boxShadow; + /// The audio waveform theme for this theme. + final StreamAudioWaveformThemeData audioWaveformTheme; + /// The avatar theme for this theme. final StreamAvatarThemeData avatarTheme; @@ -305,6 +313,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { colorScheme: colorScheme, textTheme: newTextTheme, boxShadow: boxShadow, + audioWaveformTheme: audioWaveformTheme, avatarTheme: avatarTheme, badgeCountTheme: badgeCountTheme, buttonTheme: buttonTheme, 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 5af58e2..f6742f5 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 @@ -20,6 +20,7 @@ mixin _$StreamTheme on ThemeExtension { StreamColorScheme? colorScheme, StreamTextTheme? textTheme, StreamBoxShadow? boxShadow, + StreamAudioWaveformThemeData? audioWaveformTheme, StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, StreamButtonThemeData? buttonTheme, @@ -43,6 +44,7 @@ mixin _$StreamTheme on ThemeExtension { colorScheme: colorScheme ?? _this.colorScheme, textTheme: textTheme ?? _this.textTheme, boxShadow: boxShadow ?? _this.boxShadow, + audioWaveformTheme: audioWaveformTheme ?? _this.audioWaveformTheme, avatarTheme: avatarTheme ?? _this.avatarTheme, badgeCountTheme: badgeCountTheme ?? _this.badgeCountTheme, buttonTheme: buttonTheme ?? _this.buttonTheme, @@ -79,6 +81,11 @@ mixin _$StreamTheme on ThemeExtension { (_this.colorScheme.lerp(other.colorScheme, t) as StreamColorScheme), textTheme: (_this.textTheme.lerp(other.textTheme, t) as StreamTextTheme), boxShadow: StreamBoxShadow.lerp(_this.boxShadow, other.boxShadow, t)!, + audioWaveformTheme: StreamAudioWaveformThemeData.lerp( + _this.audioWaveformTheme, + other.audioWaveformTheme, + t, + )!, avatarTheme: StreamAvatarThemeData.lerp( _this.avatarTheme, other.avatarTheme, @@ -150,6 +157,7 @@ mixin _$StreamTheme on ThemeExtension { _other.colorScheme == _this.colorScheme && _other.textTheme == _this.textTheme && _other.boxShadow == _this.boxShadow && + _other.audioWaveformTheme == _this.audioWaveformTheme && _other.avatarTheme == _this.avatarTheme && _other.badgeCountTheme == _this.badgeCountTheme && _other.buttonTheme == _this.buttonTheme && @@ -167,7 +175,7 @@ mixin _$StreamTheme on ThemeExtension { int get hashCode { final _this = (this as StreamTheme); - return Object.hash( + return Object.hashAll([ runtimeType, _this.brightness, _this.icons, @@ -177,6 +185,7 @@ mixin _$StreamTheme on ThemeExtension { _this.colorScheme, _this.textTheme, _this.boxShadow, + _this.audioWaveformTheme, _this.avatarTheme, _this.badgeCountTheme, _this.buttonTheme, @@ -188,6 +197,6 @@ mixin _$StreamTheme on ThemeExtension { _this.inputTheme, _this.onlineIndicatorTheme, _this.progressBarTheme, - ); + ]); } } 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 d6c414e..6149ec0 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 @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; +import 'components/stream_audio_waveform_theme.dart'; import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; import 'components/stream_button_theme.dart'; @@ -65,6 +66,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamBoxShadow] from the current theme. StreamBoxShadow get streamBoxShadow => streamTheme.boxShadow; + /// Returns the [StreamAudioWaveformThemeData] from the nearest ancestor. + StreamAudioWaveformThemeData get streamAudioWaveformTheme => StreamAudioWaveformTheme.of(this); + /// Returns the [StreamAvatarThemeData] from the nearest ancestor. StreamAvatarThemeData get streamAvatarTheme => StreamAvatarTheme.of(this); diff --git a/packages/stream_core_flutter/pubspec.yaml b/packages/stream_core_flutter/pubspec.yaml index 0d19659..46b8af0 100644 --- a/packages/stream_core_flutter/pubspec.yaml +++ b/packages/stream_core_flutter/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: cached_network_image: ^3.4.1 + collection: ^1.19.0 flutter: sdk: flutter flutter_svg: ^2.2.3 diff --git a/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_dark_states.png b/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_dark_states.png new file mode 100644 index 0000000..a5140bf Binary files /dev/null and b/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_dark_states.png differ diff --git a/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_light_states.png b/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_light_states.png new file mode 100644 index 0000000..87fad47 Binary files /dev/null and b/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_light_states.png differ diff --git a/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_slider_custom_theme.png b/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_slider_custom_theme.png new file mode 100644 index 0000000..76e8c24 Binary files /dev/null and b/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_slider_custom_theme.png differ diff --git a/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_slider_dark_states.png b/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_slider_dark_states.png new file mode 100644 index 0000000..c64a036 Binary files /dev/null and b/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_slider_dark_states.png differ diff --git a/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_slider_light_states.png b/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_slider_light_states.png new file mode 100644 index 0000000..1f2fa1a Binary files /dev/null and b/packages/stream_core_flutter/test/components/accessories/goldens/ci/stream_audio_waveform_slider_light_states.png differ diff --git a/packages/stream_core_flutter/test/components/accessories/stream_audio_waveform_golden_test.dart b/packages/stream_core_flutter/test/components/accessories/stream_audio_waveform_golden_test.dart new file mode 100644 index 0000000..c016b5f --- /dev/null +++ b/packages/stream_core_flutter/test/components/accessories/stream_audio_waveform_golden_test.dart @@ -0,0 +1,314 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:math' as math; + +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +final List _sampleWaveform = List.generate( + 120, + (i) => (math.sin(i * 0.15) * 0.3 + 0.5 + math.sin(i * 0.4) * 0.2).clamp(0.0, 1.0), +); + +void main() { + group('StreamAudioWaveformSlider Golden Tests', () { + goldenTest( + 'renders light theme states', + fileName: 'stream_audio_waveform_slider_light_states', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 300), + children: [ + GoldenTestScenario( + name: 'idle_no_progress', + child: _buildSliderInTheme( + StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 0, + onChanged: (_) {}, + ), + ), + ), + GoldenTestScenario( + name: 'idle_with_progress', + child: _buildSliderInTheme( + StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 0.4, + onChanged: (_) {}, + ), + ), + ), + GoldenTestScenario( + name: 'active_with_progress', + child: _buildSliderInTheme( + StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 0.5, + isActive: true, + onChanged: (_) {}, + ), + ), + ), + GoldenTestScenario( + name: 'active_full_progress', + child: _buildSliderInTheme( + StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 1, + isActive: true, + onChanged: (_) {}, + ), + ), + ), + GoldenTestScenario( + name: 'empty_waveform', + child: _buildSliderInTheme( + StreamAudioWaveformSlider( + waveform: const [], + progress: 0, + onChanged: (_) {}, + ), + ), + ), + ], + ), + ); + + goldenTest( + 'renders dark theme states', + fileName: 'stream_audio_waveform_slider_dark_states', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 300), + children: [ + GoldenTestScenario( + name: 'idle_no_progress', + child: _buildSliderInTheme( + StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 0, + onChanged: (_) {}, + ), + brightness: Brightness.dark, + ), + ), + GoldenTestScenario( + name: 'idle_with_progress', + child: _buildSliderInTheme( + StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 0.4, + onChanged: (_) {}, + ), + brightness: Brightness.dark, + ), + ), + GoldenTestScenario( + name: 'active_with_progress', + child: _buildSliderInTheme( + StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 0.5, + isActive: true, + onChanged: (_) {}, + ), + brightness: Brightness.dark, + ), + ), + GoldenTestScenario( + name: 'active_full_progress', + child: _buildSliderInTheme( + StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 1, + isActive: true, + onChanged: (_) {}, + ), + brightness: Brightness.dark, + ), + ), + ], + ), + ); + + goldenTest( + 'renders custom theme overrides', + fileName: 'stream_audio_waveform_slider_custom_theme', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 300), + children: [ + GoldenTestScenario( + name: 'custom_colors_idle', + child: _buildSliderInTheme( + StreamAudioWaveformTheme( + data: const StreamAudioWaveformThemeData( + color: Colors.purple, + progressColor: Colors.orange, + idleThumbColor: Colors.grey, + thumbBorderColor: Colors.black, + ), + child: StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 0.5, + onChanged: (_) {}, + ), + ), + ), + ), + GoldenTestScenario( + name: 'custom_colors_active', + child: _buildSliderInTheme( + StreamAudioWaveformTheme( + data: const StreamAudioWaveformThemeData( + color: Colors.purple, + progressColor: Colors.orange, + activeThumbColor: Colors.red, + thumbBorderColor: Colors.black, + ), + child: StreamAudioWaveformSlider( + waveform: _sampleWaveform, + progress: 0.5, + isActive: true, + onChanged: (_) {}, + ), + ), + ), + ), + ], + ), + ); + }); + + group('StreamAudioWaveform Golden Tests', () { + goldenTest( + 'renders light theme states', + fileName: 'stream_audio_waveform_light_states', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 300), + children: [ + GoldenTestScenario( + name: 'no_progress', + child: _buildWaveformInTheme( + StreamAudioWaveform( + waveform: _sampleWaveform, + progress: 0, + ), + ), + ), + GoldenTestScenario( + name: 'half_progress', + child: _buildWaveformInTheme( + StreamAudioWaveform( + waveform: _sampleWaveform, + progress: 0.5, + ), + ), + ), + GoldenTestScenario( + name: 'full_progress', + child: _buildWaveformInTheme( + StreamAudioWaveform( + waveform: _sampleWaveform, + progress: 1, + ), + ), + ), + GoldenTestScenario( + name: 'empty_waveform', + child: _buildWaveformInTheme( + const StreamAudioWaveform( + waveform: [], + progress: 0, + ), + ), + ), + ], + ), + ); + + goldenTest( + 'renders dark theme states', + fileName: 'stream_audio_waveform_dark_states', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 300), + children: [ + GoldenTestScenario( + name: 'no_progress', + child: _buildWaveformInTheme( + StreamAudioWaveform( + waveform: _sampleWaveform, + progress: 0, + ), + brightness: Brightness.dark, + ), + ), + GoldenTestScenario( + name: 'half_progress', + child: _buildWaveformInTheme( + StreamAudioWaveform( + waveform: _sampleWaveform, + progress: 0.5, + ), + brightness: Brightness.dark, + ), + ), + GoldenTestScenario( + name: 'full_progress', + child: _buildWaveformInTheme( + StreamAudioWaveform( + waveform: _sampleWaveform, + progress: 1, + ), + brightness: Brightness.dark, + ), + ), + ], + ), + ); + }); +} + +Widget _buildSliderInTheme( + Widget slider, { + Brightness brightness = Brightness.light, +}) { + final streamTheme = StreamTheme(brightness: brightness); + return Theme( + data: ThemeData( + brightness: brightness, + extensions: [streamTheme], + ), + child: Builder( + builder: (context) => Material( + color: StreamTheme.of(context).colorScheme.backgroundApp, + child: Padding( + padding: const EdgeInsets.all(8), + child: SizedBox(width: 280, height: 36, child: slider), + ), + ), + ), + ); +} + +Widget _buildWaveformInTheme( + Widget waveform, { + Brightness brightness = Brightness.light, +}) { + final streamTheme = StreamTheme(brightness: brightness); + return Theme( + data: ThemeData( + brightness: brightness, + extensions: [streamTheme], + ), + child: Builder( + builder: (context) => Material( + color: StreamTheme.of(context).colorScheme.backgroundApp, + child: Padding( + padding: const EdgeInsets.all(8), + child: SizedBox(width: 280, height: 32, child: waveform), + ), + ), + ), + ); +}