From 5c284a3698fc655b372628de60148ee74ceb8f6b Mon Sep 17 00:00:00 2001 From: Deep Koluguri Date: Sun, 5 Apr 2026 23:29:12 -0400 Subject: [PATCH] fixed winner --- .../src/controllers/monthlyDrawController.js | 99 +++- .../lib/core/services/chit_group_service.dart | 6 + .../manager/draw_animation_page.dart | 419 ++++++++++------ .../widgets/alternative_draw_animations.dart | 462 ++++++++++-------- .../widgets/draw_animation_selector.dart | 66 +-- 5 files changed, 647 insertions(+), 405 deletions(-) diff --git a/backend/src/controllers/monthlyDrawController.js b/backend/src/controllers/monthlyDrawController.js index 40c4ffb..a0e05c6 100644 --- a/backend/src/controllers/monthlyDrawController.js +++ b/backend/src/controllers/monthlyDrawController.js @@ -29,7 +29,18 @@ const generateProvablyFairResult = (serverSeed, clientSeed, nonce, eligibleMembe // Create a new monthly draw const createMonthlyDraw = async (req, res) => { 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 const monthInt = parseInt(month, 10); @@ -112,43 +123,92 @@ const createMonthlyDraw = async (req, res) => { attributes: ['winner_id'] }); - const wonMemberIds = wonMembers.map(draw => draw.winner_id); - const availableMembers = eligibleMembers.filter(member => - !wonMemberIds.includes(member.user_id) + const wonMemberIds = wonMembers.map((draw) => draw.winner_id); + const availableMembers = eligibleMembers.filter( + (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({ success: false, message: 'No eligible members available for draw' }); } + const alreadyWon = (uid) => + wonMemberIds.some((id) => id != null && String(id) === String(uid)); + // Determine winner let selectedWinnerId; let selectedWinner; let serverSeed; let resultHash; 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 selectedWinnerId = winner_id; serverSeed = `PAST_DRAW_${Date.now()}`; nonce = Date.now(); resultHash = crypto.createHash('sha256').update(`${serverSeed}_${winner_id}_${nonce}`).digest('hex'); - + // 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) { return res.status(400).json({ success: false, message: 'Selected winner is not a member of this group' }); } - + // Check if this member has already won - if (wonMemberIds.includes(winner_id)) { + if (alreadyWon(winner_id)) { 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.`, @@ -157,7 +217,7 @@ const createMonthlyDraw = async (req, res) => { }); } } else { - // Regular draw - provably fair random selection + // Regular draw - provably fair random selection (API-only / no animation winner) serverSeed = crypto.randomBytes(32).toString('hex'); nonce = Date.now(); @@ -167,7 +227,7 @@ const createMonthlyDraw = async (req, res) => { nonce, availableMembers ); - + selectedWinnerId = result.winner.user_id; selectedWinner = result.winner; resultHash = result.hash; @@ -177,8 +237,8 @@ const createMonthlyDraw = async (req, res) => { const totalMembers = eligibleMembers.length; 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 - const membersForDraw = is_past_draw ? eligibleMembers : availableMembers; + // For past imports, use all active members; for animation + random draws, use not-yet-won pool + const membersForDraw = is_past_draw && !fromAppAnimation ? eligibleMembers : availableMembers; // Create monthly draw const monthlyDraw = await MonthlyDraw.create({ @@ -195,13 +255,16 @@ const createMonthlyDraw = async (req, res) => { prize_amount: calculatedPrizeAmount, server_seed: serverSeed, 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, result_hash: resultHash, status: 'completed', - notes: is_past_draw - ? `Past draw result (imported) - Winner: ${selectedWinner.User.full_name}` - : `Winner selected: ${selectedWinner.User.full_name}` + notes: fromAppAnimation + ? `App animation draw — ${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({ diff --git a/luckychit/lib/core/services/chit_group_service.dart b/luckychit/lib/core/services/chit_group_service.dart index 79d7235..4f7bd43 100644 --- a/luckychit/lib/core/services/chit_group_service.dart +++ b/luckychit/lib/core/services/chit_group_service.dart @@ -467,6 +467,9 @@ class ChitGroupService extends GetxController { int year, { String? clientSeed, double? prizeAmount, + String? animationWinnerUserId, + String? serverSeed, + int? nonce, }) async { try { _isLoading.value = true; @@ -477,6 +480,9 @@ class ChitGroupService extends GetxController { 'year': year, if (clientSeed != null) 'client_seed': clientSeed, 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); diff --git a/luckychit/lib/interfaces/manager/draw_animation_page.dart b/luckychit/lib/interfaces/manager/draw_animation_page.dart index 180b869..d418d3e 100644 --- a/luckychit/lib/interfaces/manager/draw_animation_page.dart +++ b/luckychit/lib/interfaces/manager/draw_animation_page.dart @@ -34,10 +34,13 @@ class _DrawAnimationPageState extends State late AnimationController _fadeController; late Animation _fadeAnimation; bool _isComplete = false; + /// Single client seed for the whole page so animation + API save use the same value. + late final String _animationClientSeed; @override void initState() { super.initState(); + _animationClientSeed = 'DRAW_${DateTime.now().millisecondsSinceEpoch}'; _fadeController = AnimationController( duration: const Duration(milliseconds: 800), @@ -68,14 +71,14 @@ class _DrawAnimationPageState extends State _isComplete = true; }); - // Wait a moment to show the winner - await Future.delayed(const Duration(seconds: 2)); + // Let the slot settle on the winner before the confirmation sheet + await Future.delayed(const Duration(milliseconds: 900)); // Find winner (avoid firstWhere/orElse RTI mismatch Map vs dynamic on web) Map winner = {'name': 'Unknown', 'mobile': ''}; for (final raw in widget.eligibleMembers) { final m = Map.from(raw); - if (m['id'] == winnerId) { + if ('${m['id']}' == '$winnerId') { winner = m; break; } @@ -107,168 +110,247 @@ class _DrawAnimationPageState extends State } Future _showWinnerConfirmation(Map 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( context: context, barrierDismissible: false, - builder: (context) => AlertDialog( - backgroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), - ), - title: Row( - children: [ - Icon(Icons.emoji_events, color: Colors.amber.shade600, size: 32.w), - SizedBox(width: 12.w), - Expanded( - child: Text( - 'Draw Winner!', - style: TextStyle( - fontSize: 22.sp, - fontWeight: FontWeight.bold, - color: Colors.green.shade700, - ), - ), - ), - ], - ), - 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( + builder: (dialogContext) => TweenAnimationBuilder( + tween: Tween(begin: 0.88, end: 1.0), + duration: const Duration(milliseconds: 420), + curve: Curves.easeOutCubic, + builder: (context, scale, child) => + Transform.scale(scale: scale, child: child), + child: AlertDialog( + insetPadding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 24.h), + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20.r), + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Text( - winner['name'] ?? 'Unknown', - style: TextStyle( - fontSize: 24.sp, - fontWeight: FontWeight.bold, - color: Colors.green.shade900, + Container( + padding: EdgeInsets.all(10.w), + decoration: BoxDecoration( + color: Colors.amber.shade50, + shape: BoxShape.circle, ), - textAlign: TextAlign.center, - ), - SizedBox(height: 8.h), - Text( - winner['mobile'] ?? '', - style: TextStyle( - fontSize: 16.sp, - color: Colors.grey.shade700, + child: Icon( + Icons.emoji_events_rounded, + color: Colors.amber.shade800, + size: 28.w, ), ), - SizedBox(height: 16.h), - Divider(color: Colors.green.shade200), - SizedBox(height: 16.h), - Text( - 'Prize Amount', - style: TextStyle( - fontSize: 14.sp, - color: Colors.grey.shade600, - ), - ), - SizedBox(height: 8.h), - Text( - _formatIndianCurrency(bidAmount), - style: TextStyle( - fontSize: 32.sp, - fontWeight: FontWeight.bold, - color: Colors.green.shade700, + SizedBox(width: 12.w), + Expanded( + child: Text( + 'Winner', + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.w800, + color: Colors.grey.shade900, + letterSpacing: -0.5, + ), ), ), ], ), + 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), - - // Quick Actions - Share & Screenshot + ), + actionsPadding: EdgeInsets.fromLTRB(20.w, 0, 20.w, 16.h), + actions: [ Row( children: [ Expanded( - child: OutlinedButton.icon( - onPressed: () => _shareViaWhatsApp(winner, bidAmount), - icon: Icon(Icons.send_rounded, size: 18.w, color: Color(0xFF25D366)), - label: Text('Share', style: TextStyle(fontSize: 13.sp, color: Color(0xFF25D366))), + child: OutlinedButton( + onPressed: () => Navigator.pop(dialogContext, false), style: OutlinedButton.styleFrom( - padding: EdgeInsets.symmetric(vertical: 10.h), - side: BorderSide(color: Color(0xFF25D366)), + padding: EdgeInsets.symmetric(vertical: 14.h), + 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( - child: OutlinedButton.icon( - onPressed: () { - Get.snackbar( - 'Screenshot Tip', - 'Take a screenshot now to save this result!', - backgroundColor: Colors.blue, - colorText: Colors.white, - duration: Duration(seconds: 3), - ); - }, - icon: Icon(Icons.screenshot, size: 18.w, color: Colors.blue.shade600), - label: Text('Capture', style: TextStyle(fontSize: 13.sp, color: Colors.blue.shade600)), - style: OutlinedButton.styleFrom( - padding: EdgeInsets.symmetric(vertical: 10.h), - side: BorderSide(color: Colors.blue.shade600), + flex: 2, + child: FilledButton.icon( + onPressed: () => Navigator.pop(dialogContext, true), + style: FilledButton.styleFrom( + backgroundColor: Colors.green.shade700, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(vertical: 14.h), + ), + icon: Icon(Icons.check_circle_outline, size: 20.w), + label: Text( + 'Save result', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + ), ), ), ), ], ), - 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 { // Show loading Get.dialog( - WillPopScope( - onWillPop: () async => false, + PopScope( + canPop: false, child: Center( child: Container( padding: EdgeInsets.all(32.w), @@ -336,8 +418,11 @@ _Congratulations to the winner!_ widget.group.id, widget.month, widget.year, - clientSeed: 'DRAW_${DateTime.now().millisecondsSinceEpoch}', + clientSeed: _animationClientSeed, prizeAmount: bidAmount, + animationWinnerUserId: winnerId, + serverSeed: widget.serverSeed, + nonce: widget.nonce, ); if (!mounted) return; @@ -467,34 +552,43 @@ _Congratulations to the winner!_ @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async { - // Prevent back button during animation - if (_isComplete) return true; - - final result = await showDialog( + const monthLabels = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + final drawPeriodShort = + '${monthLabels[widget.month - 1]} ${widget.year}'; + + return PopScope( + canPop: _isComplete, + onPopInvokedWithResult: (didPop, result) async { + if (didPop || !mounted) return; + final leave = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Cancel Draw?'), - content: const Text('Are you sure you want to cancel the draw? This action cannot be undone.'), + builder: (ctx) => AlertDialog( + title: const Text('Leave draw?'), + content: const Text( + 'The result is not saved yet. Leave anyway?', + ), actions: [ TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Continue Draw'), + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Stay'), ), - ElevatedButton( - onPressed: () => Navigator.pop(context, true), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade700, foregroundColor: Colors.white, ), - child: const Text('Cancel Draw'), + child: const Text('Leave'), ), ], ), ); - - return result ?? false; + if (leave == true && mounted) { + Get.back(result: false); + } }, child: Scaffold( backgroundColor: Colors.black, @@ -587,9 +681,9 @@ _Congratulations to the winner!_ ), ), child: Text( - 'Month ${widget.month}/${widget.year}', + drawPeriodShort, style: TextStyle( - fontSize: 16.sp, + fontSize: 15.sp, fontWeight: FontWeight.w600, color: Colors.white, ), @@ -613,9 +707,10 @@ _Congratulations to the winner!_ members: widget.eligibleMembers, onDrawComplete: _onDrawComplete, serverSeed: widget.serverSeed, - clientSeed: 'DRAW_${DateTime.now().millisecondsSinceEpoch}', + clientSeed: _animationClientSeed, nonce: widget.nonce, animationDuration: const Duration(seconds: 8), + allowReplay: !_isComplete, ), ), ), diff --git a/luckychit/lib/shared/widgets/alternative_draw_animations.dart b/luckychit/lib/shared/widgets/alternative_draw_animations.dart index 4994046..4d2dd23 100644 --- a/luckychit/lib/shared/widgets/alternative_draw_animations.dart +++ b/luckychit/lib/shared/widgets/alternative_draw_animations.dart @@ -1,4 +1,5 @@ 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'; @@ -518,7 +519,8 @@ class _SlotMachineDrawAnimationState extends State final randomValue = _hashToNumber(hash); final selectedIndex = randomValue % widget.members.length; - _winnerId = widget.members[selectedIndex]['id']; + final raw = widget.members[selectedIndex]['id']; + _winnerId = raw?.toString() ?? ''; } void _startSlotMachine() { @@ -573,31 +575,43 @@ class _SlotMachineDrawAnimationState extends State } void _completeAnimation() { - final winnerName = widget.members.firstWhere((m) => m['id'] == _winnerId)['name']; - final winnerIndex = _baseNames.indexOf(winnerName); - + // 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 ? [winnerName] : otherNames; + + final window = List.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; - if (winnerIndex != -1) { - final List 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; - } + _displayNames = window; }); _pulseController.stop(); - // Delay callback slightly for visual effect - Future.delayed(const Duration(milliseconds: 500), () { - widget.onDrawComplete(_winnerId!); + Future.delayed(const Duration(milliseconds: 650), () { + if (_winnerId != null && _winnerId!.isNotEmpty) { + widget.onDrawComplete(_winnerId!); + } }); } @@ -618,198 +632,254 @@ class _SlotMachineDrawAnimationState extends State @override Widget build(BuildContext context) { - final winnerName = _winnerId != null - ? widget.members.firstWhere((m) => m['id'] == _winnerId)['name'] - : null; + String? winnerName; + if (_winnerId != null && _winnerId!.isNotEmpty) { + for (final m in widget.members) { + if ('${m['id']}' == '${_winnerId}') { + winnerName = m['name']?.toString(); + break; + } + } + } - return Container( - width: 320.w, - height: 520.h, - child: Column( - children: [ - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.grey.shade900, - Colors.grey.shade800, - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - borderRadius: BorderRadius.circular(20.r), - 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), + final maxW = MediaQuery.sizeOf(context).width - 40.w; + + return 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( + children: [ + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.grey.shade900, + Colors.grey.shade800, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + borderRadius: BorderRadius.circular(20.r), + 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), - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(12.r), - ), - child: Stack( - 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; + child: Container( + margin: EdgeInsets.all(18.w), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(12.r), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final rowH = constraints.maxHeight / 7; + return Stack( + clipBehavior: Clip.hardEdge, + 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 double scale = isWinner || isCenterHighlight ? 1.08 : 1.0; - final double fontSize = isWinner - ? 24.sp - : isCenterHighlight - ? 20.sp - : 18.sp; - final FontWeight weight = isWinner - ? FontWeight.w900 - : isCenterHighlight - ? FontWeight.w800 - : FontWeight.w700; - final List colors = isWinner - ? [Colors.green.shade500, Colors.green.shade600] - : isCenterHighlight - ? [Colors.deepPurple.shade500, Colors.deepPurple.shade700] - : [Colors.blueGrey.shade700, Colors.blueGrey.shade900]; + return Expanded( + child: AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + final double scale = + isWinner || isCenterHighlight + ? 1.08 + : 1.0; + final double fontSize = isWinner + ? 24.sp + : isCenterHighlight + ? 20.sp + : 18.sp; + final FontWeight weight = isWinner + ? FontWeight.w900 + : isCenterHighlight + ? FontWeight.w800 + : FontWeight.w700; + final List colors = isWinner + ? [ + Colors.green.shade500, + Colors.green.shade600, + ] + : isCenterHighlight + ? [ + Colors.deepPurple.shade500, + Colors.deepPurple.shade700, + ] + : [ + Colors.blueGrey.shade700, + Colors.blueGrey.shade900, + ]; - return AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeInOut, - transform: Matrix4.identity()..scale(scale), - child: Container( - width: double.infinity, - margin: EdgeInsets.symmetric( - vertical: 6.h, - horizontal: 12.w, - ), - decoration: BoxDecoration( - 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), + return AnimatedContainer( + duration: const Duration( + milliseconds: 220), + curve: Curves.easeInOut, + transform: Matrix4.identity() + ..scale(scale), + child: Container( + width: double.infinity, + margin: EdgeInsets.symmetric( + vertical: 6.h, + horizontal: 12.w, ), - ], - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + decoration: BoxDecoration( + 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: + 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) - Container( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), - decoration: BoxDecoration( - color: Colors.green.shade50, - borderRadius: BorderRadius.circular(12.r), - ), - 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, + SizedBox(height: 16.h), + if (_isComplete && winnerName != null) + Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.green.shade50, + Colors.green.shade100.withOpacity(0.85), + ], ), + 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, + ), + ), + ], + ), + ), + ], + ), + ), ), ); } diff --git a/luckychit/lib/shared/widgets/draw_animation_selector.dart b/luckychit/lib/shared/widgets/draw_animation_selector.dart index f59eb0d..bfff320 100644 --- a/luckychit/lib/shared/widgets/draw_animation_selector.dart +++ b/luckychit/lib/shared/widgets/draw_animation_selector.dart @@ -9,6 +9,8 @@ class DrawAnimationSelector extends StatefulWidget { final String? clientSeed; final int? nonce; final Duration animationDuration; + /// When false, hides "Spin again" after a run (e.g. draw already finalized on parent). + final bool allowReplay; const DrawAnimationSelector({ super.key, @@ -18,6 +20,7 @@ class DrawAnimationSelector extends StatefulWidget { this.clientSeed, this.nonce, this.animationDuration = const Duration(seconds: 4), + this.allowReplay = true, }); @override @@ -143,10 +146,13 @@ class _DrawAnimationSelectorState extends State { @override Widget build(BuildContext context) { - return Container( - width: 400.w, - child: Column( - children: [ + final maxW = (MediaQuery.sizeOf(context).width - 32.w).clamp(280.w, 440.w); + return Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: maxW, + child: Column( + children: [ if (!_isDrawStarted) _buildAnimationSelector(), if (_isDrawStarted) ...[ SlotMachineDrawAnimation( @@ -158,35 +164,37 @@ class _DrawAnimationSelectorState extends State { animationDuration: widget.animationDuration, ), SizedBox(height: 20.h), - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: _resetDraw, - style: OutlinedButton.styleFrom( - padding: EdgeInsets.symmetric(vertical: 12.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.r), + if (widget.allowReplay) + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _resetDraw, + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 12.h), + 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, - ), - ), - ], - ), ), - ), ], - ], + ], + ), ), ); }