import 'dart:math' as math; import 'dart:ui' show lerpDouble; import 'package:flutter/material.dart'; import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; class SCRotatingDotsLoading extends StatefulWidget { const SCRotatingDotsLoading({ super.key, this.size, this.color = SocialChatTheme.primaryLight, this.dotCount = 8, this.duration = const Duration(milliseconds: 900), this.minAdaptiveSize = 14, this.maxAdaptiveSize = 40, }); final double? size; final Color color; final int dotCount; final Duration duration; final double minAdaptiveSize; final double maxAdaptiveSize; @override State createState() => _SCRotatingDotsLoadingState(); } class _SCRotatingDotsLoadingState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: widget.duration) ..repeat(); } @override void didUpdateWidget(covariant SCRotatingDotsLoading oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.duration != widget.duration) { _controller ..duration = widget.duration ..repeat(); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final configuredSize = widget.size; if (configuredSize != null) { return _buildIndicator(configuredSize); } return LayoutBuilder( builder: (context, constraints) { final adaptiveSize = _resolveAdaptiveSize(constraints); return Center(child: _buildIndicator(adaptiveSize)); }, ); } Widget _buildIndicator(double size) { final normalizedSize = size.clamp(8.0, 200.0); final dotCount = math.max(1, widget.dotCount); final maxDotSize = normalizedSize * 0.26; final radius = normalizedSize / 2 - maxDotSize / 2; return RepaintBoundary( child: SizedBox.square( dimension: normalizedSize, child: AnimatedBuilder( animation: _controller, builder: (context, child) { return Transform.rotate( angle: _controller.value * math.pi * 2, child: Stack( children: List.generate(dotCount, (index) { final progress = 1 - (index / dotCount); final dotScale = lerpDouble(0.42, 1, progress)!; final opacity = lerpDouble(0.18, 1, progress)!; final dotSize = maxDotSize * dotScale; final angle = (math.pi * 2 * index / dotCount) - math.pi / 2; final dx = normalizedSize / 2 + math.cos(angle) * radius; final dy = normalizedSize / 2 + math.sin(angle) * radius; return Positioned( left: dx - dotSize / 2, top: dy - dotSize / 2, child: Container( width: dotSize, height: dotSize, decoration: BoxDecoration( color: widget.color.withValues(alpha: opacity), shape: BoxShape.circle, ), ), ); }), ), ); }, ), ), ); } double _resolveAdaptiveSize(BoxConstraints constraints) { final values = [ constraints.maxWidth, constraints.maxHeight, ].where((value) => value.isFinite && value > 0).toList(); final shortest = values.isEmpty ? 28.0 : values.reduce(math.min); final scaled = shortest * 0.52; return math.max( widget.minAdaptiveSize, math.min(widget.maxAdaptiveSize, scaled), ); } }