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
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,26 +123,75 @@ 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()}`;
@ -139,7 +199,7 @@ const createMonthlyDraw = async (req, res) => {
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,
@ -148,7 +208,7 @@ const createMonthlyDraw = async (req, res) => {
}
// 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();
@ -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,11 +255,14 @@ 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
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}`
});

View File

@ -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);

View File

@ -34,10 +34,13 @@ class _DrawAnimationPageState extends State<DrawAnimationPage>
late AnimationController _fadeController;
late Animation<double> _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<DrawAnimationPage>
_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<String,String> vs dynamic on web)
Map<String, dynamic> winner = {'name': 'Unknown', 'mobile': ''};
for (final raw in widget.eligibleMembers) {
final m = Map<String, dynamic>.from(raw);
if (m['id'] == winnerId) {
if ('${m['id']}' == '$winnerId') {
winner = m;
break;
}
@ -107,94 +110,158 @@ class _DrawAnimationPageState extends State<DrawAnimationPage>
}
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>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
builder: (dialogContext) => TweenAnimationBuilder<double>(
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(16.r),
borderRadius: BorderRadius.circular(20.r),
),
title: Row(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.emoji_events, color: Colors.amber.shade600, size: 32.w),
Row(
children: [
Container(
padding: EdgeInsets.all(10.w),
decoration: BoxDecoration(
color: Colors.amber.shade50,
shape: BoxShape.circle,
),
child: Icon(
Icons.emoji_events_rounded,
color: Colors.amber.shade800,
size: 28.w,
),
),
SizedBox(width: 12.w),
Expanded(
child: Text(
'Draw Winner!',
'Winner',
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.bold,
color: Colors.green.shade700,
fontWeight: FontWeight.w800,
color: Colors.grey.shade900,
letterSpacing: -0.5,
),
),
),
],
),
content: Column(
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(
padding: EdgeInsets.all(20.w),
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 22.h),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: Colors.green.shade200, width: 2),
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'] ?? 'Unknown',
winner['name']?.toString() ?? 'Unknown',
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.bold,
fontSize: 26.sp,
fontWeight: FontWeight.w800,
color: Colors.green.shade900,
height: 1.15,
),
textAlign: TextAlign.center,
),
SizedBox(height: 8.h),
if ((winner['mobile']?.toString() ?? '').isNotEmpty) ...[
SizedBox(height: 10.h),
Text(
winner['mobile'] ?? '',
winner['mobile']?.toString() ?? '',
style: TextStyle(
fontSize: 16.sp,
fontSize: 15.sp,
color: Colors.grey.shade700,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
SizedBox(height: 16.h),
Divider(color: Colors.green.shade200),
SizedBox(height: 16.h),
],
SizedBox(height: 18.h),
Container(
height: 1,
color: Colors.green.shade200.withOpacity(0.9),
),
SizedBox(height: 14.h),
Text(
'Prize Amount',
'Prize amount',
style: TextStyle(
fontSize: 14.sp,
fontSize: 13.sp,
color: Colors.grey.shade600,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
),
SizedBox(height: 8.h),
SizedBox(height: 6.h),
Text(
_formatIndianCurrency(bidAmount),
style: TextStyle(
fontSize: 32.sp,
fontWeight: FontWeight.bold,
color: Colors.green.shade700,
fontSize: 30.sp,
fontWeight: FontWeight.w800,
color: Colors.green.shade800,
),
),
],
),
),
SizedBox(height: 16.h),
// Quick Actions - Share & Screenshot
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))),
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: 10.h),
side: BorderSide(color: Color(0xFF25D366)),
padding: EdgeInsets.symmetric(vertical: 12.h),
side: const BorderSide(color: Color(0xFF25D366)),
),
),
),
@ -203,73 +270,88 @@ class _DrawAnimationPageState extends State<DrawAnimationPage>
child: OutlinedButton.icon(
onPressed: () {
Get.snackbar(
'Screenshot Tip',
'Take a screenshot now to save this result!',
backgroundColor: Colors.blue,
'Screenshot',
'Capture this screen if you want a local copy.',
backgroundColor: Colors.blue.shade700,
colorText: Colors.white,
duration: Duration(seconds: 3),
duration: const 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)),
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: 10.h),
side: BorderSide(color: Colors.blue.shade600),
padding: EdgeInsets.symmetric(vertical: 12.h),
side: BorderSide(color: Colors.blue.shade700),
),
),
),
],
),
SizedBox(height: 20.h),
SizedBox(height: 12.h),
Text(
'Do you want to save this draw result?',
'Save to record this result and get a shareable public link.',
style: TextStyle(
fontSize: 16.sp,
fontSize: 14.sp,
color: Colors.grey.shade700,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
height: 1.35,
),
textAlign: TextAlign.center,
),
],
),
actionsAlignment: MainAxisAlignment.end,
actionsPadding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h),
),
actionsPadding: EdgeInsets.fromLTRB(20.w, 0, 20.w, 16.h),
actions: [
// Cancel button
OutlinedButton(
onPressed: () => Navigator.pop(context, false),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(dialogContext, false),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 14.h),
padding: EdgeInsets.symmetric(vertical: 14.h),
side: BorderSide(color: Colors.grey.shade400),
),
child: Text(
'Cancel',
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600),
'Discard',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
SizedBox(width: 12.w),
// Save button
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade600,
),
),
SizedBox(width: 10.w),
Expanded(
flex: 2,
child: FilledButton.icon(
onPressed: () => Navigator.pop(dialogContext, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.green.shade700,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 14.h),
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,
),
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;
const monthLabels = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
final drawPeriodShort =
'${monthLabels[widget.month - 1]} ${widget.year}';
final result = await showDialog<bool>(
return PopScope(
canPop: _isComplete,
onPopInvokedWithResult: (didPop, result) async {
if (didPop || !mounted) return;
final leave = await showDialog<bool>(
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,
),
),
),

View File

@ -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<SlotMachineDrawAnimation>
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<SlotMachineDrawAnimation>
}
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 ? <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;
if (winnerIndex != -1) {
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();
// Delay callback slightly for visual effect
Future.delayed(const Duration(milliseconds: 500), () {
Future.delayed(const Duration(milliseconds: 650), () {
if (_winnerId != null && _winnerId!.isNotEmpty) {
widget.onDrawComplete(_winnerId!);
}
});
}
@ -618,13 +632,24 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
@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,
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(
@ -655,23 +680,32 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
color: Colors.black,
borderRadius: BorderRadius.circular(12.r),
),
child: Stack(
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 displayIndex =
index < _displayNames.length ? index : 0;
final name = _displayNames[displayIndex];
final isWinner = _isComplete && index == 3;
final isCenterHighlight = _isAnimating && 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 scale =
isWinner || isCenterHighlight
? 1.08
: 1.0;
final double fontSize = isWinner
? 24.sp
: isCenterHighlight
@ -683,15 +717,26 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
? FontWeight.w800
: FontWeight.w700;
final List<Color> colors = isWinner
? [Colors.green.shade500, Colors.green.shade600]
? [
Colors.green.shade500,
Colors.green.shade600,
]
: isCenterHighlight
? [Colors.deepPurple.shade500, Colors.deepPurple.shade700]
: [Colors.blueGrey.shade700, Colors.blueGrey.shade900];
? [
Colors.deepPurple.shade500,
Colors.deepPurple.shade700,
]
: [
Colors.blueGrey.shade700,
Colors.blueGrey.shade900,
];
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
duration: const Duration(
milliseconds: 220),
curve: Curves.easeInOut,
transform: Matrix4.identity()..scale(scale),
transform: Matrix4.identity()
..scale(scale),
child: Container(
width: double.infinity,
margin: EdgeInsets.symmetric(
@ -704,22 +749,35 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10.r),
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,
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,
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,
name.length > 22
? '${name.substring(0, 22)}'
: name,
style: TextStyle(
fontSize: fontSize,
fontWeight: weight,
@ -727,15 +785,18 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
letterSpacing: 0.6,
shadows: [
Shadow(
color: Colors.black.withOpacity(0.5),
color: Colors.black
.withOpacity(0.5),
blurRadius: 4.r,
offset: Offset(1.5, 1.5),
offset:
const Offset(1.5, 1.5),
),
],
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
overflow:
TextOverflow.ellipsis,
),
),
),
@ -749,33 +810,32 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
),
if (_isComplete)
Positioned(
top: 0,
top: rowH * 3,
left: 0,
right: 0,
child: Container(
height: 70.h,
height: rowH,
child: IgnorePointer(
child: DecoratedBox(
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,
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,
),
],
),
),
),
),
],
);
},
),
),
),
@ -783,23 +843,31 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
SizedBox(height: 16.h),
if (_isComplete && winnerName != null)
Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(12.r),
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.green.shade600, size: 20.w),
SizedBox(width: 8.w),
Icon(Icons.emoji_events, color: Colors.amber.shade700, size: 26.w),
SizedBox(width: 10.w),
Flexible(
child: Text(
winnerName,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w700,
color: Colors.green.shade700,
fontSize: 19.sp,
fontWeight: FontWeight.w800,
color: Colors.green.shade900,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 2,
@ -811,6 +879,8 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
),
],
),
),
),
);
}
}

View File

@ -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,8 +146,11 @@ class _DrawAnimationSelectorState extends State<DrawAnimationSelector> {
@override
Widget build(BuildContext context) {
return Container(
width: 400.w,
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(),
@ -158,6 +164,7 @@ class _DrawAnimationSelectorState extends State<DrawAnimationSelector> {
animationDuration: widget.animationDuration,
),
SizedBox(height: 20.h),
if (widget.allowReplay)
SizedBox(
width: double.infinity,
child: OutlinedButton(
@ -188,6 +195,7 @@ class _DrawAnimationSelectorState extends State<DrawAnimationSelector> {
],
],
),
),
);
}
}