diff --git a/lib/features/reviews/widgets/review_section.dart b/lib/features/reviews/widgets/review_section.dart index 32d93d0..1e92e10 100644 --- a/lib/features/reviews/widgets/review_section.dart +++ b/lib/features/reviews/widgets/review_section.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import '../../../core/storage/token_storage.dart'; +import '../../../widgets/bouncing_loader.dart'; import '../../../core/utils/error_utils.dart'; import '../models/review_models.dart'; import '../services/review_service.dart'; @@ -112,7 +113,7 @@ class _ReviewSectionState extends State { const Center( child: Padding( padding: EdgeInsets.all(32), - child: CircularProgressIndicator(color: Color(0xFF0F45CF)), + child: BouncingLoader(color: Color(0xFF0F45CF)), ), ) else if (_error != null) @@ -191,7 +192,7 @@ class _ReviewSectionState extends State { child: _loadingMore ? const Padding( padding: EdgeInsets.all(16), - child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF0F45CF))), + child: BouncingLoader(color: Color(0xFF0F45CF)), ) : TextButton( onPressed: _loadMore, diff --git a/lib/screens/contribute_screen.dart b/lib/screens/contribute_screen.dart index 0af4f61..68551b5 100644 --- a/lib/screens/contribute_screen.dart +++ b/lib/screens/contribute_screen.dart @@ -14,6 +14,7 @@ import 'package:share_plus/share_plus.dart'; import '../core/app_decoration.dart'; import '../features/gamification/models/gamification_models.dart'; import '../features/gamification/providers/gamification_provider.dart'; +import '../widgets/bouncing_loader.dart'; import '../widgets/glass_card.dart'; import '../widgets/landscape_section_header.dart'; import '../widgets/tier_avatar_ring.dart'; @@ -962,7 +963,7 @@ class _ContributeScreenState extends State Widget _buildDesktopLeaderboardTab(BuildContext context, GamificationProvider provider) { if (provider.isLoading && provider.leaderboard.isEmpty) { - return const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())); + return const Center(child: Padding(padding: EdgeInsets.all(40), child: BouncingLoader())); } final entries = provider.leaderboard; @@ -1202,7 +1203,7 @@ class _ContributeScreenState extends State Widget _buildDesktopAchievementsTab(BuildContext context, GamificationProvider provider) { final badges = provider.achievements; if (provider.isLoading && badges.isEmpty) { - return const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())); + return const Center(child: Padding(padding: EdgeInsets.all(40), child: BouncingLoader())); } return Column( @@ -2084,7 +2085,7 @@ class _ContributeScreenState extends State Widget _buildLeaderboardTab(BuildContext context, GamificationProvider provider) { if (provider.isLoading && provider.leaderboard.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: BouncingLoader()); } final entries = provider.leaderboard; @@ -2523,7 +2524,7 @@ class _ContributeScreenState extends State final theme = Theme.of(context); if (provider.isLoading && provider.achievements.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: BouncingLoader()); } final badges = provider.achievements; @@ -2638,7 +2639,7 @@ class _ContributeScreenState extends State final rp = provider.profile?.currentRp ?? 0; if (provider.isLoading && provider.shopItems.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: BouncingLoader()); } return Column( diff --git a/lib/widgets/bouncing_loader.dart b/lib/widgets/bouncing_loader.dart new file mode 100644 index 0000000..a38b015 --- /dev/null +++ b/lib/widgets/bouncing_loader.dart @@ -0,0 +1,99 @@ +// lib/widgets/bouncing_loader.dart +import 'package:flutter/material.dart'; + +/// Three-dot bouncing loader using Curves.bounceOut. +/// Drop-in replacement for CircularProgressIndicator on full-screen loads. +class BouncingLoader extends StatefulWidget { + final Color? color; + final double dotSize; + final double spacing; + + const BouncingLoader({ + Key? key, + this.color, + this.dotSize = 8.0, + this.spacing = 6.0, + }) : super(key: key); + + @override + State createState() => _BouncingLoaderState(); +} + +class _BouncingLoaderState extends State with TickerProviderStateMixin { + late final List _controllers; + late final List> _animations; + + static const _duration = Duration(milliseconds: 600); + static const _staggerDelay = Duration(milliseconds: 200); + + @override + void initState() { + super.initState(); + _controllers = List.generate( + 3, + (i) => AnimationController(vsync: this, duration: _duration), + ); + _animations = _controllers.map((c) { + return Tween(begin: 0.0, end: -12.0).animate( + CurvedAnimation(parent: c, curve: Curves.bounceOut), + ); + }).toList(); + + _startWithStagger(); + } + + void _startWithStagger() async { + for (int i = 0; i < _controllers.length; i++) { + await Future.delayed(i == 0 ? Duration.zero : _staggerDelay); + if (!mounted) return; + _startLoop(i); + } + } + + void _startLoop(int index) { + if (!mounted) return; + _controllers[index].forward(from: 0).whenComplete(() { + if (mounted) { + Future.delayed( + Duration(milliseconds: _staggerDelay.inMilliseconds * (_controllers.length - 1)), + () { if (mounted) _startLoop(index); }, + ); + } + }); + } + + @override + void dispose() { + for (final c in _controllers) { + c.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final dotColor = widget.color ?? Theme.of(context).colorScheme.primary; + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: widget.spacing / 2), + child: AnimatedBuilder( + animation: _animations[i], + builder: (_, __) => Transform.translate( + offset: Offset(0, _animations[i].value), + child: Container( + width: widget.dotSize, + height: widget.dotSize, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + ), + ), + ); + }), + ); + } +}