fixed winner

This commit is contained in:
Deep Koluguri 2026-04-05 23:29:12 -04:00
parent 37c6c497ff
commit 5c284a3698
5 changed files with 647 additions and 405 deletions

View File

@ -29,7 +29,18 @@ const generateProvablyFairResult = (serverSeed, clientSeed, nonce, eligibleMembe
// Create a new monthly draw // Create a new monthly draw
const createMonthlyDraw = async (req, res) => { const createMonthlyDraw = async (req, res) => {
try { try {
const { group_id, month, year, client_seed, winner_id, prize_amount, is_past_draw } = req.body; const {
group_id,
month,
year,
client_seed,
winner_id,
prize_amount,
is_past_draw,
animation_winner_user_id,
server_seed: bodyServerSeed,
nonce: bodyNonce
} = req.body;
// Convert month and year to integers // Convert month and year to integers
const monthInt = parseInt(month, 10); const monthInt = parseInt(month, 10);
@ -112,43 +123,92 @@ const createMonthlyDraw = async (req, res) => {
attributes: ['winner_id'] attributes: ['winner_id']
}); });
const wonMemberIds = wonMembers.map(draw => draw.winner_id); const wonMemberIds = wonMembers.map((draw) => draw.winner_id);
const availableMembers = eligibleMembers.filter(member => const availableMembers = eligibleMembers.filter(
!wonMemberIds.includes(member.user_id) (member) =>
!wonMemberIds.some((id) => id != null && String(id) === String(member.user_id))
); );
if (availableMembers.length === 0 && !is_past_draw) { const animationWinner =
animation_winner_user_id != null && String(animation_winner_user_id).trim() !== ''
? String(animation_winner_user_id).trim()
: null;
if (availableMembers.length === 0 && !is_past_draw && !animationWinner) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: 'No eligible members available for draw' message: 'No eligible members available for draw'
}); });
} }
const alreadyWon = (uid) =>
wonMemberIds.some((id) => id != null && String(id) === String(uid));
// Determine winner // Determine winner
let selectedWinnerId; let selectedWinnerId;
let selectedWinner; let selectedWinner;
let serverSeed; let serverSeed;
let resultHash; let resultHash;
let nonce; let nonce;
let fromAppAnimation = false;
if (is_past_draw && winner_id) { if (animationWinner) {
// App showed a winner in the animation; persist that user (manager-authenticated).
// Without this branch the server re-rolls and WhatsApp/public links show the wrong name.
fromAppAnimation = true;
selectedWinnerId = animationWinner;
selectedWinner = eligibleMembers.find((m) => String(m.user_id) === selectedWinnerId);
if (!selectedWinner) {
return res.status(400).json({
success: false,
message: 'Winner is not an active member of this group'
});
}
if (alreadyWon(selectedWinnerId)) {
return res.status(400).json({
success: false,
message: `${selectedWinner.User.full_name} has already won in a previous draw. Each member can only win once.`,
alreadyWon: true,
winnerName: selectedWinner.User.full_name
});
}
if (!availableMembers.some((m) => String(m.user_id) === selectedWinnerId)) {
return res.status(400).json({
success: false,
message: 'Winner is not eligible for this draw (already won or not in pool)'
});
}
serverSeed =
bodyServerSeed && String(bodyServerSeed).trim() !== ''
? String(bodyServerSeed).trim()
: crypto.randomBytes(32).toString('hex');
nonce = bodyNonce != null ? parseInt(bodyNonce, 10) : Date.now();
if (Number.isNaN(nonce)) {
nonce = Date.now();
}
const cs = client_seed != null && String(client_seed).trim() !== '' ? String(client_seed) : 'animation';
resultHash = crypto
.createHash('sha256')
.update(`${serverSeed}-${cs}-${nonce}-${selectedWinnerId}`)
.digest('hex');
} else if (is_past_draw && winner_id) {
// Past draw - manual winner selection // Past draw - manual winner selection
selectedWinnerId = winner_id; selectedWinnerId = winner_id;
serverSeed = `PAST_DRAW_${Date.now()}`; serverSeed = `PAST_DRAW_${Date.now()}`;
nonce = Date.now(); nonce = Date.now();
resultHash = crypto.createHash('sha256').update(`${serverSeed}_${winner_id}_${nonce}`).digest('hex'); resultHash = crypto.createHash('sha256').update(`${serverSeed}_${winner_id}_${nonce}`).digest('hex');
// Get winner details // Get winner details
selectedWinner = eligibleMembers.find(m => m.user_id === winner_id); selectedWinner = eligibleMembers.find((m) => String(m.user_id) === String(winner_id));
if (!selectedWinner) { if (!selectedWinner) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: 'Selected winner is not a member of this group' message: 'Selected winner is not a member of this group'
}); });
} }
// Check if this member has already won // Check if this member has already won
if (wonMemberIds.includes(winner_id)) { if (alreadyWon(winner_id)) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: `${selectedWinner.User.full_name} has already won in a previous draw. Each member can only win once.`, message: `${selectedWinner.User.full_name} has already won in a previous draw. Each member can only win once.`,
@ -157,7 +217,7 @@ const createMonthlyDraw = async (req, res) => {
}); });
} }
} else { } else {
// Regular draw - provably fair random selection // Regular draw - provably fair random selection (API-only / no animation winner)
serverSeed = crypto.randomBytes(32).toString('hex'); serverSeed = crypto.randomBytes(32).toString('hex');
nonce = Date.now(); nonce = Date.now();
@ -167,7 +227,7 @@ const createMonthlyDraw = async (req, res) => {
nonce, nonce,
availableMembers availableMembers
); );
selectedWinnerId = result.winner.user_id; selectedWinnerId = result.winner.user_id;
selectedWinner = result.winner; selectedWinner = result.winner;
resultHash = result.hash; resultHash = result.hash;
@ -177,8 +237,8 @@ const createMonthlyDraw = async (req, res) => {
const totalMembers = eligibleMembers.length; const totalMembers = eligibleMembers.length;
const calculatedPrizeAmount = prize_amount || chitGroup.total_value; // Use provided or default const calculatedPrizeAmount = prize_amount || chitGroup.total_value; // Use provided or default
// For past draws, use all eligible members; for regular draws, use only available members // For past imports, use all active members; for animation + random draws, use not-yet-won pool
const membersForDraw = is_past_draw ? eligibleMembers : availableMembers; const membersForDraw = is_past_draw && !fromAppAnimation ? eligibleMembers : availableMembers;
// Create monthly draw // Create monthly draw
const monthlyDraw = await MonthlyDraw.create({ const monthlyDraw = await MonthlyDraw.create({
@ -195,13 +255,16 @@ const createMonthlyDraw = async (req, res) => {
prize_amount: calculatedPrizeAmount, prize_amount: calculatedPrizeAmount,
server_seed: serverSeed, server_seed: serverSeed,
server_seed_hash: crypto.createHash('sha256').update(serverSeed).digest('hex'), server_seed_hash: crypto.createHash('sha256').update(serverSeed).digest('hex'),
client_seed: client_seed || (is_past_draw ? 'PAST_DRAW' : 'default'), client_seed:
client_seed || (fromAppAnimation ? 'animation' : is_past_draw ? 'PAST_DRAW' : 'default'),
nonce, nonce,
result_hash: resultHash, result_hash: resultHash,
status: 'completed', status: 'completed',
notes: is_past_draw notes: fromAppAnimation
? `Past draw result (imported) - Winner: ${selectedWinner.User.full_name}` ? `App animation draw — ${selectedWinner.User.full_name}`
: `Winner selected: ${selectedWinner.User.full_name}` : is_past_draw
? `Past draw result (imported) - Winner: ${selectedWinner.User.full_name}`
: `Winner selected: ${selectedWinner.User.full_name}`
}); });
res.status(201).json({ res.status(201).json({

View File

@ -467,6 +467,9 @@ class ChitGroupService extends GetxController {
int year, { int year, {
String? clientSeed, String? clientSeed,
double? prizeAmount, double? prizeAmount,
String? animationWinnerUserId,
String? serverSeed,
int? nonce,
}) async { }) async {
try { try {
_isLoading.value = true; _isLoading.value = true;
@ -477,6 +480,9 @@ class ChitGroupService extends GetxController {
'year': year, 'year': year,
if (clientSeed != null) 'client_seed': clientSeed, if (clientSeed != null) 'client_seed': clientSeed,
if (prizeAmount != null) 'prize_amount': prizeAmount, if (prizeAmount != null) 'prize_amount': prizeAmount,
if (animationWinnerUserId != null) 'animation_winner_user_id': animationWinnerUserId,
if (serverSeed != null) 'server_seed': serverSeed,
if (nonce != null) 'nonce': nonce,
}; };
final response = await _apiService.createMonthlyDraw(drawData); final response = await _apiService.createMonthlyDraw(drawData);

View File

@ -34,10 +34,13 @@ class _DrawAnimationPageState extends State<DrawAnimationPage>
late AnimationController _fadeController; late AnimationController _fadeController;
late Animation<double> _fadeAnimation; late Animation<double> _fadeAnimation;
bool _isComplete = false; bool _isComplete = false;
/// Single client seed for the whole page so animation + API save use the same value.
late final String _animationClientSeed;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_animationClientSeed = 'DRAW_${DateTime.now().millisecondsSinceEpoch}';
_fadeController = AnimationController( _fadeController = AnimationController(
duration: const Duration(milliseconds: 800), duration: const Duration(milliseconds: 800),
@ -68,14 +71,14 @@ class _DrawAnimationPageState extends State<DrawAnimationPage>
_isComplete = true; _isComplete = true;
}); });
// Wait a moment to show the winner // Let the slot settle on the winner before the confirmation sheet
await Future.delayed(const Duration(seconds: 2)); await Future.delayed(const Duration(milliseconds: 900));
// Find winner (avoid firstWhere/orElse RTI mismatch Map<String,String> vs dynamic on web) // Find winner (avoid firstWhere/orElse RTI mismatch Map<String,String> vs dynamic on web)
Map<String, dynamic> winner = {'name': 'Unknown', 'mobile': ''}; Map<String, dynamic> winner = {'name': 'Unknown', 'mobile': ''};
for (final raw in widget.eligibleMembers) { for (final raw in widget.eligibleMembers) {
final m = Map<String, dynamic>.from(raw); final m = Map<String, dynamic>.from(raw);
if (m['id'] == winnerId) { if ('${m['id']}' == '$winnerId') {
winner = m; winner = m;
break; break;
} }
@ -107,168 +110,247 @@ class _DrawAnimationPageState extends State<DrawAnimationPage>
} }
Future<bool?> _showWinnerConfirmation(Map<String, dynamic> winner, double bidAmount) async { Future<bool?> _showWinnerConfirmation(Map<String, dynamic> winner, double bidAmount) async {
await HapticFeedback.mediumImpact();
final monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
final periodLabel =
'${monthNames[widget.month - 1]} ${widget.year} · ${widget.group.name}';
return showDialog<bool>( return showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => AlertDialog( builder: (dialogContext) => TweenAnimationBuilder<double>(
backgroundColor: Colors.white, tween: Tween(begin: 0.88, end: 1.0),
shape: RoundedRectangleBorder( duration: const Duration(milliseconds: 420),
borderRadius: BorderRadius.circular(16.r), curve: Curves.easeOutCubic,
), builder: (context, scale, child) =>
title: Row( Transform.scale(scale: scale, child: child),
children: [ child: AlertDialog(
Icon(Icons.emoji_events, color: Colors.amber.shade600, size: 32.w), insetPadding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 24.h),
SizedBox(width: 12.w), backgroundColor: Colors.white,
Expanded( shape: RoundedRectangleBorder(
child: Text( borderRadius: BorderRadius.circular(20.r),
'Draw Winner!', ),
style: TextStyle( title: Column(
fontSize: 22.sp, crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.bold, children: [
color: Colors.green.shade700, Row(
),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.all(20.w),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: Colors.green.shade200, width: 2),
),
child: Column(
children: [ children: [
Text( Container(
winner['name'] ?? 'Unknown', padding: EdgeInsets.all(10.w),
style: TextStyle( decoration: BoxDecoration(
fontSize: 24.sp, color: Colors.amber.shade50,
fontWeight: FontWeight.bold, shape: BoxShape.circle,
color: Colors.green.shade900,
), ),
textAlign: TextAlign.center, child: Icon(
), Icons.emoji_events_rounded,
SizedBox(height: 8.h), color: Colors.amber.shade800,
Text( size: 28.w,
winner['mobile'] ?? '',
style: TextStyle(
fontSize: 16.sp,
color: Colors.grey.shade700,
), ),
), ),
SizedBox(height: 16.h), SizedBox(width: 12.w),
Divider(color: Colors.green.shade200), Expanded(
SizedBox(height: 16.h), child: Text(
Text( 'Winner',
'Prize Amount', style: TextStyle(
style: TextStyle( fontSize: 22.sp,
fontSize: 14.sp, fontWeight: FontWeight.w800,
color: Colors.grey.shade600, color: Colors.grey.shade900,
), letterSpacing: -0.5,
), ),
SizedBox(height: 8.h),
Text(
_formatIndianCurrency(bidAmount),
style: TextStyle(
fontSize: 32.sp,
fontWeight: FontWeight.bold,
color: Colors.green.shade700,
), ),
), ),
], ],
), ),
SizedBox(height: 8.h),
Text(
periodLabel,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
height: 1.3,
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 22.h),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.green.shade50,
Colors.teal.shade50.withOpacity(0.85),
],
),
borderRadius: BorderRadius.circular(16.r),
border: Border.all(color: Colors.green.shade200, width: 1.5),
),
child: Column(
children: [
Text(
winner['name']?.toString() ?? 'Unknown',
style: TextStyle(
fontSize: 26.sp,
fontWeight: FontWeight.w800,
color: Colors.green.shade900,
height: 1.15,
),
textAlign: TextAlign.center,
),
if ((winner['mobile']?.toString() ?? '').isNotEmpty) ...[
SizedBox(height: 10.h),
Text(
winner['mobile']?.toString() ?? '',
style: TextStyle(
fontSize: 15.sp,
color: Colors.grey.shade700,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
SizedBox(height: 18.h),
Container(
height: 1,
color: Colors.green.shade200.withOpacity(0.9),
),
SizedBox(height: 14.h),
Text(
'Prize amount',
style: TextStyle(
fontSize: 13.sp,
color: Colors.grey.shade600,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
),
SizedBox(height: 6.h),
Text(
_formatIndianCurrency(bidAmount),
style: TextStyle(
fontSize: 30.sp,
fontWeight: FontWeight.w800,
color: Colors.green.shade800,
),
),
],
),
),
SizedBox(height: 16.h),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _shareViaWhatsApp(winner, bidAmount),
icon: Icon(Icons.send_rounded,
size: 18.w, color: const Color(0xFF25D366)),
label: Text(
'Share',
style: TextStyle(
fontSize: 13.sp, color: const Color(0xFF25D366)),
),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 12.h),
side: const BorderSide(color: Color(0xFF25D366)),
),
),
),
SizedBox(width: 8.w),
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Get.snackbar(
'Screenshot',
'Capture this screen if you want a local copy.',
backgroundColor: Colors.blue.shade700,
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
},
icon: Icon(Icons.photo_camera_outlined,
size: 18.w, color: Colors.blue.shade700),
label: Text(
'Capture',
style: TextStyle(
fontSize: 13.sp, color: Colors.blue.shade700),
),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 12.h),
side: BorderSide(color: Colors.blue.shade700),
),
),
),
],
),
SizedBox(height: 12.h),
Text(
'Save to record this result and get a shareable public link.',
style: TextStyle(
fontSize: 14.sp,
color: Colors.grey.shade700,
fontWeight: FontWeight.w500,
height: 1.35,
),
textAlign: TextAlign.center,
),
],
), ),
SizedBox(height: 16.h), ),
actionsPadding: EdgeInsets.fromLTRB(20.w, 0, 20.w, 16.h),
// Quick Actions - Share & Screenshot actions: [
Row( Row(
children: [ children: [
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton(
onPressed: () => _shareViaWhatsApp(winner, bidAmount), onPressed: () => Navigator.pop(dialogContext, false),
icon: Icon(Icons.send_rounded, size: 18.w, color: Color(0xFF25D366)),
label: Text('Share', style: TextStyle(fontSize: 13.sp, color: Color(0xFF25D366))),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 10.h), padding: EdgeInsets.symmetric(vertical: 14.h),
side: BorderSide(color: Color(0xFF25D366)), side: BorderSide(color: Colors.grey.shade400),
),
child: Text(
'Discard',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
), ),
), ),
), ),
SizedBox(width: 8.w), SizedBox(width: 10.w),
Expanded( Expanded(
child: OutlinedButton.icon( flex: 2,
onPressed: () { child: FilledButton.icon(
Get.snackbar( onPressed: () => Navigator.pop(dialogContext, true),
'Screenshot Tip', style: FilledButton.styleFrom(
'Take a screenshot now to save this result!', backgroundColor: Colors.green.shade700,
backgroundColor: Colors.blue, foregroundColor: Colors.white,
colorText: Colors.white, padding: EdgeInsets.symmetric(vertical: 14.h),
duration: Duration(seconds: 3), ),
); icon: Icon(Icons.check_circle_outline, size: 20.w),
}, label: Text(
icon: Icon(Icons.screenshot, size: 18.w, color: Colors.blue.shade600), 'Save result',
label: Text('Capture', style: TextStyle(fontSize: 13.sp, color: Colors.blue.shade600)), style: TextStyle(
style: OutlinedButton.styleFrom( fontSize: 15.sp,
padding: EdgeInsets.symmetric(vertical: 10.h), fontWeight: FontWeight.w700,
side: BorderSide(color: Colors.blue.shade600), ),
), ),
), ),
), ),
], ],
), ),
SizedBox(height: 20.h),
Text(
'Do you want to save this draw result?',
style: TextStyle(
fontSize: 16.sp,
color: Colors.grey.shade700,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
], ],
), ),
actionsAlignment: MainAxisAlignment.end,
actionsPadding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h),
actions: [
// Cancel button
OutlinedButton(
onPressed: () => Navigator.pop(context, false),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 14.h),
side: BorderSide(color: Colors.grey.shade400),
),
child: Text(
'Cancel',
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600),
),
),
SizedBox(width: 12.w),
// Save button
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade600,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 14.h),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.save, size: 20.w),
SizedBox(width: 8.w),
Text(
'Save Result',
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600),
),
],
),
),
],
), ),
); );
} }
@ -306,8 +388,8 @@ _Congratulations to the winner!_
try { try {
// Show loading // Show loading
Get.dialog( Get.dialog(
WillPopScope( PopScope(
onWillPop: () async => false, canPop: false,
child: Center( child: Center(
child: Container( child: Container(
padding: EdgeInsets.all(32.w), padding: EdgeInsets.all(32.w),
@ -336,8 +418,11 @@ _Congratulations to the winner!_
widget.group.id, widget.group.id,
widget.month, widget.month,
widget.year, widget.year,
clientSeed: 'DRAW_${DateTime.now().millisecondsSinceEpoch}', clientSeed: _animationClientSeed,
prizeAmount: bidAmount, prizeAmount: bidAmount,
animationWinnerUserId: winnerId,
serverSeed: widget.serverSeed,
nonce: widget.nonce,
); );
if (!mounted) return; if (!mounted) return;
@ -467,34 +552,43 @@ _Congratulations to the winner!_
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return WillPopScope( const monthLabels = [
onWillPop: () async { 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
// Prevent back button during animation 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
if (_isComplete) return true; ];
final drawPeriodShort =
final result = await showDialog<bool>( '${monthLabels[widget.month - 1]} ${widget.year}';
return PopScope(
canPop: _isComplete,
onPopInvokedWithResult: (didPop, result) async {
if (didPop || !mounted) return;
final leave = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Cancel Draw?'), title: const Text('Leave draw?'),
content: const Text('Are you sure you want to cancel the draw? This action cannot be undone.'), content: const Text(
'The result is not saved yet. Leave anyway?',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(ctx, false),
child: const Text('Continue Draw'), child: const Text('Stay'),
), ),
ElevatedButton( FilledButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Colors.red, backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
child: const Text('Cancel Draw'), child: const Text('Leave'),
), ),
], ],
), ),
); );
if (leave == true && mounted) {
return result ?? false; Get.back(result: false);
}
}, },
child: Scaffold( child: Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@ -587,9 +681,9 @@ _Congratulations to the winner!_
), ),
), ),
child: Text( child: Text(
'Month ${widget.month}/${widget.year}', drawPeriodShort,
style: TextStyle( style: TextStyle(
fontSize: 16.sp, fontSize: 15.sp,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: Colors.white,
), ),
@ -613,9 +707,10 @@ _Congratulations to the winner!_
members: widget.eligibleMembers, members: widget.eligibleMembers,
onDrawComplete: _onDrawComplete, onDrawComplete: _onDrawComplete,
serverSeed: widget.serverSeed, serverSeed: widget.serverSeed,
clientSeed: 'DRAW_${DateTime.now().millisecondsSinceEpoch}', clientSeed: _animationClientSeed,
nonce: widget.nonce, nonce: widget.nonce,
animationDuration: const Duration(seconds: 8), animationDuration: const Duration(seconds: 8),
allowReplay: !_isComplete,
), ),
), ),
), ),

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:async'; import 'dart:async';
@ -518,7 +519,8 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
final randomValue = _hashToNumber(hash); final randomValue = _hashToNumber(hash);
final selectedIndex = randomValue % widget.members.length; final selectedIndex = randomValue % widget.members.length;
_winnerId = widget.members[selectedIndex]['id']; final raw = widget.members[selectedIndex]['id'];
_winnerId = raw?.toString() ?? '';
} }
void _startSlotMachine() { void _startSlotMachine() {
@ -573,31 +575,43 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
} }
void _completeAnimation() { void _completeAnimation() {
final winnerName = widget.members.firstWhere((m) => m['id'] == _winnerId)['name']; // Always center the real winner indexOf(name) breaks when names repeat.
final winnerIndex = _baseNames.indexOf(winnerName); 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(() { setState(() {
_isAnimating = false; _isAnimating = false;
_isComplete = true; _isComplete = true;
if (winnerIndex != -1) { _displayNames = window;
final List<String> window = [];
for (int offset = -3; offset <= 3; offset++) {
final index = (winnerIndex + offset) % _baseNames.length;
final adjustedIndex = index < 0 ? index + _baseNames.length : index;
window.add(_baseNames[adjustedIndex]);
}
_displayNames = window;
} else {
_displayNames = List.filled(7, '', growable: false);
_displayNames[3] = winnerName;
}
}); });
_pulseController.stop(); _pulseController.stop();
// Delay callback slightly for visual effect Future.delayed(const Duration(milliseconds: 650), () {
Future.delayed(const Duration(milliseconds: 500), () { if (_winnerId != null && _winnerId!.isNotEmpty) {
widget.onDrawComplete(_winnerId!); widget.onDrawComplete(_winnerId!);
}
}); });
} }
@ -618,198 +632,254 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final winnerName = _winnerId != null String? winnerName;
? widget.members.firstWhere((m) => m['id'] == _winnerId)['name'] if (_winnerId != null && _winnerId!.isNotEmpty) {
: null; for (final m in widget.members) {
if ('${m['id']}' == '${_winnerId}') {
winnerName = m['name']?.toString();
break;
}
}
}
return Container( final maxW = MediaQuery.sizeOf(context).width - 40.w;
width: 320.w,
height: 520.h, return Center(
child: Column( child: ConstrainedBox(
children: [ constraints: BoxConstraints(maxWidth: maxW.clamp(280.w, 400.w)),
Expanded( child: SizedBox(
child: Container( width: double.infinity,
width: double.infinity, height: 480.h.clamp(380.h, 560.h),
decoration: BoxDecoration( child: Column(
gradient: LinearGradient( children: [
colors: [ Expanded(
Colors.grey.shade900, child: Container(
Colors.grey.shade800, width: double.infinity,
], decoration: BoxDecoration(
begin: Alignment.topCenter, gradient: LinearGradient(
end: Alignment.bottomCenter, colors: [
), Colors.grey.shade900,
borderRadius: BorderRadius.circular(20.r), Colors.grey.shade800,
border: Border.all(color: Colors.orange.shade400, width: 3.w), ],
boxShadow: [ begin: Alignment.topCenter,
BoxShadow( end: Alignment.bottomCenter,
color: Colors.black.withOpacity(0.35), ),
blurRadius: 18.r, borderRadius: BorderRadius.circular(20.r),
offset: Offset(0, 10.h), border: Border.all(color: Colors.orange.shade400, width: 3.w),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.35),
blurRadius: 18.r,
offset: Offset(0, 10.h),
),
],
), ),
], child: Container(
), margin: EdgeInsets.all(18.w),
child: Container( decoration: BoxDecoration(
margin: EdgeInsets.all(18.w), color: Colors.black,
decoration: BoxDecoration( borderRadius: BorderRadius.circular(12.r),
color: Colors.black, ),
borderRadius: BorderRadius.circular(12.r), child: LayoutBuilder(
), builder: (context, constraints) {
child: Stack( final rowH = constraints.maxHeight / 7;
children: [ return Stack(
AnimatedBuilder( clipBehavior: Clip.hardEdge,
animation: _slotAnimation, children: [
builder: (context, child) { AnimatedBuilder(
return Column( animation: _slotAnimation,
children: List.generate(7, (index) { builder: (context, child) {
final displayIndex = index < _displayNames.length ? index : 0; return Column(
final name = _displayNames[displayIndex]; children: List.generate(7, (index) {
final isWinner = _isComplete && index == 3; final displayIndex =
final isCenterHighlight = _isAnimating && index == 3; index < _displayNames.length ? index : 0;
final name = _displayNames[displayIndex];
final isWinner = _isComplete && index == 3;
final isCenterHighlight =
_isAnimating && index == 3;
return Expanded( return Expanded(
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _pulseAnimation, animation: _pulseAnimation,
builder: (context, child) { builder: (context, child) {
final double scale = isWinner || isCenterHighlight ? 1.08 : 1.0; final double scale =
final double fontSize = isWinner isWinner || isCenterHighlight
? 24.sp ? 1.08
: isCenterHighlight : 1.0;
? 20.sp final double fontSize = isWinner
: 18.sp; ? 24.sp
final FontWeight weight = isWinner : isCenterHighlight
? FontWeight.w900 ? 20.sp
: isCenterHighlight : 18.sp;
? FontWeight.w800 final FontWeight weight = isWinner
: FontWeight.w700; ? FontWeight.w900
final List<Color> colors = isWinner : isCenterHighlight
? [Colors.green.shade500, Colors.green.shade600] ? FontWeight.w800
: isCenterHighlight : FontWeight.w700;
? [Colors.deepPurple.shade500, Colors.deepPurple.shade700] final List<Color> colors = isWinner
: [Colors.blueGrey.shade700, Colors.blueGrey.shade900]; ? [
Colors.green.shade500,
Colors.green.shade600,
]
: isCenterHighlight
? [
Colors.deepPurple.shade500,
Colors.deepPurple.shade700,
]
: [
Colors.blueGrey.shade700,
Colors.blueGrey.shade900,
];
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 220), duration: const Duration(
curve: Curves.easeInOut, milliseconds: 220),
transform: Matrix4.identity()..scale(scale), curve: Curves.easeInOut,
child: Container( transform: Matrix4.identity()
width: double.infinity, ..scale(scale),
margin: EdgeInsets.symmetric( child: Container(
vertical: 6.h, width: double.infinity,
horizontal: 12.w, margin: EdgeInsets.symmetric(
), vertical: 6.h,
decoration: BoxDecoration( horizontal: 12.w,
gradient: LinearGradient(
colors: colors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10.r),
border: Border.all(
color: Colors.white.withOpacity(isWinner || isCenterHighlight ? 0.7 : 0.15),
width: isWinner || isCenterHighlight ? 2.w : 1.w,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.4),
blurRadius: isWinner ? 14.r : 6.r,
offset: Offset(0, 3.h),
),
],
),
child: Center(
child: Text(
name.length > 22 ? '${name.substring(0, 22)}' : name,
style: TextStyle(
fontSize: fontSize,
fontWeight: weight,
color: Colors.white,
letterSpacing: 0.6,
shadows: [
Shadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 4.r,
offset: Offset(1.5, 1.5),
), ),
], decoration: BoxDecoration(
), gradient: LinearGradient(
textAlign: TextAlign.center, colors: colors,
maxLines: 1, begin: Alignment.topLeft,
overflow: TextOverflow.ellipsis, end: Alignment.bottomRight,
), ),
borderRadius:
BorderRadius.circular(10.r),
border: Border.all(
color: Colors.white.withOpacity(
isWinner ||
isCenterHighlight
? 0.7
: 0.15),
width: isWinner ||
isCenterHighlight
? 2.w
: 1.w,
),
boxShadow: [
BoxShadow(
color: Colors.black
.withOpacity(0.4),
blurRadius: isWinner
? 14.r
: 6.r,
offset: Offset(0, 3.h),
),
],
),
child: Center(
child: Text(
name.length > 22
? '${name.substring(0, 22)}'
: name,
style: TextStyle(
fontSize: fontSize,
fontWeight: weight,
color: Colors.white,
letterSpacing: 0.6,
shadows: [
Shadow(
color: Colors.black
.withOpacity(0.5),
blurRadius: 4.r,
offset:
const Offset(1.5, 1.5),
),
],
),
textAlign: TextAlign.center,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
),
),
),
);
},
), ),
);
}),
);
},
),
if (_isComplete)
Positioned(
top: rowH * 3,
left: 0,
right: 0,
height: rowH,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.r),
border: Border.all(
color: Colors.amber.shade400,
width: 2.5.w,
),
boxShadow: [
BoxShadow(
color: Colors.amber.withOpacity(0.35),
blurRadius: 14.r,
spreadRadius: 0.5,
),
],
), ),
); ),
}, ),
), ),
); ],
}),
); );
}, },
), ),
if (_isComplete) ),
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
height: 70.h,
decoration: BoxDecoration(
color: Colors.green.shade400.withOpacity(0.35),
borderRadius: BorderRadius.circular(6.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.emoji_events, color: Colors.yellowAccent, size: 26.w),
SizedBox(width: 10.w),
Text(
'WINNER',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w900,
color: Colors.yellowAccent,
),
),
],
),
),
),
],
), ),
), ),
), SizedBox(height: 16.h),
), if (_isComplete && winnerName != null)
SizedBox(height: 16.h), Container(
if (_isComplete && winnerName != null) width: double.infinity,
Container( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), decoration: BoxDecoration(
decoration: BoxDecoration( gradient: LinearGradient(
color: Colors.green.shade50, colors: [
borderRadius: BorderRadius.circular(12.r), Colors.green.shade50,
), Colors.green.shade100.withOpacity(0.85),
child: Row( ],
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.emoji_events, color: Colors.green.shade600, size: 20.w),
SizedBox(width: 8.w),
Flexible(
child: Text(
winnerName,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w700,
color: Colors.green.shade700,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
borderRadius: BorderRadius.circular(14.r),
border: Border.all(color: Colors.green.shade200, width: 1.5),
), ),
], child: Row(
), mainAxisAlignment: MainAxisAlignment.center,
), children: [
], Icon(Icons.emoji_events, color: Colors.amber.shade700, size: 26.w),
SizedBox(width: 10.w),
Flexible(
child: Text(
winnerName,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w800,
color: Colors.green.shade900,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
), ),
); );
} }

