Files
App/lib/screens/lottery.dart
2025-10-25 00:29:36 +08:00

830 lines
26 KiB
Dart

import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/wallet.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/wallet.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/payment/payment_overlay.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'lottery.g.dart';
@riverpod
Future<List<SnLotteryTicket>> lotteryTickets(
Ref ref, {
int offset = 0,
int take = 20,
}) async {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/pass/lotteries?offset=$offset&take=$take');
return (resp.data as List).map((e) => SnLotteryTicket.fromJson(e)).toList();
}
@riverpod
Future<List<SnLotteryRecord>> lotteryRecords(
Ref ref, {
int offset = 0,
int take = 20,
}) async {
final client = ref.watch(apiClientProvider);
final resp = await client.get(
'/pass/lotteries/records?offset=$offset&take=$take',
);
return (resp.data as List).map((e) => SnLotteryRecord.fromJson(e)).toList();
}
class LotteryTab extends StatelessWidget {
const LotteryTab({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Column(
children: [
TabBar(
tabs: [Tab(text: 'myTickets'.tr()), Tab(text: 'drawHistory'.tr())],
),
Expanded(
child: TabBarView(
children: [LotteryTicketsList(), LotteryRecordsList()],
),
),
],
),
);
}
}
class LotteryTicketsList extends HookConsumerWidget {
const LotteryTicketsList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tickets = ref.watch(lotteryTicketsProvider());
return tickets.when(
data: (ticketsList) {
if (ticketsList.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.casino,
size: 48,
color: Theme.of(context).colorScheme.outline,
),
const Gap(16),
Text(
'noLotteryTickets'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const Gap(8),
Text(
'buyYourFirstTicket'.tr(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const Gap(16),
FilledButton.icon(
onPressed: () => _showLotteryPurchaseSheet(context, ref),
icon: const Icon(Symbols.add),
label: Text('buyTicket'.tr()),
),
],
),
);
}
return Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: ticketsList.length,
itemBuilder: (context, index) {
final ticket = ticketsList[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.confirmation_number,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Expanded(
child: Text(ticket.createdAt.formatSystem()),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getLotteryStatusColor(
context,
ticket.drawStatus,
).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_getLotteryStatusText(ticket.drawStatus),
style: TextStyle(
color: _getLotteryStatusColor(
context,
ticket.drawStatus,
),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
const Gap(8),
_buildTicketNumbersDisplay(context, ticket),
const Gap(8),
Row(
spacing: 6,
children: [
const Icon(Symbols.asterisk, size: 18),
Text('multiplier').tr().fontSize(13),
Text(
'·',
).fontWeight(FontWeight.w900).fontSize(13),
Text(
'${ticket.multiplier}x',
style: Theme.of(context).textTheme.bodyMedium,
).fontSize(13),
],
).opacity(0.75),
],
),
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: () => _showLotteryPurchaseSheet(context, ref),
icon: const Icon(Symbols.add),
label: Text('buyTicket'.tr()),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
);
}
Future<void> _showLotteryPurchaseSheet(
BuildContext context,
WidgetRef ref,
) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const LotteryPurchaseSheet(),
);
if (result != null && context.mounted) {
await _handleLotteryPurchase(context, ref, result);
}
}
Future<void> _handleLotteryPurchase(
BuildContext context,
WidgetRef ref,
Map<String, dynamic> purchaseData,
) async {
final client = ref.read(apiClientProvider);
try {
showLoadingModal(context);
// The lottery API creates the order for us
final orderResponse = await client.post(
'/pass/lotteries',
data: purchaseData,
);
if (context.mounted) hideLoadingModal(context);
final order = SnWalletOrder.fromJson(orderResponse.data);
// Show payment overlay
if (context.mounted) {
final completedOrder = await PaymentOverlay.show(
context: context,
order: order,
);
if (completedOrder != null) {
// Payment successful, refresh data
ref.invalidate(lotteryTicketsProvider);
ref.invalidate(walletCurrentProvider);
if (context.mounted) {
showSnackBar('ticketPurchasedSuccessfully'.tr());
}
}
}
} catch (err) {
if (context.mounted) hideLoadingModal(context);
showErrorAlert(err);
}
}
String _getLotteryStatusText(int status) {
switch (status) {
case 0:
return 'pending'.tr();
case 1:
return 'drawn'.tr();
case 2:
return 'won'.tr();
case 3:
return 'lost'.tr();
default:
return 'unknown'.tr();
}
}
Color _getLotteryStatusColor(BuildContext context, int status) {
switch (status) {
case 0:
return Colors.blue;
case 1:
return Colors.orange;
case 2:
return Colors.green;
case 3:
return Colors.red;
default:
return Theme.of(context).colorScheme.primary;
}
}
Widget _buildTicketNumbersDisplay(
BuildContext context,
SnLotteryTicket ticket,
) {
final numbers = <Widget>[];
// Check if any numbers matched
bool hasAnyMatch =
ticket.matchedRegionOneNumbers != null &&
ticket.matchedRegionOneNumbers!.isNotEmpty;
// Add region one numbers
for (final number in ticket.regionOneNumbers) {
final isMatched =
ticket.matchedRegionOneNumbers?.contains(number) ?? false;
if (isMatched) hasAnyMatch = true;
numbers.add(
_buildNumberWidget(
context,
number,
isMatched: isMatched,
allUnmatched: !hasAnyMatch && ticket.drawStatus >= 1,
),
);
}
// Add region two number
final isSpecialMatched =
ticket.matchedRegionTwoNumber == ticket.regionTwoNumber;
if (isSpecialMatched) hasAnyMatch = true;
numbers.add(
_buildNumberWidget(
context,
ticket.regionTwoNumber,
isMatched: isSpecialMatched,
isSpecial: true,
allUnmatched: !hasAnyMatch && ticket.drawStatus >= 1,
),
);
return Wrap(
spacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: numbers,
);
}
Widget _buildNumberWidget(
BuildContext context,
int number, {
bool isMatched = false,
bool isSpecial = false,
bool allUnmatched = false,
}) {
Color backgroundColor;
Color textColor;
Color borderColor;
if (isMatched) {
backgroundColor = Colors.green;
textColor = Colors.white;
borderColor = Colors.green;
} else {
backgroundColor =
isSpecial
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.surface;
textColor =
isSpecial
? Theme.of(context).colorScheme.onSecondary
: Theme.of(context).colorScheme.onSurface;
borderColor =
isSpecial
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.outline.withOpacity(0.3);
// Blend with red if all numbers are unmatched
if (allUnmatched) {
backgroundColor = Color.alphaBlend(
Colors.red.withOpacity(0.3),
backgroundColor,
);
if (!isSpecial) {
textColor = Color.alphaBlend(Colors.red.withOpacity(0.5), textColor);
}
}
}
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: backgroundColor,
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
number.toString().padLeft(2, '0'),
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
);
}
}
class LotteryRecordsList extends HookConsumerWidget {
const LotteryRecordsList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final records = ref.watch(lotteryRecordsProvider());
return records.when(
data: (recordsList) {
if (recordsList.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.history,
size: 48,
color: Theme.of(context).colorScheme.outline,
),
const Gap(16),
Text(
'noDrawHistory'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: recordsList.length,
itemBuilder: (context, index) {
final record = recordsList[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.celebration,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Text(
DateFormat.yMd().format(record.drawDate),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
const Gap(8),
Text(
'${'winningNumbers'.tr()}: ${record.winningRegionOneNumbers.join(', ')}',
style: Theme.of(context).textTheme.bodyMedium,
),
const Gap(4),
Text(
'${'specialNumber'.tr()}: ${record.winningRegionTwoNumber}',
style: Theme.of(context).textTheme.bodyMedium,
),
const Gap(8),
Text(
'${'totalTickets'.tr()}: ${record.totalTickets}',
style: Theme.of(context).textTheme.bodySmall,
),
const Gap(4),
Text(
'${'totalWinners'.tr()}: ${record.totalPrizesAwarded}',
style: Theme.of(context).textTheme.bodySmall,
),
const Gap(4),
Text(
'${'prizePool'.tr()}: ${record.totalPrizeAmount.toStringAsFixed(2)} ${'walletCurrencyShortPoints'.tr()}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
);
}
}
class LotteryPurchaseSheet extends StatefulWidget {
const LotteryPurchaseSheet({super.key});
@override
State<LotteryPurchaseSheet> createState() => _LotteryPurchaseSheetState();
}
class _LotteryPurchaseSheetState extends State<LotteryPurchaseSheet> {
final List<int> selectedNumbers = [];
int multiplier = 1;
@override
Widget build(BuildContext context) {
final totalCost = 10.0 * multiplier; // Base cost of 10 ISP per ticket
return SheetScaffold(
titleText: 'buyLotteryTicket'.tr(),
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Number Selection Section
Text(
'selectNumbers'.tr(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
const Gap(8),
Text(
'select5UniqueNumbers'.tr(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const Gap(4),
Text(
'The last selected number will be your special number.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.w500,
),
),
const Gap(16),
// Number Grid
_buildNumberGrid(),
const Gap(16),
// Multiplier Section
Text(
'selectMultiplier'.tr(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
const Gap(16),
_buildMultiplierSelector(),
const Gap(16),
// Cost Summary
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('baseCost'.tr()),
Text('10.00 ${'walletCurrencyShortPoints'.tr()}'),
],
),
if (multiplier > 1) ...[
const Gap(8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'multiplier'.tr(
args: [multiplier.toString()],
),
),
Text(
'+ ${(10.0 * (multiplier - 1)).toStringAsFixed(2)} ${'walletCurrencyShortPoints'.tr()}',
),
],
),
],
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'totalCost'.tr(),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
Text(
'${totalCost.toStringAsFixed(2)} ${'walletCurrencyShortPoints'.tr()}',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
),
const Gap(16),
// Prize Structure
Text(
'prizeStructure'.tr(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
const Gap(8),
_buildPrizeStructure(),
],
),
),
),
// Action Buttons
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('cancel'.tr()),
),
),
const Gap(8),
Expanded(
child: FilledButton(
onPressed: _canPurchase ? _purchaseTicket : null,
child: Text('purchase'.tr()),
),
),
],
),
),
],
),
);
}
Widget _buildNumberGrid() {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 10,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: 100,
itemBuilder: (context, index) {
final number = index;
final isSelected = selectedNumbers.contains(number);
final isSpecialNumber =
selectedNumbers.isNotEmpty &&
selectedNumbers.last == number &&
selectedNumbers.length == 6;
return GestureDetector(
onTap: () => _toggleNumber(number),
child: Container(
decoration: BoxDecoration(
color:
isSpecialNumber
? Theme.of(context).colorScheme.secondary
: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surface,
border: Border.all(
color:
isSpecialNumber
? Theme.of(context).colorScheme.secondary
: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.outline.withOpacity(0.3),
),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
number.toString().padLeft(2, '0'),
style: TextStyle(
color:
isSpecialNumber
? Theme.of(context).colorScheme.onSecondary
: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
),
),
);
},
);
}
Widget _buildMultiplierSelector() {
return TextFormField(
initialValue: multiplier.toString(),
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'multiplierLabel'.tr(),
prefixText: 'x',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
final parsed = int.tryParse(value);
if (parsed != null && parsed >= 1) {
setState(() => multiplier = parsed);
}
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a multiplier';
}
final parsed = int.tryParse(value);
if (parsed == null || parsed < 1 || parsed > 10) {
return 'Multiplier must be between 1 and 10';
}
return null;
},
);
}
Widget _buildPrizeStructure() {
// Base rewards for matched numbers (0-5)
final baseRewards = [0, 10, 100, 500, 1000, 10000];
final prizeStructure = <String, String>{};
// Generate prize structure for 0-5 matches with and without special
for (int matches = 5; matches >= 0; matches--) {
final baseReward = baseRewards[matches];
// With special number match (x10 multiplier)
final specialReward = baseReward * 10;
prizeStructure['$matches+Special'] = specialReward.toStringAsFixed(2);
// Without special number match
if (matches > 0) {
prizeStructure[matches.toString()] = baseReward.toStringAsFixed(2);
}
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children:
prizeStructure.entries.map((entry) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
entry.key == '0+Special'
? 'specialOnly'.tr()
: entry.key.tr(),
),
Text(
'${entry.value} ${'walletCurrencyShortPoints'.tr()}',
),
],
),
);
}).toList(),
),
),
);
}
void _toggleNumber(int number) {
setState(() {
if (selectedNumbers.contains(number)) {
selectedNumbers.remove(number);
} else if (selectedNumbers.length < 6) {
selectedNumbers.add(number);
}
});
}
bool get _canPurchase {
return selectedNumbers.length == 6;
}
Future<void> _purchaseTicket() async {
if (!_canPurchase) return;
// Sort all numbers except the last one (special number)
final regularNumbers = selectedNumbers.sublist(0, 5)..sort();
final specialNumber = selectedNumbers.last;
final purchaseData = {
'region_one_numbers': regularNumbers,
'region_two_number': specialNumber,
'multiplier': multiplier,
};
if (mounted) Navigator.of(context).pop(purchaseData);
}
}