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

866 lines
29 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 '../../core/themes/draw_slot_theme.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;
/// True after the draw is successfully persisted (safe to leave without prompt).
bool _drawRecorded = false;
/// Single client seed for the whole page so animation + API save use the same value.
late final String _animationClientSeed;
void _exitDrawScreen({required bool recorded}) {
if (!mounted) return;
Navigator.of(context).pop(recorded);
}
/// System back / header — always offers a way out.
Future<void> _handleLeaveIntent() async {
if (!_isComplete) {
final cancel = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Cancel draw?'),
content: const Text(
'The draw is still in progress. Stop and go back?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Keep drawing'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
),
child: const Text('Stop'),
),
],
),
);
if (cancel == true && mounted) {
_exitDrawScreen(recorded: false);
}
return;
}
if (_drawRecorded) {
_exitDrawScreen(recorded: true);
return;
}
final leave = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Leave without saving?'),
content: const Text(
'This winner is not recorded yet. If you leave now, you will need to run the draw again.',
),
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 anyway'),
),
],
),
);
if (leave == true && mounted) {
_exitDrawScreen(recorded: false);
}
}
@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 {
_exitDrawScreen(recorded: 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,
),
),
),
IconButton(
tooltip: 'Close',
onPressed: () => Navigator.pop(dialogContext, false),
icon: Icon(Icons.close, color: Colors.grey.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,
),
],
),
),
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.snackbar(
'Error',
'Failed to save draw result',
backgroundColor: Colors.red,
colorText: Colors.white,
duration: const Duration(seconds: 4),
snackPosition: SnackPosition.TOP,
);
return;
}
setState(() {
_drawRecorded = true;
});
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;
_exitDrawScreen(recorded: 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) {
try {
Get.back();
} catch (_) {}
}
if (mounted) {
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: true,
builder: (ctx) => AlertDialog(
title: Row(
children: [
Expanded(child: const Text('Public result link')),
IconButton(
tooltip: 'Close',
onPressed: () => Navigator.of(ctx).pop(),
icon: const Icon(Icons.close),
),
],
),
content: SingleChildScrollView(
child: SelectableText(
url,
style: TextStyle(fontSize: 13.sp),
),
),
actions: [
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: url));
if (ctx.mounted) {
Get.snackbar(
'Copied',
'Link copied — anyone can open it in a browser',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 3),
);
}
},
child: const Text('Copy only'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Done'),
),
],
),
);
}
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}';
final theme = Theme.of(context);
final d = DrawSlotTheming(theme.colorScheme, theme.brightness);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop || !mounted) return;
await _handleLeaveIntent();
},
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: FadeTransition(
opacity: _fadeAnimation,
child: Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
gradient: d.pageBackdrop,
),
child: Column(
children: [
// Header
Padding(
padding: EdgeInsets.fromLTRB(8.w, 12.h, 12.w, 8.h),
child: Column(
children: [
Row(
children: [
IconButton(
tooltip: _drawRecorded
? 'Done'
: (_isComplete
? 'Close'
: 'Cancel draw'),
icon: Icon(
_drawRecorded
? Icons.check_circle_outline_rounded
: Icons.close_rounded,
color: d.pageOnText,
size: 28.w,
),
onPressed: () => _handleLeaveIntent(),
),
Expanded(
child: Column(
children: [
Text(
widget.group.name,
style: theme.textTheme.headlineSmall?.copyWith(
color: d.pageOnText,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 10.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 14.w,
vertical: 8.h,
),
decoration: BoxDecoration(
color: d.chipFill,
borderRadius: BorderRadius.circular(20.r),
border: Border.all(color: d.chipBorder),
),
child: Text(
drawPeriodShort,
style: theme.textTheme.labelLarge?.copyWith(
color: d.pageOnText,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
],
),
),
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.fromLTRB(20.w, 8.h, 20.w, 16.h),
child: Column(
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 14.h,
),
decoration: BoxDecoration(
color: d.chipFill,
borderRadius: BorderRadius.circular(18.r),
border: Border.all(color: d.chipBorder),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildInfoItem(
d,
'In this draw',
'${widget.eligibleMembers.length}',
Icons.how_to_reg_rounded,
),
Container(
width: 1,
height: 36.h,
color: d.chipBorder.withOpacity(0.6),
),
_buildInfoItem(
d,
'Group size',
'${widget.group.maxMembers}',
Icons.groups_rounded,
),
],
),
),
SizedBox(height: 14.h),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.verified_user_rounded,
size: 16.w,
color: theme.colorScheme.tertiary,
),
SizedBox(width: 8.w),
Text(
'Provably fair · transparent draw',
style: theme.textTheme.bodyMedium?.copyWith(
color: d.pageMuted,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
),
],
),
),
),
),
),
);
}
Widget _buildInfoItem(
DrawSlotTheming d,
String label,
String value,
IconData icon,
) {
return Column(
children: [
Icon(
icon,
color: d.pageMuted,
size: 22.w,
),
SizedBox(height: 6.h),
Text(
value,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w800,
color: d.pageOnText,
),
),
Text(
label,
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w600,
color: d.pageMuted,
letterSpacing: 0.2,
),
),
],
);
}
}