proxybuy-flutter/lib/screens/mains/hunt/widgets/leaderboard_widget.dart

1013 lines
34 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:lba/gen/assets.gen.dart';
import 'package:lba/res/colors.dart';
import '../models/hunt_card.dart';
class LeaderboardWidget extends StatefulWidget {
final List<LeaderboardEntry> entries;
final int userPoints;
final VoidCallback onClose;
final VoidCallback? onRefresh;
const LeaderboardWidget({
super.key,
required this.entries,
required this.userPoints,
required this.onClose,
this.onRefresh,
});
@override
State<LeaderboardWidget> createState() => _LeaderboardWidgetState();
}
class _LeaderboardWidgetState extends State<LeaderboardWidget>
with TickerProviderStateMixin {
late AnimationController _slideController;
late AnimationController _itemController;
late AnimationController _particleController;
late AnimationController _trophyController;
late Animation<Offset> _slideAnimation;
late List<Animation<double>> _itemAnimations;
late Animation<double> _particleAnimation;
late Animation<double> _trophyGlowAnimation;
String _searchQuery = '';
bool _showSearch = false;
@override
void initState() {
super.initState();
_slideController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_itemController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
);
_particleController = AnimationController(
duration: const Duration(milliseconds: 3000),
vsync: this,
);
_trophyController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(
CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic),
);
_itemAnimations = List.generate(
widget.entries.length,
(index) => Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _itemController,
curve: Interval(
(index * 0.08).clamp(0.0, 0.3),
((index * 0.08) + 0.7).clamp(0.1, 1.0),
curve: Curves.elasticOut,
),
),
),
);
_particleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _particleController, curve: Curves.linear),
);
_trophyGlowAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _trophyController, curve: Curves.easeInOut),
);
_slideController.forward();
_itemController.forward();
_particleController.repeat();
_trophyController.repeat(reverse: true);
}
@override
void dispose() {
_slideController.dispose();
_itemController.dispose();
_particleController.dispose();
_trophyController.dispose();
super.dispose();
}
Future<void> _close() async {
await _slideController.reverse();
widget.onClose();
}
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
final statusBarHeight = MediaQuery.of(context).padding.top;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final availableHeight =
screenHeight - statusBarHeight - bottomPadding - 100;
return SlideTransition(
position: _slideAnimation,
child: Container(
height: availableHeight,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.surface,
AppColors.cardBackground.withOpacity(0.8),
AppColors.surface,
],
stops: const [0.0, 0.5, 1.0],
),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: AppColors.shadowColor.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, -10),
spreadRadius: 2,
),
],
),
child: Stack(
children: [
AnimatedBuilder(
animation: _particleAnimation,
builder:
(context, child) => CustomPaint(
painter: ParticlesPainter(
animation: _particleAnimation,
color: AppColors.primary.withOpacity(0.1),
),
size: Size.infinite,
),
),
Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.symmetric(vertical: 4),
height: 3,
width: 32,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.primary.withOpacity(0.3),
AppColors.primary,
AppColors.primary.withOpacity(0.3),
],
),
borderRadius: BorderRadius.circular(2),
),
),
_buildCompactHeader(),
if (widget.entries.length > 10) _buildQuickSearch(),
if (widget.entries.length >= 3) _buildPodium(),
Expanded(child: _buildParticipantsList()),
],
),
],
),
),
);
}
Widget _buildCompactHeader() {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 2, 20, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.primary,
AppColors.primary.withOpacity(0.7),
],
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: SvgPicture.asset(
Assets.icons.cup.path,
color: Colors.white,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Hunt Leaderboard',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 20,
fontWeight: FontWeight.bold,
letterSpacing: -0.3,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.confirmButton.withOpacity(0.2),
AppColors.confirmButton.withOpacity(0.1),
],
),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.confirmButton.withOpacity(0.3),
width: 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: AppColors.confirmButton,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
'${widget.entries.length}',
style: TextStyle(
color: AppColors.confirmButton,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
Text(
'Live ranking updates',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
IconButton(
onPressed: _close,
padding: const EdgeInsets.all(4),
icon: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: AppColors.shadowColor.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
child: Icon(
Icons.close_rounded,
color: AppColors.textSecondary,
size: 20,
),
),
),
],
),
);
}
String _formatNumber(int number) {
if (number >= 1000) {
return '${(number / 1000).toStringAsFixed(1)}k';
}
return number.toString();
}
String _getShortName(String fullName) {
if (fullName.length <= 10) return fullName;
final parts = fullName.split(' ');
if (parts.length > 1) {
final firstName = parts[0];
final lastInitial = parts[1].isNotEmpty ? parts[1][0] : '';
if (firstName.length <= 8) {
return '$firstName $lastInitial.';
}
return '${firstName.substring(0, 8)}...';
}
return '${fullName.substring(0, 8)}...';
}
Widget _buildQuickSearch() {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: _showSearch ? 60 : 40,
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.primary.withOpacity(0.1), width: 1),
),
child: Column(
children: [
Row(
children: [
Icon(Icons.search_rounded, color: AppColors.primary, size: 20),
const SizedBox(width: 8),
Expanded(
child:
_showSearch
? TextField(
onChanged:
(value) => setState(() => _searchQuery = value),
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
),
decoration: InputDecoration(
hintText: 'Search hunters...',
hintStyle: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
)
: GestureDetector(
onTap: () => setState(() => _showSearch = true),
child: Text(
'Search in ${widget.entries.length} hunters',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
),
),
),
if (_showSearch)
IconButton(
onPressed:
() => setState(() {
_showSearch = false;
_searchQuery = '';
}),
icon: Icon(
Icons.close_rounded,
color: AppColors.textSecondary,
size: 18,
),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
),
],
),
if (_showSearch && _searchQuery.isNotEmpty)
Expanded(
child: Text(
'${_getFilteredEntries().length} results found',
style: TextStyle(
color: AppColors.primary,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
List<LeaderboardEntry> _getFilteredEntries() {
if (_searchQuery.isEmpty) return widget.entries;
return widget.entries
.where(
(entry) =>
entry.name.toLowerCase().contains(_searchQuery.toLowerCase()),
)
.toList();
}
Widget _buildListView(List<LeaderboardEntry> remainingParticipants) {
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: remainingParticipants.length,
itemExtent: 70,
itemBuilder: (context, index) {
final entry = remainingParticipants[index];
final actualIndex = index + 3;
return AnimatedBuilder(
animation:
_itemAnimations.length > actualIndex
? _itemAnimations[actualIndex]
: AlwaysStoppedAnimation(1.0),
builder: (context, child) {
final animation =
_itemAnimations.length > actualIndex
? _itemAnimations[actualIndex]
: AlwaysStoppedAnimation(1.0);
return Transform.translate(
offset: Offset(0, 20 * (1 - animation.value)),
child: Opacity(
opacity: animation.value.clamp(0.0, 1.0),
child: _buildCompactLeaderboardItem(entry, actualIndex + 1),
),
);
},
);
},
);
}
Widget _buildPodium() {
final topThree = widget.entries.take(3).toList();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
height: 150,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (topThree.length > 1) _buildPodiumPlace(topThree[1], 2, 105),
_buildPodiumPlace(topThree[0], 1, 130),
if (topThree.length > 2) _buildPodiumPlace(topThree[2], 3, 85),
],
),
);
}
Widget _buildPodiumPlace(LeaderboardEntry entry, int place, double height) {
final colors = _getPodiumColors(place);
return AnimatedBuilder(
animation: _trophyGlowAnimation,
builder: (context, child) {
return GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Container(
padding: const EdgeInsets.all(4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colors['primary'],
shape: BoxShape.circle,
),
child: Text(
'#$place',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Row(
children: [
Expanded(
child: Text(
entry.name,
style: TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.bold,
fontSize: 15,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
'${entry.totalPoints}',
style: TextStyle(
color: AppColors.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Text(
entry.avatar,
style: const TextStyle(fontSize: 18),
),
),
],
),
),
backgroundColor: AppColors.surface,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 8,
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 2),
),
);
},
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (place == 1) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
colors['primary']!.withOpacity(0.8),
colors['primary']!,
],
),
boxShadow: [
BoxShadow(
color: colors['primary']!.withOpacity(
0.3 + (_trophyGlowAnimation.value * 0.2),
),
blurRadius: 10 + (_trophyGlowAnimation.value * 5),
spreadRadius: 1,
),
],
),
child: Icon(
Icons.emoji_events_rounded,
color: Colors.white,
size: 12,
),
),
const SizedBox(height: 3),
] else
const SizedBox(height: 3),
Container(
width: 70,
height: height,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colors['light']!, colors['primary']!],
),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(10),
),
boxShadow: [
BoxShadow(
color: colors['primary']!.withOpacity(0.25),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 1,
vertical: 1,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 24,
height: 24,
child: Center(
child: Text(
entry.avatar,
style: const TextStyle(fontSize: 10),
),
),
),
SizedBox(
height: place == 1 ? 14 : 12,
child: Center(
child: Text(
_getShortName(entry.name),
style: TextStyle(
color: place == 1 ? Colors.black : Colors.white,
fontSize: place == 1 ? 12 : 10,
fontWeight:
place == 1
? FontWeight.w900
: FontWeight.bold,
height: 1.0,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
Text(
_formatNumber(entry.totalPoints),
style: TextStyle(
color: place == 1 ? Colors.black : Colors.white,
fontSize: place == 1 ? 13 : 11,
fontWeight:
place == 1 ? FontWeight.w900 : FontWeight.bold,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'#$place',
style: TextStyle(
color: place == 1 ? Colors.black : Colors.white,
fontSize: place == 1 ? 10 : 9,
fontWeight:
place == 1 ? FontWeight.w900 : FontWeight.bold,
),
),
),
],
),
),
),
],
),
);
},
);
}
Map<String, Color> _getPodiumColors(int place) {
switch (place) {
case 1:
return {
'primary': const Color(0xFFFFD700),
'light': const Color(0xFFFFFACD),
};
case 2:
return {
'primary': const Color(0xFFC0C0C0),
'light': const Color(0xFFE8E8E8),
};
case 3:
return {
'primary': const Color(0xFFCD7F32),
'light': const Color(0xFFDEB887),
};
default:
return {
'primary': AppColors.primary,
'light': AppColors.primary.withOpacity(0.3),
};
}
}
Widget _buildParticipantsList() {
final filteredEntries = _getFilteredEntries();
final remainingParticipants =
filteredEntries.length > 3
? filteredEntries.skip(3).toList()
: <LeaderboardEntry>[];
if (_searchQuery.isNotEmpty) {
final allFiltered = filteredEntries.toList();
if (allFiltered.isEmpty) {
return Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off_rounded,
color: AppColors.textSecondary,
size: 48,
),
const SizedBox(height: 16),
Text(
'No hunters found',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'Try a different search term',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
),
),
],
),
),
);
}
remainingParticipants.clear();
remainingParticipants.addAll(allFiltered);
}
if (remainingParticipants.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_searchQuery.isNotEmpty
? 'Search Results'
: 'Other Participants',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${widget.entries.length} hunters',
style: TextStyle(
color: AppColors.primary,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
Expanded(
child:
widget.onRefresh != null
? RefreshIndicator(
onRefresh: () async {
widget.onRefresh?.call();
await Future.delayed(const Duration(milliseconds: 500));
},
color: AppColors.primary,
backgroundColor: AppColors.surface,
child: _buildListView(remainingParticipants),
)
: _buildListView(remainingParticipants),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 20),
],
);
}
Widget _buildCompactLeaderboardItem(LeaderboardEntry entry, int index) {
final isCurrentUser = entry.isCurrentUser;
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
gradient:
isCurrentUser
? LinearGradient(
colors: [
AppColors.primary.withOpacity(0.08),
AppColors.primary.withOpacity(0.04),
],
)
: null,
color: isCurrentUser ? null : AppColors.cardBackground,
borderRadius: BorderRadius.circular(14),
border:
isCurrentUser
? Border.all(
color: AppColors.primary.withOpacity(0.2),
width: 1,
)
: null,
boxShadow: [
BoxShadow(
color:
isCurrentUser
? AppColors.primary.withOpacity(0.08)
: AppColors.shadowColor.withOpacity(0.03),
blurRadius: isCurrentUser ? 8 : 3,
offset: const Offset(0, 1),
),
],
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppColors.cardBackground,
shape: BoxShape.circle,
border: Border.all(
color: AppColors.divider.withOpacity(0.3),
width: 0.8,
),
),
child: Center(
child: Text(
'#$index',
style: TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
const SizedBox(width: 12),
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.primary.withOpacity(0.15),
AppColors.primary.withOpacity(0.08),
],
),
shape: BoxShape.circle,
border: Border.all(
color: AppColors.primary.withOpacity(0.2),
width: 0.8,
),
),
child: Center(
child: Text(entry.avatar, style: const TextStyle(fontSize: 18)),
),
),
const SizedBox(width: 12),
Expanded(
child: Row(
children: [
Expanded(
child: Text(
entry.name.length > 12
? '${entry.name.substring(0, 12)}...'
: entry.name,
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
fontWeight:
isCurrentUser ? FontWeight.bold : FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isCurrentUser) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'You',
style: TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
],
Text(
'${_formatNumber(entry.totalPoints)}',
style: TextStyle(
color: AppColors.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
if (entry.totalPoints > 500)
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.confirmButton.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.local_fire_department_rounded,
color: AppColors.confirmButton,
size: 14,
),
),
],
),
);
}
}
class ParticlesPainter extends CustomPainter {
final Animation<double> animation;
final Color color;
ParticlesPainter({required this.animation, required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint =
Paint()
..color = color
..style = PaintingStyle.fill;
final particleCount = 20;
for (int i = 0; i < particleCount; i++) {
final progress = (animation.value + (i / particleCount)) % 1.0;
final x = (i * 37.0) % size.width;
final y = size.height * progress;
final radius = (2.0 + (i % 3)) * (1.0 - progress);
paint.color = color.withOpacity((1.0 - progress) * 0.3);
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}