fixed winner
This commit is contained in:
parent
37c6c497ff
commit
5c284a3698
|
|
@ -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}`
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
|||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue