feat: UX-002 — BouncingLoader widget replacing CircularProgressIndicator in key screens
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
import '../../../core/storage/token_storage.dart';
|
import '../../../core/storage/token_storage.dart';
|
||||||
|
import '../../../widgets/bouncing_loader.dart';
|
||||||
import '../../../core/utils/error_utils.dart';
|
import '../../../core/utils/error_utils.dart';
|
||||||
import '../models/review_models.dart';
|
import '../models/review_models.dart';
|
||||||
import '../services/review_service.dart';
|
import '../services/review_service.dart';
|
||||||
@@ -112,7 +113,7 @@ class _ReviewSectionState extends State<ReviewSection> {
|
|||||||
const Center(
|
const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(32),
|
padding: EdgeInsets.all(32),
|
||||||
child: CircularProgressIndicator(color: Color(0xFF0F45CF)),
|
child: BouncingLoader(color: Color(0xFF0F45CF)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_error != null)
|
else if (_error != null)
|
||||||
@@ -191,7 +192,7 @@ class _ReviewSectionState extends State<ReviewSection> {
|
|||||||
child: _loadingMore
|
child: _loadingMore
|
||||||
? const Padding(
|
? const Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF0F45CF))),
|
child: BouncingLoader(color: Color(0xFF0F45CF)),
|
||||||
)
|
)
|
||||||
: TextButton(
|
: TextButton(
|
||||||
onPressed: _loadMore,
|
onPressed: _loadMore,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:share_plus/share_plus.dart';
|
|||||||
import '../core/app_decoration.dart';
|
import '../core/app_decoration.dart';
|
||||||
import '../features/gamification/models/gamification_models.dart';
|
import '../features/gamification/models/gamification_models.dart';
|
||||||
import '../features/gamification/providers/gamification_provider.dart';
|
import '../features/gamification/providers/gamification_provider.dart';
|
||||||
|
import '../widgets/bouncing_loader.dart';
|
||||||
import '../widgets/glass_card.dart';
|
import '../widgets/glass_card.dart';
|
||||||
import '../widgets/landscape_section_header.dart';
|
import '../widgets/landscape_section_header.dart';
|
||||||
import '../widgets/tier_avatar_ring.dart';
|
import '../widgets/tier_avatar_ring.dart';
|
||||||
@@ -962,7 +963,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
|
|
||||||
Widget _buildDesktopLeaderboardTab(BuildContext context, GamificationProvider provider) {
|
Widget _buildDesktopLeaderboardTab(BuildContext context, GamificationProvider provider) {
|
||||||
if (provider.isLoading && provider.leaderboard.isEmpty) {
|
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;
|
final entries = provider.leaderboard;
|
||||||
@@ -1202,7 +1203,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
Widget _buildDesktopAchievementsTab(BuildContext context, GamificationProvider provider) {
|
Widget _buildDesktopAchievementsTab(BuildContext context, GamificationProvider provider) {
|
||||||
final badges = provider.achievements;
|
final badges = provider.achievements;
|
||||||
if (provider.isLoading && badges.isEmpty) {
|
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(
|
return Column(
|
||||||
@@ -2084,7 +2085,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
|
|
||||||
Widget _buildLeaderboardTab(BuildContext context, GamificationProvider provider) {
|
Widget _buildLeaderboardTab(BuildContext context, GamificationProvider provider) {
|
||||||
if (provider.isLoading && provider.leaderboard.isEmpty) {
|
if (provider.isLoading && provider.leaderboard.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: BouncingLoader());
|
||||||
}
|
}
|
||||||
|
|
||||||
final entries = provider.leaderboard;
|
final entries = provider.leaderboard;
|
||||||
@@ -2523,7 +2524,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
if (provider.isLoading && provider.achievements.isEmpty) {
|
if (provider.isLoading && provider.achievements.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: BouncingLoader());
|
||||||
}
|
}
|
||||||
|
|
||||||
final badges = provider.achievements;
|
final badges = provider.achievements;
|
||||||
@@ -2638,7 +2639,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
final rp = provider.profile?.currentRp ?? 0;
|
final rp = provider.profile?.currentRp ?? 0;
|
||||||
|
|
||||||
if (provider.isLoading && provider.shopItems.isEmpty) {
|
if (provider.isLoading && provider.shopItems.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: BouncingLoader());
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
|
|||||||
99
lib/widgets/bouncing_loader.dart
Normal file
99
lib/widgets/bouncing_loader.dart
Normal file
@@ -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<BouncingLoader> createState() => _BouncingLoaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BouncingLoaderState extends State<BouncingLoader> with TickerProviderStateMixin {
|
||||||
|
late final List<AnimationController> _controllers;
|
||||||
|
late final List<Animation<double>> _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<double>(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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user