View File

@ -9,6 +9,8 @@ class DrawAnimationSelector extends StatefulWidget {
final String? clientSeed; final String? clientSeed;
final int? nonce; final int? nonce;
final Duration animationDuration; final Duration animationDuration;
/// When false, hides "Spin again" after a run (e.g. draw already finalized on parent).
final bool allowReplay;
const DrawAnimationSelector({ const DrawAnimationSelector({
super.key, super.key,
@ -18,6 +20,7 @@ class DrawAnimationSelector extends StatefulWidget {
this.clientSeed, this.clientSeed,
this.nonce, this.nonce,
this.animationDuration = const Duration(seconds: 4), this.animationDuration = const Duration(seconds: 4),
this.allowReplay = true,
}); });
@override @override
@ -143,10 +146,13 @@ class _DrawAnimationSelectorState extends State<DrawAnimationSelector> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final maxW = (MediaQuery.sizeOf(context).width - 32.w).clamp(280.w, 440.w);
width: 400.w, return Align(
child: Column( alignment: Alignment.topCenter,
children: [ child: SizedBox(
width: maxW,
child: Column(
children: [
if (!_isDrawStarted) _buildAnimationSelector(), if (!_isDrawStarted) _buildAnimationSelector(),
if (_isDrawStarted) ...[ if (_isDrawStarted) ...[
SlotMachineDrawAnimation( SlotMachineDrawAnimation(
@ -158,35 +164,37 @@ class _DrawAnimationSelectorState extends State<DrawAnimationSelector> {
animationDuration: widget.animationDuration, animationDuration: widget.animationDuration,
), ),
SizedBox(height: 20.h), SizedBox(height: 20.h),
SizedBox( if (widget.allowReplay)
width: double.infinity, SizedBox(
child: OutlinedButton( width: double.infinity,
onPressed: _resetDraw, child: OutlinedButton(
style: OutlinedButton.styleFrom( onPressed: _resetDraw,
padding: EdgeInsets.symmetric(vertical: 12.h), style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder( padding: EdgeInsets.symmetric(vertical: 12.h),
borderRadius: BorderRadius.circular(8.r), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.r),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.refresh, size: 18.w, color: Colors.purple.shade600),
SizedBox(width: 6.w),
Text(
'Spin Again',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.purple.shade600,
),
),
],
), ),
), ),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.refresh, size: 18.w, color: Colors.purple.shade600),
SizedBox(width: 6.w),
Text(
'Spin Again',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.purple.shade600,
),
),
],
),
), ),
),
], ],
], ],
),
), ),
); );
} }