chitfund/luckychit/lib/interfaces/manager/draw_animation_page.dart

807 lines
28 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../core/services/chit_group_service.dart';
import '../../core/models/chit_group.dart';
import '../../shared/widgets/draw_animation_selector.dart';
import '../../core/utils/whatsapp_util.dart';
class DrawAnimationPage extends StatefulWidget {
final ChitGroup group;
final int month;
final int year;
final String serverSeed;
final int nonce;
final List<Map<String, dynamic>> eligibleMembers;
const DrawAnimationPage({
super.key,
required this.group,
required this.month,
required this.year,
required this.serverSeed,
required this.nonce,
required this.eligibleMembers,
});
@override
State<DrawAnimationPage> createState() => _DrawAnimationPageState();
}
class _DrawAnimationPageState extends State<DrawAnimationPage>
with SingleTickerProviderStateMixin {
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),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
));
_fadeController.forward();
}
@override
void dispose() {
_fadeController.dispose();
super.dispose();
}
void _onDrawComplete(String winnerId) async {
if (_isComplete) return;
setState(() {
_isComplete = true;
});
// 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') {
winner = m;
break;
}
}
// Get the bid amount for this month from financial data
final chitGroupService = Get.find<ChitGroupService>();
await chitGroupService.loadGroupFinancialData(widget.group.id);
// Find the financial data for this specific month
final monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
final monthKey = '${monthNames[widget.month - 1]}-${widget.year.toString().substring(2)}';
final financialEntry = chitGroupService.financialData.firstWhereOrNull(
(entry) => entry.monthYear == monthKey,
);
final bidAmount = financialEntry?.bidAmount ?? widget.group.monthlyInstallment;
// Show confirmation dialog with winner and bid amount
final shouldSave = await _showWinnerConfirmation(winner, bidAmount);
if (shouldSave == true) {
await _saveDrawResult(winnerId, bidAmount);
} else {
// User cancelled, go back without saving
Get.back(result: false);
}
}
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: (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(20.r),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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(
'Winner',
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.w800,
color: Colors.grey.shade900,
letterSpacing: -0.5,
),
),
),
],
),
SizedBox(height: 8.h),
Text(
periodLabel,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
height: 1.3,
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 22.h),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.green.shade50,
Colors.teal.shade50.withOpacity(0.85),
],
),
borderRadius: BorderRadius.circular(16.r),
border: Border.all(color: Colors.green.shade200, width: 1.5),
),
child: Column(
children: [
Text(
winner['name']?.toString() ?? 'Unknown',
style: TextStyle(
fontSize: 26.sp,
fontWeight: FontWeight.w800,
color: Colors.green.shade900,
height: 1.15,
),
textAlign: TextAlign.center,
),
if ((winner['mobile']?.toString() ?? '').isNotEmpty) ...[
SizedBox(height: 10.h),
Text(
winner['mobile']?.toString() ?? '',
style: TextStyle(
fontSize: 15.sp,
color: Colors.grey.shade700,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
SizedBox(height: 18.h),
Container(
height: 1,
color: Colors.green.shade200.withOpacity(0.9),
),
SizedBox(height: 14.h),
Text(
'Prize amount',
style: TextStyle(
fontSize: 13.sp,
color: Colors.grey.shade600,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
),
SizedBox(height: 6.h),
Text(
_formatIndianCurrency(bidAmount),
style: TextStyle(
fontSize: 30.sp,
fontWeight: FontWeight.w800,
color: Colors.green.shade800,
),
),
],
),
),
SizedBox(height: 16.h),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _shareViaWhatsApp(winner, bidAmount),
icon: Icon(Icons.send_rounded,
size: 18.w, color: const Color(0xFF25D366)),
label: Text(
'Share',
style: TextStyle(
fontSize: 13.sp, color: const Color(0xFF25D366)),
),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 12.h),
side: const BorderSide(color: Color(0xFF25D366)),
),
),
),
SizedBox(width: 8.w),
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Get.snackbar(
'Screenshot',
'Capture this screen if you want a local copy.',
backgroundColor: Colors.blue.shade700,
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
},
icon: Icon(Icons.photo_camera_outlined,
size: 18.w, color: Colors.blue.shade700),
label: Text(
'Capture',
style: TextStyle(
fontSize: 13.sp, color: Colors.blue.shade700),
),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 12.h),
side: BorderSide(color: Colors.blue.shade700),
),
),
),
],
),
SizedBox(height: 12.h),
Text(
'Save to record this result and get a shareable public link.',
style: TextStyle(
fontSize: 14.sp,
color: Colors.grey.shade700,
fontWeight: FontWeight.w500,
height: 1.35,
),
textAlign: TextAlign.center,
),
],
),
),
actionsPadding: EdgeInsets.fromLTRB(20.w, 0, 20.w, 16.h),
actions: [
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(dialogContext, false),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 14.h),
side: BorderSide(color: Colors.grey.shade400),
),
child: Text(
'Discard',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
),
),
SizedBox(width: 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(vertical: 14.h),
),
icon: Icon(Icons.check_circle_outline, size: 20.w),
label: Text(
'Save result',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
],
),
),
);
}
void _shareViaWhatsApp(Map<String, dynamic> winner, double bidAmount) {
final monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
final monthName = monthNames[widget.month - 1];
final message = '''
🎉 *${widget.group.name}* 🎉
📅 *Monthly Draw Result*
Month: $monthName ${widget.year}
🏆 *WINNER*
${winner['name']}
${winner['mobile']}
💰 *Prize Amount*
${_formatIndianCurrency(bidAmount)}
✨ This draw was conducted using our provably fair system for complete transparency.
_Congratulations to the winner!_
''';
// Share to all members or specific number
WhatsAppUtil.shareText(message);
}
Future<void> _saveDrawResult(String winnerId, double bidAmount) async {
final chitGroupService = Get.find<ChitGroupService>();
try {
// Show loading
Get.dialog(
PopScope(
canPop: false,
child: Center(
child: Container(
padding: EdgeInsets.all(32.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16.h),
Text(
'Saving draw result...',
style: TextStyle(fontSize: 16.sp),
),
],
),
),
),
),
barrierDismissible: false,
);
final created = await chitGroupService.createMonthlyDraw(
widget.group.id,
widget.month,
widget.year,
clientSeed: _animationClientSeed,
prizeAmount: bidAmount,
animationWinnerUserId: winnerId,
serverSeed: widget.serverSeed,
nonce: widget.nonce,
);
if (!mounted) return;
Get.back();
if (created == null) {
Get.back(result: false);
Get.snackbar(
'Error',
'Failed to save draw result',
backgroundColor: Colors.red,
colorText: Colors.white,
duration: const Duration(seconds: 4),
snackPosition: SnackPosition.TOP,
);
return;
}
final drawId = created['id']?.toString();
if (drawId != null) {
final publicUrl = await WhatsAppUtil.getDrawPublicShareUrl(drawId);
if (mounted && publicUrl != null && publicUrl.isNotEmpty) {
await _showPublicResultLinkDialog(publicUrl);
} else if (mounted && (publicUrl == null || publicUrl.isEmpty)) {
Get.snackbar(
'Draw saved',
'Public link unavailable — set PUBLIC_BASE_URL on the server to share results.',
backgroundColor: Colors.orange.shade700,
colorText: Colors.white,
duration: const Duration(seconds: 5),
snackPosition: SnackPosition.TOP,
);
}
}
if (!mounted) return;
Get.back(result: true);
Get.snackbar(
'Draw Saved! 🎉',
'Winner has been recorded. Share the public link so anyone can view results.',
backgroundColor: Colors.green,
colorText: Colors.white,
duration: const Duration(seconds: 3),
snackPosition: SnackPosition.TOP,
);
} catch (e) {
if (mounted) Get.back();
Get.back(result: false);
Get.snackbar(
'Error',
'Failed to save draw result: ${e.toString()}',
backgroundColor: Colors.red,
colorText: Colors.white,
duration: const Duration(seconds: 4),
);
}
}
Future<void> _showPublicResultLinkDialog(String url) async {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Public result link'),
content: SelectableText(
url,
style: TextStyle(fontSize: 13.sp),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Close'),
),
FilledButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: url));
if (ctx.mounted) Navigator.of(ctx).pop();
Get.snackbar(
'Copied',
'Link copied — anyone can open it in a browser',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 3),
);
},
child: const Text('Copy link'),
),
],
),
);
}
String _formatIndianCurrency(double amount) {
int intAmount = amount.round();
String amountStr = intAmount.toString();
String formatted = '';
if (amountStr.length <= 3) {
formatted = amountStr;
} else {
int remaining = amountStr.length;
int start = 0;
if (remaining > 3) {
formatted = amountStr.substring(amountStr.length - 3);
remaining -= 3;
start = amountStr.length - 3;
} else {
formatted = amountStr;
remaining = 0;
}
while (remaining > 0) {
int groupSize = remaining >= 2 ? 2 : remaining;
int groupStart = start - groupSize;
String group = amountStr.substring(groupStart, start);
formatted = group + ',' + formatted;
start = groupStart;
remaining -= groupSize;
}
}
return '$formatted';
}
@override
Widget build(BuildContext context) {
const monthLabels = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
final drawPeriodShort =
'${monthLabels[widget.month - 1]} ${widget.year}';
return PopScope(
canPop: _isComplete,
onPopInvokedWithResult: (didPop, result) async {
if (didPop || !mounted) return;
final leave = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Leave draw?'),
content: const Text(
'The result is not saved yet. Leave anyway?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Stay'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
),
child: const Text('Leave'),
),
],
),
);
if (leave == true && mounted) {
Get.back(result: false);
}
},
child: Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: FadeTransition(
opacity: _fadeAnimation,
child: Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.purple.shade900,
Colors.blue.shade900,
Colors.indigo.shade900,
],
),
),
child: Column(
children: [
// Header
Padding(
padding: EdgeInsets.all(20.w),
child: Column(
children: [
Row(
children: [
if (!_isComplete)
IconButton(
icon: Icon(
Icons.close,
color: Colors.white.withOpacity(0.8),
size: 28.w,
),
onPressed: () async {
final result = 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?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Continue'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Cancel'),
),
],
),
);
if (result == true) {
Get.back(result: false);
}
},
)
else
SizedBox(width: 48.w),
Expanded(
child: Column(
children: [
Text(
widget.group.name,
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
SizedBox(height: 8.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 6.h,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20.r),
border: Border.all(
color: Colors.white.withOpacity(0.3),
),
),
child: Text(
drawPeriodShort,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
),
),
SizedBox(width: 48.w),
],
),
],
),
),
// Animation Area - Scrollable
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 20.h),
child: DrawAnimationSelector(
members: widget.eligibleMembers,
onDrawComplete: _onDrawComplete,
serverSeed: widget.serverSeed,
clientSeed: _animationClientSeed,
nonce: widget.nonce,
animationDuration: const Duration(seconds: 8),
allowReplay: !_isComplete,
),
),
),
// Footer
Padding(
padding: EdgeInsets.all(20.w),
child: Column(
children: [
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(12.r),
border: Border.all(
color: Colors.white.withOpacity(0.2),
),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildInfoItem(
'Eligible Members',
'${widget.eligibleMembers.length}',
Icons.people,
),
Container(
width: 1,
height: 30.h,
color: Colors.white.withOpacity(0.3),
),
_buildInfoItem(
'Total Members',
'${widget.group.maxMembers}',
Icons.group,
),
],
),
],
),
),
SizedBox(height: 12.h),
Text(
'🎲 Provably Fair Draw',
style: TextStyle(
fontSize: 14.sp,
color: Colors.white.withOpacity(0.7),
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
),
),
),
);
}
Widget _buildInfoItem(String label, String value, IconData icon) {
return Column(
children: [
Icon(
icon,
color: Colors.white.withOpacity(0.8),
size: 24.w,
),
SizedBox(height: 6.h),
Text(
value,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
label,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withOpacity(0.7),
),
),
],
);
}
}