375 lines
10 KiB
Dart
375 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'dart:math' as math;
|
|
|
|
class AnimatedDrawWheel extends StatefulWidget {
|
|
final List<Map<String, dynamic>> members;
|
|
final Function(String winnerId) onDrawComplete;
|
|
final String? serverSeed;
|
|
final String? clientSeed;
|
|
final int? nonce;
|
|
|
|
const AnimatedDrawWheel({
|
|
super.key,
|
|
required this.members,
|
|
required this.onDrawComplete,
|
|
this.serverSeed,
|
|
this.clientSeed,
|
|
this.nonce,
|
|
});
|
|
|
|
@override
|
|
State<AnimatedDrawWheel> createState() => _AnimatedDrawWheelState();
|
|
}
|
|
|
|
class _AnimatedDrawWheelState extends State<AnimatedDrawWheel>
|
|
with TickerProviderStateMixin {
|
|
late AnimationController _spinController;
|
|
late AnimationController _pulseController;
|
|
late Animation<double> _spinAnimation;
|
|
late Animation<double> _pulseAnimation;
|
|
|
|
bool _isSpinning = false;
|
|
bool _isDrawComplete = false;
|
|
String? _winnerId;
|
|
int _selectedIndex = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_spinController = AnimationController(
|
|
duration: const Duration(seconds: 4),
|
|
vsync: this,
|
|
);
|
|
|
|
_pulseController = AnimationController(
|
|
duration: const Duration(milliseconds: 1000),
|
|
vsync: this,
|
|
);
|
|
|
|
_spinAnimation = Tween<double>(
|
|
begin: 0,
|
|
end: 1,
|
|
).animate(CurvedAnimation(
|
|
parent: _spinController,
|
|
curve: Curves.easeOut,
|
|
));
|
|
|
|
_pulseAnimation = Tween<double>(
|
|
begin: 1.0,
|
|
end: 1.2,
|
|
).animate(CurvedAnimation(
|
|
parent: _pulseController,
|
|
curve: Curves.easeInOut,
|
|
));
|
|
|
|
_spinController.addStatusListener((status) {
|
|
if (status == AnimationStatus.completed) {
|
|
_completeDraw();
|
|
}
|
|
});
|
|
|
|
// Auto-start the draw after a short delay
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
if (mounted) {
|
|
_startDraw();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_spinController.dispose();
|
|
_pulseController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startDraw() {
|
|
if (_isSpinning) return;
|
|
|
|
print('🎰 Starting draw animation...');
|
|
|
|
setState(() {
|
|
_isSpinning = true;
|
|
_isDrawComplete = false;
|
|
});
|
|
|
|
// Calculate the winner using provably fair algorithm
|
|
_calculateWinner();
|
|
|
|
print('🎯 Winner calculated: ${_winnerId} (index: $_selectedIndex)');
|
|
|
|
// Start spinning animation
|
|
_spinController.forward();
|
|
_pulseController.repeat(reverse: true);
|
|
}
|
|
|
|
void _calculateWinner() {
|
|
// Use provably fair algorithm to determine winner
|
|
final serverSeed = widget.serverSeed ?? 'default_server_seed';
|
|
final clientSeed = widget.clientSeed ?? 'default_client_seed';
|
|
final nonce = widget.nonce ?? DateTime.now().millisecondsSinceEpoch;
|
|
|
|
// Create a deterministic hash from the seeds and nonce
|
|
final combinedSeed = '$serverSeed:$clientSeed:$nonce';
|
|
final hash = _generateHash(combinedSeed);
|
|
|
|
// Convert hash to a number and select winner
|
|
final randomValue = _hashToNumber(hash);
|
|
_selectedIndex = randomValue % widget.members.length;
|
|
_winnerId = widget.members[_selectedIndex]['id'];
|
|
}
|
|
|
|
String _generateHash(String input) {
|
|
// Simple hash function for demonstration
|
|
// In production, use a proper cryptographic hash
|
|
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) {
|
|
// Pad with zeros if hash is too short
|
|
hash = hash.padRight(10, '0');
|
|
}
|
|
return int.parse(hash.substring(0, 10));
|
|
}
|
|
|
|
void _completeDraw() {
|
|
print('🎉 Draw completed! Winner: $_winnerId');
|
|
|
|
setState(() {
|
|
_isSpinning = false;
|
|
_isDrawComplete = true;
|
|
});
|
|
|
|
_pulseController.stop();
|
|
_pulseController.reset();
|
|
|
|
// Notify parent of the winner
|
|
if (_winnerId != null) {
|
|
widget.onDrawComplete(_winnerId!);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 400.w,
|
|
height: 400.w,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
// Outer ring
|
|
Container(
|
|
width: 400.w,
|
|
height: 400.w,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: Colors.purple.shade300,
|
|
width: 8.w,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.purple.shade200,
|
|
blurRadius: 20.r,
|
|
spreadRadius: 5.r,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Spinning wheel
|
|
AnimatedBuilder(
|
|
animation: _spinAnimation,
|
|
builder: (context, child) {
|
|
return Transform.rotate(
|
|
angle: _spinAnimation.value * 2 * math.pi * 5 +
|
|
(_selectedIndex * 2 * math.pi / widget.members.length),
|
|
child: Container(
|
|
width: 360.w,
|
|
height: 360.w,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(
|
|
colors: [
|
|
Colors.purple.shade50,
|
|
Colors.purple.shade100,
|
|
Colors.purple.shade200,
|
|
],
|
|
),
|
|
),
|
|
child: CustomPaint(
|
|
painter: WheelPainter(
|
|
members: widget.members,
|
|
selectedIndex: _isDrawComplete ? _selectedIndex : -1,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
// Center circle with pointer
|
|
Container(
|
|
width: 80.w,
|
|
height: 80.w,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Colors.white,
|
|
border: Border.all(color: Colors.purple.shade400, width: 3.w),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 10.r,
|
|
offset: Offset(0, 5.h),
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
Icons.casino,
|
|
color: Colors.purple.shade600,
|
|
size: 40.w,
|
|
),
|
|
),
|
|
|
|
// Pointer arrow
|
|
Positioned(
|
|
top: 20.h,
|
|
child: Container(
|
|
width: 0,
|
|
height: 0,
|
|
child: CustomPaint(
|
|
painter: PointerPainter(),
|
|
size: Size(20.w, 40.h),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Winner highlight
|
|
if (_isDrawComplete)
|
|
AnimatedBuilder(
|
|
animation: _pulseAnimation,
|
|
builder: (context, child) {
|
|
return Transform.scale(
|
|
scale: _pulseAnimation.value,
|
|
child: Container(
|
|
width: 400.w,
|
|
height: 400.w,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: Colors.green.shade400,
|
|
width: 4.w,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class WheelPainter extends CustomPainter {
|
|
final List<Map<String, dynamic>> members;
|
|
final int selectedIndex;
|
|
|
|
WheelPainter({
|
|
required this.members,
|
|
required this.selectedIndex,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final center = Offset(size.width / 2, size.height / 2);
|
|
final radius = size.width / 2 - 20;
|
|
|
|
final paint = Paint()
|
|
..style = PaintingStyle.fill;
|
|
|
|
final textPainter = TextPainter(
|
|
textDirection: TextDirection.ltr,
|
|
textAlign: TextAlign.center,
|
|
);
|
|
|
|
for (int i = 0; i < members.length; i++) {
|
|
final startAngle = (i * 2 * math.pi / members.length) - math.pi / 2;
|
|
final sweepAngle = 2 * math.pi / members.length;
|
|
|
|
// Color based on selection
|
|
if (i == selectedIndex) {
|
|
paint.color = Colors.green.shade300;
|
|
} else {
|
|
paint.color = (i % 2 == 0)
|
|
? Colors.purple.shade100
|
|
: Colors.purple.shade200;
|
|
}
|
|
|
|
// Draw segment
|
|
canvas.drawArc(
|
|
Rect.fromCircle(center: center, radius: radius),
|
|
startAngle,
|
|
sweepAngle,
|
|
true,
|
|
paint,
|
|
);
|
|
|
|
// Draw member name
|
|
final memberName = members[i]['name'] ?? 'Member ${i + 1}';
|
|
final text = TextSpan(
|
|
text: memberName.length > 8 ? '${memberName.substring(0, 8)}...' : memberName,
|
|
style: TextStyle(
|
|
color: Colors.purple.shade800,
|
|
fontSize: 12.sp,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
);
|
|
|
|
textPainter.text = text;
|
|
textPainter.layout();
|
|
|
|
final textAngle = startAngle + sweepAngle / 2;
|
|
final textRadius = radius * 0.7;
|
|
final textX = center.dx + textRadius * math.cos(textAngle) - textPainter.width / 2;
|
|
final textY = center.dy + textRadius * math.sin(textAngle) - textPainter.height / 2;
|
|
|
|
canvas.save();
|
|
canvas.translate(textX, textY);
|
|
canvas.rotate(textAngle + math.pi / 2);
|
|
textPainter.paint(canvas, Offset.zero);
|
|
canvas.restore();
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
|
}
|
|
|
|
class PointerPainter extends CustomPainter {
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = Colors.red.shade600
|
|
..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;
|
|
}
|