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
|
// Create a new monthly draw
|
||||||
const createMonthlyDraw = async (req, res) => {
|
const createMonthlyDraw = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { group_id, month, year, client_seed, winner_id, prize_amount, is_past_draw } = req.body;
|
const {
|
||||||
|
group_id,
|
||||||
|
month,
|
||||||
|
year,
|
||||||
|
client_seed,
|
||||||
|
winner_id,
|
||||||
|
prize_amount,
|
||||||
|
is_past_draw,
|
||||||
|
animation_winner_user_id,
|
||||||
|
server_seed: bodyServerSeed,
|
||||||
|
nonce: bodyNonce
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
// Convert month and year to integers
|
// Convert month and year to integers
|
||||||
const monthInt = parseInt(month, 10);
|
const monthInt = parseInt(month, 10);
|
||||||
|
|
@ -112,26 +123,75 @@ const createMonthlyDraw = async (req, res) => {
|
||||||
attributes: ['winner_id']
|
attributes: ['winner_id']
|
||||||
});
|
});
|
||||||
|
|
||||||
const wonMemberIds = wonMembers.map(draw => draw.winner_id);
|
const wonMemberIds = wonMembers.map((draw) => draw.winner_id);
|
||||||
const availableMembers = eligibleMembers.filter(member =>
|
const availableMembers = eligibleMembers.filter(
|
||||||
!wonMemberIds.includes(member.user_id)
|
(member) =>
|
||||||
|
!wonMemberIds.some((id) => id != null && String(id) === String(member.user_id))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (availableMembers.length === 0 && !is_past_draw) {
|
const animationWinner =
|
||||||
|
animation_winner_user_id != null && String(animation_winner_user_id).trim() !== ''
|
||||||
|
? String(animation_winner_user_id).trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (availableMembers.length === 0 && !is_past_draw && !animationWinner) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'No eligible members available for draw'
|
message: 'No eligible members available for draw'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const alreadyWon = (uid) =>
|
||||||
|
wonMemberIds.some((id) => id != null && String(id) === String(uid));
|
||||||
|
|
||||||
// Determine winner
|
// Determine winner
|
||||||
let selectedWinnerId;
|
let selectedWinnerId;
|
||||||
let selectedWinner;
|
let selectedWinner;
|
||||||
let serverSeed;
|
let serverSeed;
|
||||||
let resultHash;
|
let resultHash;
|
||||||
let nonce;
|
let nonce;
|
||||||
|
let fromAppAnimation = false;
|
||||||
|
|
||||||
if (is_past_draw && winner_id) {
|
if (animationWinner) {
|
||||||
|
// App showed a winner in the animation; persist that user (manager-authenticated).
|
||||||
|
// Without this branch the server re-rolls and WhatsApp/public links show the wrong name.
|
||||||
|
fromAppAnimation = true;
|
||||||
|
selectedWinnerId = animationWinner;
|
||||||
|
selectedWinner = eligibleMembers.find((m) => String(m.user_id) === selectedWinnerId);
|
||||||
|
if (!selectedWinner) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Winner is not an active member of this group'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (alreadyWon(selectedWinnerId)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `${selectedWinner.User.full_name} has already won in a previous draw. Each member can only win once.`,
|
||||||
|
alreadyWon: true,
|
||||||
|
winnerName: selectedWinner.User.full_name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!availableMembers.some((m) => String(m.user_id) === selectedWinnerId)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Winner is not eligible for this draw (already won or not in pool)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
serverSeed =
|
||||||
|
bodyServerSeed && String(bodyServerSeed).trim() !== ''
|
||||||
|
? String(bodyServerSeed).trim()
|
||||||
|
: crypto.randomBytes(32).toString('hex');
|
||||||
|
nonce = bodyNonce != null ? parseInt(bodyNonce, 10) : Date.now();
|
||||||
|
if (Number.isNaN(nonce)) {
|
||||||
|
nonce = Date.now();
|
||||||
|
}
|
||||||
|
const cs = client_seed != null && String(client_seed).trim() !== '' ? String(client_seed) : 'animation';
|
||||||
|
resultHash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(`${serverSeed}-${cs}-${nonce}-${selectedWinnerId}`)
|
||||||
|
.digest('hex');
|
||||||
|
} else if (is_past_draw && winner_id) {
|
||||||
// Past draw - manual winner selection
|
// Past draw - manual winner selection
|
||||||
selectedWinnerId = winner_id;
|
selectedWinnerId = winner_id;
|
||||||
serverSeed = `PAST_DRAW_${Date.now()}`;
|
serverSeed = `PAST_DRAW_${Date.now()}`;
|
||||||
|
|
@ -139,7 +199,7 @@ const createMonthlyDraw = async (req, res) => {
|
||||||
resultHash = crypto.createHash('sha256').update(`${serverSeed}_${winner_id}_${nonce}`).digest('hex');
|
resultHash = crypto.createHash('sha256').update(`${serverSeed}_${winner_id}_${nonce}`).digest('hex');
|
||||||
|
|
||||||
// Get winner details
|
// Get winner details
|
||||||
selectedWinner = eligibleMembers.find(m => m.user_id === winner_id);
|
selectedWinner = eligibleMembers.find((m) => String(m.user_id) === String(winner_id));
|
||||||
if (!selectedWinner) {
|
if (!selectedWinner) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -148,7 +208,7 @@ const createMonthlyDraw = async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this member has already won
|
// Check if this member has already won
|
||||||
if (wonMemberIds.includes(winner_id)) {
|
if (alreadyWon(winner_id)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: `${selectedWinner.User.full_name} has already won in a previous draw. Each member can only win once.`,
|
message: `${selectedWinner.User.full_name} has already won in a previous draw. Each member can only win once.`,
|
||||||
|
|
@ -157,7 +217,7 @@ const createMonthlyDraw = async (req, res) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular draw - provably fair random selection
|
// Regular draw - provably fair random selection (API-only / no animation winner)
|
||||||
serverSeed = crypto.randomBytes(32).toString('hex');
|
serverSeed = crypto.randomBytes(32).toString('hex');
|
||||||
nonce = Date.now();
|
nonce = Date.now();
|
||||||
|
|
||||||
|
|
@ -177,8 +237,8 @@ const createMonthlyDraw = async (req, res) => {
|
||||||
const totalMembers = eligibleMembers.length;
|
const totalMembers = eligibleMembers.length;
|
||||||
const calculatedPrizeAmount = prize_amount || chitGroup.total_value; // Use provided or default
|
const calculatedPrizeAmount = prize_amount || chitGroup.total_value; // Use provided or default
|
||||||
|
|
||||||
// For past draws, use all eligible members; for regular draws, use only available members
|
// For past imports, use all active members; for animation + random draws, use not-yet-won pool
|
||||||
const membersForDraw = is_past_draw ? eligibleMembers : availableMembers;
|
const membersForDraw = is_past_draw && !fromAppAnimation ? eligibleMembers : availableMembers;
|
||||||
|
|
||||||
// Create monthly draw
|
// Create monthly draw
|
||||||
const monthlyDraw = await MonthlyDraw.create({
|
const monthlyDraw = await MonthlyDraw.create({
|
||||||
|
|
@ -195,13 +255,16 @@ const createMonthlyDraw = async (req, res) => {
|
||||||
prize_amount: calculatedPrizeAmount,
|
prize_amount: calculatedPrizeAmount,
|
||||||
server_seed: serverSeed,
|
server_seed: serverSeed,
|
||||||
server_seed_hash: crypto.createHash('sha256').update(serverSeed).digest('hex'),
|
server_seed_hash: crypto.createHash('sha256').update(serverSeed).digest('hex'),
|
||||||
client_seed: client_seed || (is_past_draw ? 'PAST_DRAW' : 'default'),
|
client_seed:
|
||||||
|
client_seed || (fromAppAnimation ? 'animation' : is_past_draw ? 'PAST_DRAW' : 'default'),
|
||||||
nonce,
|
nonce,
|
||||||
result_hash: resultHash,
|
result_hash: resultHash,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
notes: is_past_draw
|
notes: fromAppAnimation
|
||||||
? `Past draw result (imported) - Winner: ${selectedWinner.User.full_name}`
|
? `App animation draw — ${selectedWinner.User.full_name}`
|
||||||
: `Winner selected: ${selectedWinner.User.full_name}`
|
: is_past_draw
|
||||||
|
? `Past draw result (imported) - Winner: ${selectedWinner.User.full_name}`
|
||||||
|
: `Winner selected: ${selectedWinner.User.full_name}`
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
|
|
|
||||||
|
|
@ -467,6 +467,9 @@ class ChitGroupService extends GetxController {
|
||||||
int year, {
|
int year, {
|
||||||
String? clientSeed,
|
String? clientSeed,
|
||||||
double? prizeAmount,
|
double? prizeAmount,
|
||||||
|
String? animationWinnerUserId,
|
||||||
|
String? serverSeed,
|
||||||
|
int? nonce,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
_isLoading.value = true;
|
_isLoading.value = true;
|
||||||
|
|
@ -477,6 +480,9 @@ class ChitGroupService extends GetxController {
|
||||||
'year': year,
|
'year': year,
|
||||||
if (clientSeed != null) 'client_seed': clientSeed,
|
if (clientSeed != null) 'client_seed': clientSeed,
|
||||||
if (prizeAmount != null) 'prize_amount': prizeAmount,
|
if (prizeAmount != null) 'prize_amount': prizeAmount,
|
||||||
|
if (animationWinnerUserId != null) 'animation_winner_user_id': animationWinnerUserId,
|
||||||
|
if (serverSeed != null) 'server_seed': serverSeed,
|
||||||
|
if (nonce != null) 'nonce': nonce,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await _apiService.createMonthlyDraw(drawData);
|
final response = await _apiService.createMonthlyDraw(drawData);
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,13 @@ class _DrawAnimationPageState extends State<DrawAnimationPage>
|
||||||
late AnimationController _fadeController;
|
late AnimationController _fadeController;
|
||||||
late Animation<double> _fadeAnimation;
|
late Animation<double> _fadeAnimation;
|
||||||
bool _isComplete = false;
|
bool _isComplete = false;
|
||||||
|
/// Single client seed for the whole page so animation + API save use the same value.
|
||||||
|
late final String _animationClientSeed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_animationClientSeed = 'DRAW_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
|
||||||
_fadeController = AnimationController(
|
_fadeController = AnimationController(
|
||||||
duration: const Duration(milliseconds: 800),
|
duration: const Duration(milliseconds: 800),
|
||||||
|
|
@ -68,14 +71,14 @@ class _DrawAnimationPageState extends State<DrawAnimationPage>
|
||||||
_isComplete = true;
|
_isComplete = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait a moment to show the winner
|
// Let the slot settle on the winner before the confirmation sheet
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(milliseconds: 900));
|
||||||
|
|
||||||
// Find winner (avoid firstWhere/orElse RTI mismatch Map<String,String> vs dynamic on web)
|
// Find winner (avoid firstWhere/orElse RTI mismatch Map<String,String> vs dynamic on web)
|
||||||
Map<String, dynamic> winner = {'name': 'Unknown', 'mobile': ''};
|
Map<String, dynamic> winner = {'name': 'Unknown', 'mobile': ''};
|
||||||
for (final raw in widget.eligibleMembers) {
|
for (final raw in widget.eligibleMembers) {
|
||||||
final m = Map<String, dynamic>.from(raw);
|
final m = Map<String, dynamic>.from(raw);
|
||||||
if (m['id'] == winnerId) {
|
if ('${m['id']}' == '$winnerId') {
|
||||||
winner = m;
|
winner = m;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -107,168 +110,247 @@ class _DrawAnimationPageState extends State<DrawAnimationPage>
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool?> _showWinnerConfirmation(Map<String, dynamic> winner, double bidAmount) async {
|
Future<bool?> _showWinnerConfirmation(Map<String, dynamic> winner, double bidAmount) async {
|
||||||
|
await HapticFeedback.mediumImpact();
|
||||||
|
final monthNames = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
final periodLabel =
|
||||||
|
'${monthNames[widget.month - 1]} ${widget.year} · ${widget.group.name}';
|
||||||
|
|
||||||
return showDialog<bool>(
|
return showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => AlertDialog(
|
builder: (dialogContext) => TweenAnimationBuilder<double>(
|
||||||
backgroundColor: Colors.white,
|
tween: Tween(begin: 0.88, end: 1.0),
|
||||||
shape: RoundedRectangleBorder(
|
duration: const Duration(milliseconds: 420),
|
||||||
borderRadius: BorderRadius.circular(16.r),
|
curve: Curves.easeOutCubic,
|
||||||
),
|
builder: (context, scale, child) =>
|
||||||
title: Row(
|
Transform.scale(scale: scale, child: child),
|
||||||
children: [
|
child: AlertDialog(
|
||||||
Icon(Icons.emoji_events, color: Colors.amber.shade600, size: 32.w),
|
insetPadding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 24.h),
|
||||||
SizedBox(width: 12.w),
|
backgroundColor: Colors.white,
|
||||||
Expanded(
|
shape: RoundedRectangleBorder(
|
||||||
child: Text(
|
borderRadius: BorderRadius.circular(20.r),
|
||||||
'Draw Winner!',
|
),
|
||||||
style: TextStyle(
|
title: Column(
|
||||||
fontSize: 22.sp,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontWeight: FontWeight.bold,
|
children: [
|
||||||
color: Colors.green.shade700,
|
Row(
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.all(20.w),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.green.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(12.r),
|
|
||||||
border: Border.all(color: Colors.green.shade200, width: 2),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Container(
|
||||||
winner['name'] ?? 'Unknown',
|
padding: EdgeInsets.all(10.w),
|
||||||
style: TextStyle(
|
decoration: BoxDecoration(
|
||||||
fontSize: 24.sp,
|
color: Colors.amber.shade50,
|
||||||
fontWeight: FontWeight.bold,
|
shape: BoxShape.circle,
|
||||||
color: Colors.green.shade900,
|
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
child: Icon(
|
||||||
),
|
Icons.emoji_events_rounded,
|
||||||
SizedBox(height: 8.h),
|
color: Colors.amber.shade800,
|
||||||
Text(
|
size: 28.w,
|
||||||
winner['mobile'] ?? '',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16.sp,
|
|
||||||
color: Colors.grey.shade700,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 16.h),
|
SizedBox(width: 12.w),
|
||||||
Divider(color: Colors.green.shade200),
|
Expanded(
|
||||||
SizedBox(height: 16.h),
|
child: Text(
|
||||||
Text(
|
'Winner',
|
||||||
'Prize Amount',
|
style: TextStyle(
|
||||||
style: TextStyle(
|
fontSize: 22.sp,
|
||||||
fontSize: 14.sp,
|
fontWeight: FontWeight.w800,
|
||||||
color: Colors.grey.shade600,
|
color: Colors.grey.shade900,
|
||||||
),
|
letterSpacing: -0.5,
|
||||||
),
|
),
|
||||||
SizedBox(height: 8.h),
|
|
||||||
Text(
|
|
||||||
_formatIndianCurrency(bidAmount),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 32.sp,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.green.shade700,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
SizedBox(height: 8.h),
|
||||||
|
Text(
|
||||||
|
periodLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.sp,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 22.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
Colors.green.shade50,
|
||||||
|
Colors.teal.shade50.withOpacity(0.85),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16.r),
|
||||||
|
border: Border.all(color: Colors.green.shade200, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
winner['name']?.toString() ?? 'Unknown',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 26.sp,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.green.shade900,
|
||||||
|
height: 1.15,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if ((winner['mobile']?.toString() ?? '').isNotEmpty) ...[
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
Text(
|
||||||
|
winner['mobile']?.toString() ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15.sp,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
SizedBox(height: 18.h),
|
||||||
|
Container(
|
||||||
|
height: 1,
|
||||||
|
color: Colors.green.shade200.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
SizedBox(height: 14.h),
|
||||||
|
Text(
|
||||||
|
'Prize amount',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 6.h),
|
||||||
|
Text(
|
||||||
|
_formatIndianCurrency(bidAmount),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 30.sp,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.green.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 16.h),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _shareViaWhatsApp(winner, bidAmount),
|
||||||
|
icon: Icon(Icons.send_rounded,
|
||||||
|
size: 18.w, color: const Color(0xFF25D366)),
|
||||||
|
label: Text(
|
||||||
|
'Share',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp, color: const Color(0xFF25D366)),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||||
|
side: const BorderSide(color: Color(0xFF25D366)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 8.w),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Get.snackbar(
|
||||||
|
'Screenshot',
|
||||||
|
'Capture this screen if you want a local copy.',
|
||||||
|
backgroundColor: Colors.blue.shade700,
|
||||||
|
colorText: Colors.white,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: Icon(Icons.photo_camera_outlined,
|
||||||
|
size: 18.w, color: Colors.blue.shade700),
|
||||||
|
label: Text(
|
||||||
|
'Capture',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp, color: Colors.blue.shade700),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||||
|
side: BorderSide(color: Colors.blue.shade700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
Text(
|
||||||
|
'Save to record this result and get a shareable public link.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 16.h),
|
),
|
||||||
|
actionsPadding: EdgeInsets.fromLTRB(20.w, 0, 20.w, 16.h),
|
||||||
// Quick Actions - Share & Screenshot
|
actions: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton(
|
||||||
onPressed: () => _shareViaWhatsApp(winner, bidAmount),
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
icon: Icon(Icons.send_rounded, size: 18.w, color: Color(0xFF25D366)),
|
|
||||||
label: Text('Share', style: TextStyle(fontSize: 13.sp, color: Color(0xFF25D366))),
|
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
padding: EdgeInsets.symmetric(vertical: 10.h),
|
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||||
side: BorderSide(color: Color(0xFF25D366)),
|
side: BorderSide(color: Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Discard',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8.w),
|
SizedBox(width: 10.w),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
flex: 2,
|
||||||
onPressed: () {
|
child: FilledButton.icon(
|
||||||
Get.snackbar(
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
'Screenshot Tip',
|
style: FilledButton.styleFrom(
|
||||||
'Take a screenshot now to save this result!',
|
backgroundColor: Colors.green.shade700,
|
||||||
backgroundColor: Colors.blue,
|
foregroundColor: Colors.white,
|
||||||
colorText: Colors.white,
|
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||||
duration: Duration(seconds: 3),
|
),
|
||||||
);
|
icon: Icon(Icons.check_circle_outline, size: 20.w),
|
||||||
},
|
label: Text(
|
||||||
icon: Icon(Icons.screenshot, size: 18.w, color: Colors.blue.shade600),
|
'Save result',
|
||||||
label: Text('Capture', style: TextStyle(fontSize: 13.sp, color: Colors.blue.shade600)),
|
style: TextStyle(
|
||||||
style: OutlinedButton.styleFrom(
|
fontSize: 15.sp,
|
||||||
padding: EdgeInsets.symmetric(vertical: 10.h),
|
fontWeight: FontWeight.w700,
|
||||||
side: BorderSide(color: Colors.blue.shade600),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 20.h),
|
|
||||||
Text(
|
|
||||||
'Do you want to save this draw result?',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16.sp,
|
|
||||||
color: Colors.grey.shade700,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actionsAlignment: MainAxisAlignment.end,
|
|
||||||
actionsPadding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h),
|
|
||||||
actions: [
|
|
||||||
// Cancel button
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 14.h),
|
|
||||||
side: BorderSide(color: Colors.grey.shade400),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Cancel',
|
|
||||||
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 12.w),
|
|
||||||
// Save button
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.green.shade600,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 14.h),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.save, size: 20.w),
|
|
||||||
SizedBox(width: 8.w),
|
|
||||||
Text(
|
|
||||||
'Save Result',
|
|
||||||
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -306,8 +388,8 @@ _Congratulations to the winner!_
|
||||||
try {
|
try {
|
||||||
// Show loading
|
// Show loading
|
||||||
Get.dialog(
|
Get.dialog(
|
||||||
WillPopScope(
|
PopScope(
|
||||||
onWillPop: () async => false,
|
canPop: false,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.all(32.w),
|
padding: EdgeInsets.all(32.w),
|
||||||
|
|
@ -336,8 +418,11 @@ _Congratulations to the winner!_
|
||||||
widget.group.id,
|
widget.group.id,
|
||||||
widget.month,
|
widget.month,
|
||||||
widget.year,
|
widget.year,
|
||||||
clientSeed: 'DRAW_${DateTime.now().millisecondsSinceEpoch}',
|
clientSeed: _animationClientSeed,
|
||||||
prizeAmount: bidAmount,
|
prizeAmount: bidAmount,
|
||||||
|
animationWinnerUserId: winnerId,
|
||||||
|
serverSeed: widget.serverSeed,
|
||||||
|
nonce: widget.nonce,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -467,34 +552,43 @@ _Congratulations to the winner!_
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return WillPopScope(
|
const monthLabels = [
|
||||||
onWillPop: () async {
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
// Prevent back button during animation
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
if (_isComplete) return true;
|
];
|
||||||
|
final drawPeriodShort =
|
||||||
|
'${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,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Cancel Draw?'),
|
title: const Text('Leave draw?'),
|
||||||
content: const Text('Are you sure you want to cancel the draw? This action cannot be undone.'),
|
content: const Text(
|
||||||
|
'The result is not saved yet. Leave anyway?',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
child: const Text('Continue Draw'),
|
child: const Text('Stay'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
style: ElevatedButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red.shade700,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
child: const Text('Cancel Draw'),
|
child: const Text('Leave'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (leave == true && mounted) {
|
||||||
return result ?? false;
|
Get.back(result: false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
|
|
@ -587,9 +681,9 @@ _Congratulations to the winner!_
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Month ${widget.month}/${widget.year}',
|
drawPeriodShort,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16.sp,
|
fontSize: 15.sp,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
|
|
@ -613,9 +707,10 @@ _Congratulations to the winner!_
|
||||||
members: widget.eligibleMembers,
|
members: widget.eligibleMembers,
|
||||||
onDrawComplete: _onDrawComplete,
|
onDrawComplete: _onDrawComplete,
|
||||||
serverSeed: widget.serverSeed,
|
serverSeed: widget.serverSeed,
|
||||||
clientSeed: 'DRAW_${DateTime.now().millisecondsSinceEpoch}',
|
clientSeed: _animationClientSeed,
|
||||||
nonce: widget.nonce,
|
nonce: widget.nonce,
|
||||||
animationDuration: const Duration(seconds: 8),
|
animationDuration: const Duration(seconds: 8),
|
||||||
|
allowReplay: !_isComplete,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
@ -518,7 +519,8 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
|
||||||
final randomValue = _hashToNumber(hash);
|
final randomValue = _hashToNumber(hash);
|
||||||
final selectedIndex = randomValue % widget.members.length;
|
final selectedIndex = randomValue % widget.members.length;
|
||||||
|
|
||||||
_winnerId = widget.members[selectedIndex]['id'];
|
final raw = widget.members[selectedIndex]['id'];
|
||||||
|
_winnerId = raw?.toString() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startSlotMachine() {
|
void _startSlotMachine() {
|
||||||
|
|
@ -573,31 +575,43 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
|
||||||
}
|
}
|
||||||
|
|
||||||
void _completeAnimation() {
|
void _completeAnimation() {
|
||||||
final winnerName = widget.members.firstWhere((m) => m['id'] == _winnerId)['name'];
|
// Always center the real winner — indexOf(name) breaks when names repeat.
|
||||||
final winnerIndex = _baseNames.indexOf(winnerName);
|
String winnerName = 'Unknown';
|
||||||
|
for (final m in widget.members) {
|
||||||
|
if ('${m['id']}' == '${_winnerId}') {
|
||||||
|
winnerName = m['name']?.toString() ?? 'Unknown';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final otherNames = widget.members
|
||||||
|
.where((m) => '${m['id']}' != '${_winnerId}')
|
||||||
|
.map((m) => m['name']?.toString() ?? 'Unknown')
|
||||||
|
.toList();
|
||||||
|
final filler = otherNames.isEmpty ? <String>[winnerName] : otherNames;
|
||||||
|
|
||||||
|
final window = List<String>.filled(7, '');
|
||||||
|
window[3] = winnerName;
|
||||||
|
int o = 0;
|
||||||
|
for (int i = 0; i < 7; i++) {
|
||||||
|
if (i == 3) continue;
|
||||||
|
window[i] = filler[o % filler.length];
|
||||||
|
o++;
|
||||||
|
}
|
||||||
|
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
setState(() {
|
setState(() {
|
||||||
_isAnimating = false;
|
_isAnimating = false;
|
||||||
_isComplete = true;
|
_isComplete = true;
|
||||||
if (winnerIndex != -1) {
|
_displayNames = window;
|
||||||
final List<String> window = [];
|
|
||||||
for (int offset = -3; offset <= 3; offset++) {
|
|
||||||
final index = (winnerIndex + offset) % _baseNames.length;
|
|
||||||
final adjustedIndex = index < 0 ? index + _baseNames.length : index;
|
|
||||||
window.add(_baseNames[adjustedIndex]);
|
|
||||||
}
|
|
||||||
_displayNames = window;
|
|
||||||
} else {
|
|
||||||
_displayNames = List.filled(7, '', growable: false);
|
|
||||||
_displayNames[3] = winnerName;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
_pulseController.stop();
|
_pulseController.stop();
|
||||||
|
|
||||||
// Delay callback slightly for visual effect
|
Future.delayed(const Duration(milliseconds: 650), () {
|
||||||
Future.delayed(const Duration(milliseconds: 500), () {
|
if (_winnerId != null && _winnerId!.isNotEmpty) {
|
||||||
widget.onDrawComplete(_winnerId!);
|
widget.onDrawComplete(_winnerId!);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -618,198 +632,254 @@ class _SlotMachineDrawAnimationState extends State<SlotMachineDrawAnimation>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final winnerName = _winnerId != null
|
String? winnerName;
|
||||||
? widget.members.firstWhere((m) => m['id'] == _winnerId)['name']
|
if (_winnerId != null && _winnerId!.isNotEmpty) {
|
||||||
: null;
|
for (final m in widget.members) {
|
||||||
|
if ('${m['id']}' == '${_winnerId}') {
|
||||||
|
winnerName = m['name']?.toString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
final maxW = MediaQuery.sizeOf(context).width - 40.w;
|
||||||
width: 320.w,
|
|
||||||
height: 520.h,
|
return Center(
|
||||||
child: Column(
|
child: ConstrainedBox(
|
||||||
children: [
|
constraints: BoxConstraints(maxWidth: maxW.clamp(280.w, 400.w)),
|
||||||
Expanded(
|
child: SizedBox(
|
||||||
child: Container(
|
width: double.infinity,
|
||||||
width: double.infinity,
|
height: 480.h.clamp(380.h, 560.h),
|
||||||
decoration: BoxDecoration(
|
child: Column(
|
||||||
gradient: LinearGradient(
|
children: [
|
||||||
colors: [
|
Expanded(
|
||||||
Colors.grey.shade900,
|
child: Container(
|
||||||
Colors.grey.shade800,
|
width: double.infinity,
|
||||||
],
|
decoration: BoxDecoration(
|
||||||
begin: Alignment.topCenter,
|
gradient: LinearGradient(
|
||||||
end: Alignment.bottomCenter,
|
colors: [
|
||||||
),
|
Colors.grey.shade900,
|
||||||
borderRadius: BorderRadius.circular(20.r),
|
Colors.grey.shade800,
|
||||||
border: Border.all(color: Colors.orange.shade400, width: 3.w),
|
],
|
||||||
boxShadow: [
|
begin: Alignment.topCenter,
|
||||||
BoxShadow(
|
end: Alignment.bottomCenter,
|
||||||
color: Colors.black.withOpacity(0.35),
|
),
|
||||||
blurRadius: 18.r,
|
borderRadius: BorderRadius.circular(20.r),
|
||||||
offset: Offset(0, 10.h),
|
border: Border.all(color: Colors.orange.shade400, width: 3.w),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.35),
|
||||||
|
blurRadius: 18.r,
|
||||||
|
offset: Offset(0, 10.h),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
child: Container(
|
||||||
),
|
margin: EdgeInsets.all(18.w),
|
||||||
child: Container(
|
decoration: BoxDecoration(
|
||||||
margin: EdgeInsets.all(18.w),
|
color: Colors.black,
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
color: Colors.black,
|
),
|
||||||
borderRadius: BorderRadius.circular(12.r),
|
child: LayoutBuilder(
|
||||||
),
|
builder: (context, constraints) {
|
||||||
child: Stack(
|
final rowH = constraints.maxHeight / 7;
|
||||||
children: [
|
return Stack(
|
||||||
AnimatedBuilder(
|
clipBehavior: Clip.hardEdge,
|
||||||
animation: _slotAnimation,
|
children: [
|
||||||
builder: (context, child) {
|
AnimatedBuilder(
|
||||||
return Column(
|
animation: _slotAnimation,
|
||||||
children: List.generate(7, (index) {
|
builder: (context, child) {
|
||||||
final displayIndex = index < _displayNames.length ? index : 0;
|
return Column(
|
||||||
final name = _displayNames[displayIndex];
|
children: List.generate(7, (index) {
|
||||||
final isWinner = _isComplete && index == 3;
|
final displayIndex =
|
||||||
final isCenterHighlight = _isAnimating && index == 3;
|
index < _displayNames.length ? index : 0;
|
||||||
|
final name = _displayNames[displayIndex];
|
||||||
|
final isWinner = _isComplete && index == 3;
|
||||||
|
final isCenterHighlight =
|
||||||
|
_isAnimating && index == 3;
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: _pulseAnimation,
|
animation: _pulseAnimation,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final double scale = isWinner || isCenterHighlight ? 1.08 : 1.0;
|
final double scale =
|
||||||
final double fontSize = isWinner
|
isWinner || isCenterHighlight
|
||||||
? 24.sp
|
? 1.08
|
||||||
: isCenterHighlight
|
: 1.0;
|
||||||
? 20.sp
|
final double fontSize = isWinner
|
||||||
: 18.sp;
|
? 24.sp
|
||||||
final FontWeight weight = isWinner
|
: isCenterHighlight
|
||||||
? FontWeight.w900
|
? 20.sp
|
||||||
: isCenterHighlight
|
: 18.sp;
|
||||||
? FontWeight.w800
|
final FontWeight weight = isWinner
|
||||||
: FontWeight.w700;
|
? FontWeight.w900
|
||||||
final List<Color> colors = isWinner
|
: isCenterHighlight
|
||||||
? [Colors.green.shade500, Colors.green.shade600]
|
? FontWeight.w800
|
||||||
: isCenterHighlight
|
: FontWeight.w700;
|
||||||
? [Colors.deepPurple.shade500, Colors.deepPurple.shade700]
|
final List<Color> colors = isWinner
|
||||||
: [Colors.blueGrey.shade700, Colors.blueGrey.shade900];
|
? [
|
||||||
|
Colors.green.shade500,
|
||||||
|
Colors.green.shade600,
|
||||||
|
]
|
||||||
|
: isCenterHighlight
|
||||||
|
? [
|
||||||
|
Colors.deepPurple.shade500,
|
||||||
|
Colors.deepPurple.shade700,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
Colors.blueGrey.shade700,
|
||||||
|
Colors.blueGrey.shade900,
|
||||||
|
];
|
||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(
|
||||||
curve: Curves.easeInOut,
|
milliseconds: 220),
|
||||||
transform: Matrix4.identity()..scale(scale),
|
curve: Curves.easeInOut,
|
||||||
child: Container(
|
transform: Matrix4.identity()
|
||||||
width: double.infinity,
|
..scale(scale),
|
||||||
margin: EdgeInsets.symmetric(
|
child: Container(
|
||||||
vertical: 6.h,
|
width: double.infinity,
|
||||||
horizontal: 12.w,
|
margin: EdgeInsets.symmetric(
|
||||||
),
|
vertical: 6.h,
|
||||||
decoration: BoxDecoration(
|
horizontal: 12.w,
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: colors,
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(10.r),
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.white.withOpacity(isWinner || isCenterHighlight ? 0.7 : 0.15),
|
|
||||||
width: isWinner || isCenterHighlight ? 2.w : 1.w,
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.4),
|
|
||||||
blurRadius: isWinner ? 14.r : 6.r,
|
|
||||||
offset: Offset(0, 3.h),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
name.length > 22 ? '${name.substring(0, 22)}…' : name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: fontSize,
|
|
||||||
fontWeight: weight,
|
|
||||||
color: Colors.white,
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
shadows: [
|
|
||||||
Shadow(
|
|
||||||
color: Colors.black.withOpacity(0.5),
|
|
||||||
blurRadius: 4.r,
|
|
||||||
offset: Offset(1.5, 1.5),
|
|
||||||
),
|
),
|
||||||
],
|
decoration: BoxDecoration(
|
||||||
),
|
gradient: LinearGradient(
|
||||||
textAlign: TextAlign.center,
|
colors: colors,
|
||||||
maxLines: 1,
|
begin: Alignment.topLeft,
|
||||||
overflow: TextOverflow.ellipsis,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(10.r),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withOpacity(
|
||||||
|
isWinner ||
|
||||||
|
isCenterHighlight
|
||||||
|
? 0.7
|
||||||
|
: 0.15),
|
||||||
|
width: isWinner ||
|
||||||
|
isCenterHighlight
|
||||||
|
? 2.w
|
||||||
|
: 1.w,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black
|
||||||
|
.withOpacity(0.4),
|
||||||
|
blurRadius: isWinner
|
||||||
|
? 14.r
|
||||||
|
: 6.r,
|
||||||
|
offset: Offset(0, 3.h),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
name.length > 22
|
||||||
|
? '${name.substring(0, 22)}…'
|
||||||
|
: name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: weight,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Colors.black
|
||||||
|
.withOpacity(0.5),
|
||||||
|
blurRadius: 4.r,
|
||||||
|
offset:
|
||||||
|
const Offset(1.5, 1.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow:
|
||||||
|
TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_isComplete)
|
||||||
|
Positioned(
|
||||||
|
top: rowH * 3,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: rowH,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10.r),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.amber.shade400,
|
||||||
|
width: 2.5.w,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.amber.withOpacity(0.35),
|
||||||
|
blurRadius: 14.r,
|
||||||
|
spreadRadius: 0.5,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (_isComplete)
|
),
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Container(
|
|
||||||
height: 70.h,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.green.shade400.withOpacity(0.35),
|
|
||||||
borderRadius: BorderRadius.circular(6.r),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.emoji_events, color: Colors.yellowAccent, size: 26.w),
|
|
||||||
SizedBox(width: 10.w),
|
|
||||||
Text(
|
|
||||||
'WINNER',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20.sp,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: Colors.yellowAccent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: 16.h),
|
||||||
),
|
if (_isComplete && winnerName != null)
|
||||||
SizedBox(height: 16.h),
|
Container(
|
||||||
if (_isComplete && winnerName != null)
|
width: double.infinity,
|
||||||
Container(
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h),
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
gradient: LinearGradient(
|
||||||
color: Colors.green.shade50,
|
colors: [
|
||||||
borderRadius: BorderRadius.circular(12.r),
|
Colors.green.shade50,
|
||||||
),
|
Colors.green.shade100.withOpacity(0.85),
|
||||||
child: Row(
|
],
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.emoji_events, color: Colors.green.shade600, size: 20.w),
|
|
||||||
SizedBox(width: 8.w),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
winnerName,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18.sp,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: Colors.green.shade700,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
|
borderRadius: BorderRadius.circular(14.r),
|
||||||
|
border: Border.all(color: Colors.green.shade200, width: 1.5),
|
||||||
),
|
),
|
||||||
],
|
child: Row(
|
||||||
),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
),
|
children: [
|
||||||
],
|
Icon(Icons.emoji_events, color: Colors.amber.shade700, size: 26.w),
|
||||||
|
SizedBox(width: 10.w),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
winnerName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 19.sp,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.green.shade900,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ class DrawAnimationSelector extends StatefulWidget {
|
||||||
final String? clientSeed;
|
final String? clientSeed;
|
||||||
final int? nonce;
|
final int? nonce;
|
||||||
final Duration animationDuration;
|
final Duration animationDuration;
|
||||||
|
/// When false, hides "Spin again" after a run (e.g. draw already finalized on parent).
|
||||||
|
final bool allowReplay;
|
||||||
|
|
||||||
const DrawAnimationSelector({
|
const DrawAnimationSelector({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -18,6 +20,7 @@ class DrawAnimationSelector extends StatefulWidget {
|
||||||
this.clientSeed,
|
this.clientSeed,
|
||||||
this.nonce,
|
this.nonce,
|
||||||
this.animationDuration = const Duration(seconds: 4),
|
this.animationDuration = const Duration(seconds: 4),
|
||||||
|
this.allowReplay = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -143,10 +146,13 @@ class _DrawAnimationSelectorState extends State<DrawAnimationSelector> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
final maxW = (MediaQuery.sizeOf(context).width - 32.w).clamp(280.w, 440.w);
|
||||||
width: 400.w,
|
return Align(
|
||||||
child: Column(
|
alignment: Alignment.topCenter,
|
||||||
children: [
|
child: SizedBox(
|
||||||
|
width: maxW,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
if (!_isDrawStarted) _buildAnimationSelector(),
|
if (!_isDrawStarted) _buildAnimationSelector(),
|
||||||
if (_isDrawStarted) ...[
|
if (_isDrawStarted) ...[
|
||||||
SlotMachineDrawAnimation(
|
SlotMachineDrawAnimation(
|
||||||
|
|
@ -158,35 +164,37 @@ class _DrawAnimationSelectorState extends State<DrawAnimationSelector> {
|
||||||
animationDuration: widget.animationDuration,
|
animationDuration: widget.animationDuration,
|
||||||
),
|
),
|
||||||
SizedBox(height: 20.h),
|
SizedBox(height: 20.h),
|
||||||
SizedBox(
|
if (widget.allowReplay)
|
||||||
width: double.infinity,
|
SizedBox(
|
||||||
child: OutlinedButton(
|
width: double.infinity,
|
||||||
onPressed: _resetDraw,
|
child: OutlinedButton(
|
||||||
style: OutlinedButton.styleFrom(
|
onPressed: _resetDraw,
|
||||||
padding: EdgeInsets.symmetric(vertical: 12.h),
|
style: OutlinedButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(
|
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||||
borderRadius: BorderRadius.circular(8.r),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8.r),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.refresh, size: 18.w, color: Colors.purple.shade600),
|
||||||
|
SizedBox(width: 6.w),
|
||||||
|
Text(
|
||||||
|
'Spin Again',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.purple.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.refresh, size: 18.w, color: Colors.purple.shade600),
|
|
||||||
SizedBox(width: 6.w),
|
|
||||||
Text(
|
|
||||||
'Spin Again',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14.sp,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.purple.shade600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue