1252 lines
43 KiB
Dart
1252 lines
43 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'dart:math' as math;
|
|
import 'dart:async';
|
|
|
|
import '../../core/themes/draw_slot_theme.dart';
|
|
|
|
// Base class for all draw animations
|
|
abstract class DrawAnimation extends StatefulWidget {
|
|
final List<Map<String, dynamic>> members;
|
|
final Function(String winnerId) onDrawComplete;
|
|
final String? serverSeed;
|
|
final String? clientSeed;
|
|
final int? nonce;
|
|
final Duration animationDuration;
|
|
|
|
const DrawAnimation({
|
|
super.key,
|
|
required this.members,
|
|
required this.onDrawComplete,
|
|
this.serverSeed,
|
|
this.clientSeed,
|
|
this.nonce,
|
|
this.animationDuration = const Duration(seconds: 4),
|
|
});
|
|
}
|
|
|
|
// 1. Card Flip Animation - Shows member cards flipping rapidly
|
|
class CardFlipDrawAnimation extends DrawAnimation {
|
|
const CardFlipDrawAnimation({
|
|
super.key,
|
|
required super.members,
|
|
required super.onDrawComplete,
|
|
super.serverSeed,
|
|
super.clientSeed,
|
|
super.nonce,
|
|
super.animationDuration,
|
|
});
|
|
|
|
@override
|
|
State<CardFlipDrawAnimation> createState() => _CardFlipDrawAnimationState();
|
|
}
|
|
|
|
class _CardFlipDrawAnimationState extends State<CardFlipDrawAnimation>
|
|
with TickerProviderStateMixin {
|
|
late AnimationController _flipController;
|
|
late AnimationController _scaleController;
|
|
late Animation<double> _flipAnimation;
|
|
late Animation<double> _scaleAnimation;
|
|
|
|
bool _isAnimating = false;
|
|
bool _isComplete = false;
|
|
String? _winnerId;
|
|
int _currentIndex = 0;
|
|
Timer? _flipTimer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_flipController = AnimationController(
|
|
duration: const Duration(milliseconds: 200),
|
|
vsync: this,
|
|
);
|
|
|
|
_scaleController = AnimationController(
|
|
duration: const Duration(milliseconds: 500),
|
|
vsync: this,
|
|
);
|
|
|
|
_flipAnimation = Tween<double>(
|
|
begin: 0,
|
|
end: 1,
|
|
).animate(CurvedAnimation(
|
|
parent: _flipController,
|
|
curve: Curves.easeInOut,
|
|
));
|
|
|
|
_scaleAnimation = Tween<double>(
|
|
begin: 1.0,
|
|
end: 1.1,
|
|
).animate(CurvedAnimation(
|
|
parent: _scaleController,
|
|
curve: Curves.elasticOut,
|
|
));
|
|
|
|
// Auto-start after delay
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
if (mounted) _startAnimation();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_flipController.dispose();
|
|
_scaleController.dispose();
|
|
_flipTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startAnimation() {
|
|
if (_isAnimating) return;
|
|
|
|
setState(() {
|
|
_isAnimating = true;
|
|
});
|
|
|
|
_calculateWinner();
|
|
_startCardFlipping();
|
|
}
|
|
|
|
void _calculateWinner() {
|
|
final serverSeed = widget.serverSeed ?? 'default_server_seed';
|
|
final clientSeed = widget.clientSeed ?? 'default_client_seed';
|
|
final nonce = widget.nonce ?? DateTime.now().millisecondsSinceEpoch;
|
|
|
|
final combinedSeed = '$serverSeed:$clientSeed:$nonce';
|
|
final hash = _generateHash(combinedSeed);
|
|
final randomValue = _hashToNumber(hash);
|
|
final selectedIndex = randomValue % widget.members.length;
|
|
|
|
_winnerId = widget.members[selectedIndex]['id'];
|
|
}
|
|
|
|
void _startCardFlipping() {
|
|
final totalFlips = 20 + (widget.members.length * 2);
|
|
int currentFlip = 0;
|
|
|
|
_flipTimer = Timer.periodic(const Duration(milliseconds: 150), (timer) {
|
|
if (currentFlip >= totalFlips) {
|
|
timer.cancel();
|
|
_completeAnimation();
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_currentIndex = currentFlip % widget.members.length;
|
|
});
|
|
|
|
_flipController.forward().then((_) {
|
|
_flipController.reset();
|
|
});
|
|
|
|
currentFlip++;
|
|
});
|
|
}
|
|
|
|
void _completeAnimation() {
|
|
setState(() {
|
|
_isAnimating = false;
|
|
_isComplete = true;
|
|
});
|
|
|
|
_scaleController.forward();
|
|
widget.onDrawComplete(_winnerId!);
|
|
}
|
|
|
|
String _generateHash(String input) {
|
|
int hash = 0;
|
|
for (int i = 0; i < input.length; i++) {
|
|
hash = ((hash << 5) - hash + input.codeUnitAt(i)) & 0xffffffff;
|
|
}
|
|
return hash.abs().toString();
|
|
}
|
|
|
|
int _hashToNumber(String hash) {
|
|
if (hash.length < 10) {
|
|
hash = hash.padRight(10, '0');
|
|
}
|
|
return int.parse(hash.substring(0, 10));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 300.w,
|
|
height: 400.h,
|
|
child: Column(
|
|
children: [
|
|
// Title
|
|
Text(
|
|
'Card Flip Draw',
|
|
style: TextStyle(
|
|
fontSize: 20.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.purple.shade700,
|
|
),
|
|
),
|
|
SizedBox(height: 20.h),
|
|
|
|
// Card Display
|
|
Expanded(
|
|
child: AnimatedBuilder(
|
|
animation: _flipAnimation,
|
|
builder: (context, child) {
|
|
return Transform(
|
|
alignment: Alignment.center,
|
|
transform: Matrix4.identity()
|
|
..setEntry(3, 2, 0.001)
|
|
..rotateY(_flipAnimation.value * math.pi),
|
|
child: AnimatedBuilder(
|
|
animation: _scaleAnimation,
|
|
builder: (context, child) {
|
|
return Transform.scale(
|
|
scale: _isComplete ? _scaleAnimation.value : 1.0,
|
|
child: Container(
|
|
width: 250.w,
|
|
height: 300.h,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16.r),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.2),
|
|
blurRadius: 10.r,
|
|
offset: Offset(0, 5.h),
|
|
),
|
|
],
|
|
),
|
|
child: _flipAnimation.value < 0.5
|
|
? _buildCardFront()
|
|
: _buildCardBack(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
// Status
|
|
SizedBox(height: 20.h),
|
|
if (_isAnimating)
|
|
Text(
|
|
'Flipping cards...',
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: Colors.orange.shade600,
|
|
),
|
|
)
|
|
else if (_isComplete)
|
|
Text(
|
|
'Winner Selected!',
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: Colors.green.shade600,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCardFront() {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
Colors.purple.shade400,
|
|
Colors.purple.shade600,
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(16.r),
|
|
),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.casino,
|
|
size: 60.w,
|
|
color: Colors.white,
|
|
),
|
|
SizedBox(height: 16.h),
|
|
Text(
|
|
'LuckyChit',
|
|
style: TextStyle(
|
|
fontSize: 24.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
Text(
|
|
'Draw',
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: Colors.white.withOpacity(0.8),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCardBack() {
|
|
final member = widget.members[_currentIndex];
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: _isComplete && _winnerId == member['id']
|
|
? [Colors.green.shade400, Colors.green.shade600]
|
|
: [Colors.blue.shade400, Colors.blue.shade600],
|
|
),
|
|
borderRadius: BorderRadius.circular(16.r),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(20.w),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 40.r,
|
|
backgroundColor: Colors.white.withOpacity(0.2),
|
|
child: Text(
|
|
(member['name']?.isNotEmpty == true
|
|
? member['name'].substring(0, 1)
|
|
: 'M').toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 32.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 16.h),
|
|
Text(
|
|
member['name'] ?? 'Unknown',
|
|
style: TextStyle(
|
|
fontSize: 18.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
Text(
|
|
member['mobile'] ?? '',
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
color: Colors.white.withOpacity(0.8),
|
|
),
|
|
),
|
|
if (_isComplete && _winnerId == member['id']) ...[
|
|
SizedBox(height: 16.h),
|
|
Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(20.r),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.emoji_events, color: Colors.white, size: 20.w),
|
|
SizedBox(width: 8.w),
|
|
Text(
|
|
'WINNER!',
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 2. Slot Machine Animation - Numbers/names spinning like a slot machine
|
|
class SlotMachineDrawAnimation extends DrawAnimation {
|
|
const SlotMachineDrawAnimation({
|
|
super.key,
|
|
required super.members,
|
|
required super.onDrawComplete,
|
|
super.serverSeed,
|
|
super.clientSeed,
|
|
super.nonce,
|
|
super.animationDuration,
|
|
});
|
|
|
|
@override
|
|
State<SlotMachineDrawAnimation> createState() => _SlotMachineDrawAnimationState();
|
|
}
|
|
|
|
class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
|
|
with TickerProviderStateMixin {
|
|
late AnimationController _slotController;
|
|
late Animation<double> _slotAnimation;
|
|
late AnimationController _pulseController;
|
|
late Animation<double> _pulseAnimation;
|
|
|
|
bool _isAnimating = false;
|
|
bool _isComplete = false;
|
|
String? _winnerId;
|
|
final List<String> _baseNames = [];
|
|
List<String> _displayNames = [];
|
|
int _currentIndex = 0;
|
|
Timer? _slotTimer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_slotController = AnimationController(
|
|
duration: const Duration(milliseconds: 400),
|
|
vsync: this,
|
|
);
|
|
|
|
_slotAnimation = Tween<double>(
|
|
begin: 0,
|
|
end: 1,
|
|
).animate(CurvedAnimation(
|
|
parent: _slotController,
|
|
curve: Curves.linear,
|
|
));
|
|
|
|
_pulseController = AnimationController(
|
|
duration: const Duration(milliseconds: 800),
|
|
vsync: this,
|
|
);
|
|
|
|
_pulseAnimation = Tween<double>(
|
|
begin: 0.8,
|
|
end: 1.2,
|
|
).animate(CurvedAnimation(
|
|
parent: _pulseController,
|
|
curve: Curves.easeInOut,
|
|
));
|
|
|
|
_prepareDisplayNames();
|
|
|
|
// Auto-start after delay
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
if (mounted) _startAnimation();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_slotController.dispose();
|
|
_pulseController.dispose();
|
|
_slotTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _prepareDisplayNames() {
|
|
_baseNames.clear();
|
|
_baseNames.addAll(
|
|
widget.members.map((m) => m['name']?.toString() ?? 'Unknown'),
|
|
);
|
|
|
|
if (_baseNames.isEmpty) {
|
|
_baseNames.add('Unknown');
|
|
}
|
|
|
|
// Ensure we have at least 7 entries to fill the slot windows
|
|
if (_baseNames.length < 7) {
|
|
final original = List<String>.from(_baseNames);
|
|
while (_baseNames.length < 7) {
|
|
_baseNames.addAll(original);
|
|
}
|
|
}
|
|
|
|
_shuffleBaseNames();
|
|
_currentIndex = 0;
|
|
_updateDisplayWindow();
|
|
}
|
|
|
|
void _shuffleBaseNames() {
|
|
final seedSource = DateTime.now().millisecondsSinceEpoch.toString();
|
|
final hash = _generateHash(seedSource);
|
|
final randomValue = _hashToNumber(hash);
|
|
final random = math.Random(randomValue);
|
|
|
|
for (var i = _baseNames.length - 1; i > 0; i--) {
|
|
final j = random.nextInt(i + 1);
|
|
final temp = _baseNames[i];
|
|
_baseNames[i] = _baseNames[j];
|
|
_baseNames[j] = temp;
|
|
}
|
|
}
|
|
|
|
void _updateDisplayWindow() {
|
|
_displayNames = List.generate(
|
|
7,
|
|
(index) => _baseNames[(_currentIndex + index) % _baseNames.length],
|
|
);
|
|
}
|
|
|
|
void _startAnimation() {
|
|
if (_isAnimating) return;
|
|
|
|
setState(() {
|
|
_isAnimating = true;
|
|
});
|
|
|
|
_calculateWinner();
|
|
_startSlotMachine();
|
|
_pulseController.repeat(reverse: true);
|
|
}
|
|
|
|
void _calculateWinner() {
|
|
final serverSeed = widget.serverSeed ?? 'default_server_seed';
|
|
final clientSeed = widget.clientSeed ?? 'default_client_seed';
|
|
final nonce = widget.nonce ?? DateTime.now().millisecondsSinceEpoch;
|
|
|
|
final combinedSeed = '$serverSeed:$clientSeed:$nonce';
|
|
final hash = _generateHash(combinedSeed);
|
|
final randomValue = _hashToNumber(hash);
|
|
final selectedIndex = randomValue % widget.members.length;
|
|
|
|
final raw = widget.members[selectedIndex]['id'];
|
|
_winnerId = raw?.toString() ?? '';
|
|
}
|
|
|
|
void _startSlotMachine() {
|
|
final totalSpins = 25 + (widget.members.length * 2);
|
|
int currentSpin = 0;
|
|
|
|
_slotTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
|
|
if (currentSpin >= totalSpins) {
|
|
timer.cancel();
|
|
_completeAnimation();
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_currentIndex = (_currentIndex + 1) % _baseNames.length;
|
|
_updateDisplayWindow();
|
|
});
|
|
|
|
_slotController.forward().then((_) {
|
|
_slotController.reset();
|
|
});
|
|
|
|
currentSpin++;
|
|
|
|
// Gradually slow down the animation
|
|
if (currentSpin > totalSpins * 0.7) {
|
|
// Slow down in the last 30% of spins
|
|
timer.cancel();
|
|
_slotTimer = Timer.periodic(
|
|
Duration(milliseconds: 350 + (currentSpin * 15)),
|
|
(newTimer) {
|
|
if (currentSpin >= totalSpins) {
|
|
newTimer.cancel();
|
|
_completeAnimation();
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_currentIndex = (_currentIndex + 1) % _baseNames.length;
|
|
_updateDisplayWindow();
|
|
});
|
|
|
|
_slotController.forward().then((_) {
|
|
_slotController.reset();
|
|
});
|
|
|
|
currentSpin++;
|
|
}
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _completeAnimation() {
|
|
// Always center the real winner — indexOf(name) breaks when names repeat.
|
|
String winnerName = 'Unknown';
|
|
for (final m in widget.members) {
|
|
if ('${m['id']}' == '${_winnerId}') {
|
|
winnerName = m['name']?.toString() ?? 'Unknown';
|
|
break;
|
|
}
|
|
}
|
|
|
|
final otherNames = widget.members
|
|
.where((m) => '${m['id']}' != '${_winnerId}')
|
|
.map((m) => m['name']?.toString() ?? 'Unknown')
|
|
.toList();
|
|
final filler = otherNames.isEmpty ? <String>[winnerName] : otherNames;
|
|
|
|
final window = List<String>.filled(7, '');
|
|
window[3] = winnerName;
|
|
int o = 0;
|
|
for (int i = 0; i < 7; i++) {
|
|
if (i == 3) continue;
|
|
window[i] = filler[o % filler.length];
|
|
o++;
|
|
}
|
|
|
|
HapticFeedback.mediumImpact();
|
|
setState(() {
|
|
_isAnimating = false;
|
|
_isComplete = true;
|
|
_displayNames = window;
|
|
});
|
|
|
|
_pulseController.stop();
|
|
|
|
Future.delayed(const Duration(milliseconds: 650), () {
|
|
if (_winnerId != null && _winnerId!.isNotEmpty) {
|
|
widget.onDrawComplete(_winnerId!);
|
|
}
|
|
});
|
|
}
|
|
|
|
String _generateHash(String input) {
|
|
int hash = 0;
|
|
for (int i = 0; i < input.length; i++) {
|
|
hash = ((hash << 5) - hash + input.codeUnitAt(i)) & 0xffffffff;
|
|
}
|
|
return hash.abs().toString();
|
|
}
|
|
|
|
int _hashToNumber(String hash) {
|
|
if (hash.length < 10) {
|
|
hash = hash.padRight(10, '0');
|
|
}
|
|
return int.parse(hash.substring(0, 10));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final scheme = theme.colorScheme;
|
|
final d = DrawSlotTheming(scheme, theme.brightness);
|
|
|
|
String? winnerName;
|
|
if (_winnerId != null && _winnerId!.isNotEmpty) {
|
|
for (final m in widget.members) {
|
|
if ('${m['id']}' == '${_winnerId}') {
|
|
winnerName = m['name']?.toString();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
final maxW = MediaQuery.sizeOf(context).width - 40.w;
|
|
|
|
return Semantics(
|
|
label: 'Slot machine draw',
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(maxWidth: maxW.clamp(280.w, 400.w)),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
height: 480.h.clamp(380.h, 560.h),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (_isAnimating)
|
|
Padding(
|
|
padding: EdgeInsets.only(bottom: 10.h),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 15.w,
|
|
height: 15.w,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2.2,
|
|
color: scheme.tertiary,
|
|
),
|
|
),
|
|
SizedBox(width: 10.w),
|
|
Text(
|
|
'Selecting winner…',
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
color: scheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Container(
|
|
width: double.infinity,
|
|
decoration: d.slotChassisDecoration(),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(14.w),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: d.slotInnerWell,
|
|
borderRadius: BorderRadius.circular(14.r),
|
|
border: Border.all(
|
|
color: scheme.outline.withOpacity(0.28),
|
|
),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(13.r),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final rowH = constraints.maxHeight / 7;
|
|
return Stack(
|
|
clipBehavior: Clip.hardEdge,
|
|
fit: StackFit.expand,
|
|
children: [
|
|
AnimatedBuilder(
|
|
animation: _slotAnimation,
|
|
builder: (context, child) {
|
|
return Column(
|
|
children:
|
|
List.generate(7, (index) {
|
|
final displayIndex = index <
|
|
_displayNames.length
|
|
? index
|
|
: 0;
|
|
final name =
|
|
_displayNames[displayIndex];
|
|
final isWinner =
|
|
_isComplete && index == 3;
|
|
final isCenterHighlight =
|
|
_isAnimating && index == 3;
|
|
|
|
return Expanded(
|
|
child: AnimatedBuilder(
|
|
animation: _pulseAnimation,
|
|
builder: (context, child) {
|
|
final scale = isWinner ||
|
|
isCenterHighlight
|
|
? 1.05
|
|
: 1.0;
|
|
List<Color> rowColors;
|
|
if (isWinner) {
|
|
rowColors = d.rowColorsWinner();
|
|
} else if (isCenterHighlight) {
|
|
rowColors =
|
|
d.rowColorsSpinning();
|
|
} else {
|
|
rowColors = d.rowColorsIdle();
|
|
}
|
|
|
|
final borderColor = isWinner
|
|
? scheme.tertiary
|
|
.withOpacity(0.9)
|
|
: isCenterHighlight
|
|
? scheme.primary
|
|
.withOpacity(0.55)
|
|
: scheme.outline
|
|
.withOpacity(0.22);
|
|
|
|
return AnimatedContainer(
|
|
duration: const Duration(
|
|
milliseconds: 220),
|
|
curve: Curves.easeInOut,
|
|
transform: Matrix4.identity()
|
|
..scale(scale),
|
|
child: Container(
|
|
width: double.infinity,
|
|
margin:
|
|
EdgeInsets.symmetric(
|
|
vertical: 4.h,
|
|
horizontal: 10.w,
|
|
),
|
|
decoration: BoxDecoration(
|
|
gradient:
|
|
LinearGradient(
|
|
colors: rowColors,
|
|
begin: Alignment
|
|
.centerLeft,
|
|
end: Alignment
|
|
.centerRight,
|
|
),
|
|
borderRadius:
|
|
BorderRadius
|
|
.circular(12.r),
|
|
border: Border.all(
|
|
color: borderColor,
|
|
width: isWinner
|
|
? 2.w
|
|
: 1.w,
|
|
),
|
|
boxShadow: [
|
|
if (isWinner)
|
|
BoxShadow(
|
|
color: scheme
|
|
.primary
|
|
.withOpacity(
|
|
0.45),
|
|
blurRadius: 16.r,
|
|
offset: Offset(
|
|
0, 4.h),
|
|
),
|
|
],
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
name.length > 22
|
|
? '${name.substring(0, 22)}…'
|
|
: name,
|
|
style: theme
|
|
.textTheme
|
|
.titleMedium
|
|
?.copyWith(
|
|
fontSize: isWinner
|
|
? 21.sp
|
|
: isCenterHighlight
|
|
? 18.sp
|
|
: 16.sp,
|
|
fontWeight: isWinner
|
|
? FontWeight.w900
|
|
: FontWeight
|
|
.w700,
|
|
color: isWinner
|
|
? d
|
|
.winnerRowText
|
|
: d.idleRowText,
|
|
letterSpacing: 0.2,
|
|
shadows: theme
|
|
.brightness ==
|
|
Brightness
|
|
.dark
|
|
? [
|
|
Shadow(
|
|
color: Colors
|
|
.black
|
|
.withOpacity(
|
|
0.45),
|
|
blurRadius:
|
|
3,
|
|
offset:
|
|
const Offset(
|
|
0,
|
|
1),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
textAlign:
|
|
TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow
|
|
.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
},
|
|
),
|
|
Positioned(
|
|
top: rowH * 2.5,
|
|
left: 8.w,
|
|
right: 8.w,
|
|
height: 1,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
Colors.transparent,
|
|
scheme.tertiary.withOpacity(0.45),
|
|
Colors.transparent,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: rowH * 4.5,
|
|
left: 8.w,
|
|
right: 8.w,
|
|
height: 1,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
Colors.transparent,
|
|
scheme.tertiary.withOpacity(0.45),
|
|
Colors.transparent,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_isComplete)
|
|
Positioned(
|
|
top: rowH * 3,
|
|
left: 0,
|
|
right: 0,
|
|
height: rowH,
|
|
child: IgnorePointer(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius:
|
|
BorderRadius.circular(12.r),
|
|
border: Border.all(
|
|
color: d.winnerFrameColor,
|
|
width: 2.5.w,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: scheme.tertiary
|
|
.withOpacity(0.35),
|
|
blurRadius: 18.r,
|
|
spreadRadius: 0.5,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 14.h),
|
|
if (_isComplete && winnerName != null)
|
|
Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 16.w, vertical: 16.h),
|
|
decoration: d.winnerSummaryDecoration(),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.emoji_events_rounded,
|
|
color: d.winnerSummaryIcon,
|
|
size: 28.w,
|
|
),
|
|
SizedBox(width: 12.w),
|
|
Flexible(
|
|
child: Text(
|
|
winnerName,
|
|
style: d.winnerSummaryTitle(theme.textTheme)
|
|
.copyWith(fontSize: 18.sp),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 3. Number Roulette Animation - Numbers spinning like a roulette wheel
|
|
class NumberRouletteDrawAnimation extends DrawAnimation {
|
|
const NumberRouletteDrawAnimation({
|
|
super.key,
|
|
required super.members,
|
|
required super.onDrawComplete,
|
|
super.serverSeed,
|
|
super.clientSeed,
|
|
super.nonce,
|
|
super.animationDuration,
|
|
});
|
|
|
|
@override
|
|
State<NumberRouletteDrawAnimation> createState() => _NumberRouletteDrawAnimationState();
|
|
}
|
|
|
|
class _NumberRouletteDrawAnimationState extends State<NumberRouletteDrawAnimation>
|
|
with TickerProviderStateMixin {
|
|
late AnimationController _rouletteController;
|
|
late Animation<double> _rouletteAnimation;
|
|
|
|
bool _isAnimating = false;
|
|
bool _isComplete = false;
|
|
String? _winnerId;
|
|
int _selectedNumber = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_rouletteController = AnimationController(
|
|
duration: widget.animationDuration,
|
|
vsync: this,
|
|
);
|
|
|
|
_rouletteAnimation = Tween<double>(
|
|
begin: 0,
|
|
end: 1,
|
|
).animate(CurvedAnimation(
|
|
parent: _rouletteController,
|
|
curve: Curves.easeOut,
|
|
));
|
|
|
|
_rouletteController.addStatusListener((status) {
|
|
if (status == AnimationStatus.completed) {
|
|
_completeAnimation();
|
|
}
|
|
});
|
|
|
|
// Auto-start after delay
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
if (mounted) _startAnimation();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_rouletteController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startAnimation() {
|
|
if (_isAnimating) return;
|
|
|
|
setState(() {
|
|
_isAnimating = true;
|
|
});
|
|
|
|
_calculateWinner();
|
|
_rouletteController.forward();
|
|
}
|
|
|
|
void _calculateWinner() {
|
|
final serverSeed = widget.serverSeed ?? 'default_server_seed';
|
|
final clientSeed = widget.clientSeed ?? 'default_client_seed';
|
|
final nonce = widget.nonce ?? DateTime.now().millisecondsSinceEpoch;
|
|
|
|
final combinedSeed = '$serverSeed:$clientSeed:$nonce';
|
|
final hash = _generateHash(combinedSeed);
|
|
final randomValue = _hashToNumber(hash);
|
|
final selectedIndex = randomValue % widget.members.length;
|
|
|
|
_winnerId = widget.members[selectedIndex]['id'];
|
|
_selectedNumber = selectedIndex + 1;
|
|
}
|
|
|
|
void _completeAnimation() {
|
|
setState(() {
|
|
_isAnimating = false;
|
|
_isComplete = true;
|
|
});
|
|
|
|
widget.onDrawComplete(_winnerId!);
|
|
}
|
|
|
|
String _generateHash(String input) {
|
|
int hash = 0;
|
|
for (int i = 0; i < input.length; i++) {
|
|
hash = ((hash << 5) - hash + input.codeUnitAt(i)) & 0xffffffff;
|
|
}
|
|
return hash.abs().toString();
|
|
}
|
|
|
|
int _hashToNumber(String hash) {
|
|
if (hash.length < 10) {
|
|
hash = hash.padRight(10, '0');
|
|
}
|
|
return int.parse(hash.substring(0, 10));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 300.w,
|
|
height: 400.h,
|
|
child: Column(
|
|
children: [
|
|
// Title
|
|
Text(
|
|
'Number Roulette',
|
|
style: TextStyle(
|
|
fontSize: 20.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.red.shade700,
|
|
),
|
|
),
|
|
SizedBox(height: 20.h),
|
|
|
|
// Roulette Display
|
|
Expanded(
|
|
child: AnimatedBuilder(
|
|
animation: _rouletteAnimation,
|
|
builder: (context, child) {
|
|
return Container(
|
|
width: 250.w,
|
|
height: 250.w,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(
|
|
colors: [
|
|
Colors.red.shade50,
|
|
Colors.red.shade100,
|
|
Colors.red.shade200,
|
|
],
|
|
),
|
|
border: Border.all(color: Colors.red.shade400, width: 4.w),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.2),
|
|
blurRadius: 15.r,
|
|
offset: Offset(0, 8.h),
|
|
),
|
|
],
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
// Numbers around the circle
|
|
...List.generate(widget.members.length, (index) {
|
|
final angle = (index * 2 * math.pi / widget.members.length) - math.pi / 2;
|
|
final currentAngle = _rouletteAnimation.value * 2 * math.pi * 5 + angle;
|
|
final isSelected = _isComplete && index == (_selectedNumber - 1);
|
|
|
|
return Positioned(
|
|
left: 125.w + 100.w * math.cos(currentAngle) - 15.w,
|
|
top: 125.w + 100.w * math.sin(currentAngle) - 15.w,
|
|
child: Container(
|
|
width: 30.w,
|
|
height: 30.w,
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? Colors.green.shade600
|
|
: Colors.red.shade600,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: Colors.white,
|
|
width: 2.w,
|
|
),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'${index + 1}',
|
|
style: TextStyle(
|
|
fontSize: 12.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
|
|
// Center pointer
|
|
Center(
|
|
child: Container(
|
|
width: 0,
|
|
height: 0,
|
|
child: CustomPaint(
|
|
painter: PointerPainter(),
|
|
size: Size(20.w, 40.h),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Winner highlight
|
|
if (_isComplete)
|
|
Center(
|
|
child: Container(
|
|
width: 60.w,
|
|
height: 60.w,
|
|
decoration: BoxDecoration(
|
|
color: Colors.green.shade400.withOpacity(0.3),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Center(
|
|
child: Icon(
|
|
Icons.emoji_events,
|
|
color: Colors.yellow,
|
|
size: 30.w,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
// Status
|
|
SizedBox(height: 20.h),
|
|
if (_isAnimating)
|
|
Text(
|
|
'Spinning...',
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: Colors.red.shade600,
|
|
),
|
|
)
|
|
else if (_isComplete)
|
|
Column(
|
|
children: [
|
|
Text(
|
|
'Winner: #$_selectedNumber',
|
|
style: TextStyle(
|
|
fontSize: 18.sp,
|
|
color: Colors.green.shade600,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
widget.members.firstWhere((m) => m['id'] == _winnerId)['name'] ?? 'Unknown',
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: Colors.grey.shade600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class PointerPainter extends CustomPainter {
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = Colors.black
|
|
..style = PaintingStyle.fill;
|
|
|
|
final path = Path();
|
|
path.moveTo(size.width / 2, 0);
|
|
path.lineTo(0, size.height);
|
|
path.lineTo(size.width, size.height);
|
|
path.close();
|
|
|
|
canvas.drawPath(path, paint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|