yumi-flutter/lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart
2026-05-25 23:32:49 +08:00

996 lines
29 KiB
Dart

import 'dart:io';
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:provider/provider.dart';
import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:yumi/services/payment/google_payment_manager.dart';
import 'package:yumi/shared/business_logic/models/res/sc_first_recharge_reward_res.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
enum FirstRechargeRewardStyle { coin, prop }
class FirstRechargeReward {
const FirstRechargeReward({
required this.title,
required this.style,
this.badgeText = '',
this.cover = '',
});
final String title;
final FirstRechargeRewardStyle style;
final String badgeText;
final String cover;
bool get isCoinReward => style == FirstRechargeRewardStyle.coin;
}
class FirstRechargePackage {
const FirstRechargePackage({
required this.priceText,
required this.rewards,
this.discountText = '',
this.totalValueText = '',
this.rechargeAmountCents = 0,
this.productPackage = '',
});
final String priceText;
final String discountText;
final String totalValueText;
final int rechargeAmountCents;
final String productPackage;
final List<FirstRechargeReward> rewards;
}
class FirstRechargeDialogData {
const FirstRechargeDialogData({
required this.packages,
this.timeRemainingText = '',
this.initialPackageIndex = 0,
});
final List<FirstRechargePackage> packages;
final String timeRemainingText;
final int initialPackageIndex;
factory FirstRechargeDialogData.mock() {
return const FirstRechargeDialogData(
timeRemainingText: 'Time remaining: 6 D 59 H 59 M 59S',
packages: [
FirstRechargePackage(
priceText: r'$0.99',
discountText: '70%',
totalValueText: 'Total value of gifts: xxx gold coins',
rewards: [
FirstRechargeReward(
title: 'NameName',
badgeText: '80000',
cover: _FirstRechargeAssets.rewardAvatarFrame,
style: FirstRechargeRewardStyle.prop,
),
FirstRechargeReward(
title: 'NameName',
badgeText: '240000',
cover: _FirstRechargeAssets.rewardAvatarFrame,
style: FirstRechargeRewardStyle.prop,
),
FirstRechargeReward(
title: '240,000 Coins',
cover: _FirstRechargeAssets.rewardCoinStack,
style: FirstRechargeRewardStyle.coin,
),
FirstRechargeReward(
title: 'Vehicle',
badgeText: '3d',
cover: _FirstRechargeAssets.rewardVehicle,
style: FirstRechargeRewardStyle.prop,
),
FirstRechargeReward(
title: 'Frame',
badgeText: '3d',
cover: _FirstRechargeAssets.rewardFrame,
style: FirstRechargeRewardStyle.prop,
),
FirstRechargeReward(
title: 'Gift',
badgeText: 'x1',
cover: _FirstRechargeAssets.rewardGift,
style: FirstRechargeRewardStyle.prop,
),
],
),
FirstRechargePackage(
priceText: r'$2.99',
discountText: '60%',
totalValueText: 'Total value of gifts: 720,000 gold coins',
rewards: [
FirstRechargeReward(
title: 'Avatar Frame',
badgeText: '7d',
cover: _FirstRechargeAssets.rewardAvatarFrame,
style: FirstRechargeRewardStyle.prop,
),
FirstRechargeReward(
title: 'Room Frame',
badgeText: '7d',
cover: _FirstRechargeAssets.rewardFrame,
style: FirstRechargeRewardStyle.prop,
),
FirstRechargeReward(
title: '720,000 Coins',
cover: _FirstRechargeAssets.rewardCoinStack,
style: FirstRechargeRewardStyle.coin,
),
FirstRechargeReward(
title: 'Vehicle',
badgeText: '7d',
cover: _FirstRechargeAssets.rewardVehicle,
style: FirstRechargeRewardStyle.prop,
),
FirstRechargeReward(
title: 'Gift',
badgeText: 'x3',
cover: _FirstRechargeAssets.rewardGift,
style: FirstRechargeRewardStyle.prop,
),
FirstRechargeReward(
title: 'Profile Frame',
badgeText: '7d',
cover: _FirstRechargeAssets.rewardAvatarFrame,
style: FirstRechargeRewardStyle.prop,
),
],
),
],
);
}
factory FirstRechargeDialogData.fromRewardHome(
SCFirstRechargeRewardHomeRes home,
) {
final levels = home.availableLevelConfigs.take(2).toList();
final packages = <FirstRechargePackage>[];
var selectedPackageIndex = 0;
for (final level in levels) {
final rewards =
level.rewardItems.where(_isDisplayableRewardItem).map((item) {
return FirstRechargeReward(
title: _rewardTitle(item),
style:
item.isCoinReward
? FirstRechargeRewardStyle.coin
: FirstRechargeRewardStyle.prop,
badgeText: item.displayBadgeText,
cover: item.cover,
);
}).toList();
if (rewards.isEmpty) {
continue;
}
if (level.current) {
selectedPackageIndex = packages.length;
}
packages.add(
FirstRechargePackage(
priceText: _formatPriceText(level),
discountText: level.discountText,
totalValueText: level.totalValueText,
rechargeAmountCents: level.rechargeAmountCents,
productPackage: level.productPackage,
rewards: rewards,
),
);
}
return FirstRechargeDialogData(
packages: packages,
initialPackageIndex: selectedPackageIndex,
);
}
static bool _isDisplayableRewardItem(SCFirstRechargeRewardItem item) {
if (item.isCoinReward) {
return item.coinAmount > 0;
}
return item.displayTitle.trim().isNotEmpty || item.cover.trim().isNotEmpty;
}
static String _rewardTitle(SCFirstRechargeRewardItem item) {
if (item.isCoinReward) {
return item.displayTitle;
}
return item.displayTitle;
}
static String _formatPriceText(SCFirstRechargeLevelConfig level) {
final amount = level.rechargeAmount.trim();
if (amount.isNotEmpty) {
return amount.startsWith(r'$') ? amount : '\$$amount';
}
if (level.rechargeAmountCents > 0) {
return '\$${(level.rechargeAmountCents / 100).toStringAsFixed(2)}';
}
return r'$0.00';
}
}
class FirstRechargeDialog extends StatefulWidget {
const FirstRechargeDialog({super.key, required this.data, this.onBuyNow});
static const String dialogTag = 'showFirstRechargeDialog';
static const double _designWidth = 340;
static const double _designHeight = 528;
final FirstRechargeDialogData data;
final ValueChanged<FirstRechargePackage>? onBuyNow;
static Future<void> show(
BuildContext context, {
required FirstRechargeDialogData data,
ValueChanged<FirstRechargePackage>? onBuyNow,
}) async {
SmartDialog.dismiss(tag: dialogTag);
await SmartDialog.show(
tag: dialogTag,
alignment: Alignment.center,
animationType: SmartAnimationType.fade,
clickMaskDismiss: true,
maskColor: Colors.black.withValues(alpha: 0.5),
builder: (_) => FirstRechargeDialog(data: data, onBuyNow: onBuyNow),
);
}
static Future<void> showFromRemote(
BuildContext context, {
bool forceRefresh = false,
}) async {
try {
SCLoadingManager.show(context: context);
final home = await Provider.of<SCAppGeneralManager>(
context,
listen: false,
).refreshFirstRechargeRewardHome(forceRefresh: forceRefresh);
if (!context.mounted) {
return;
}
if (home == null || !home.shouldShowRewardDialog) {
SmartDialog.dismiss(tag: dialogTag);
return;
}
final data = FirstRechargeDialogData.fromRewardHome(home);
if (data.packages.isEmpty) {
SmartDialog.dismiss(tag: dialogTag);
return;
}
SCLoadingManager.hide();
await show(
context,
data: data,
onBuyNow: (package) {
_startGooglePay(context, package);
},
);
} catch (e) {
SCTts.show('Failed to load first recharge rewards');
} finally {
SCLoadingManager.hide();
}
}
static Future<void> _startGooglePay(
BuildContext context,
FirstRechargePackage package,
) async {
if (!Platform.isAndroid) {
SCTts.show('Google Play payment is only available on Android');
return;
}
SmartDialog.dismiss(tag: dialogTag);
final processor = Provider.of<AndroidPaymentProcessor>(
context,
listen: false,
);
await processor.processFirstRechargePurchase(
context: context,
rechargeAmountCents: package.rechargeAmountCents,
productPackage: package.productPackage,
);
}
@override
State<FirstRechargeDialog> createState() => _FirstRechargeDialogState();
}
class _FirstRechargeDialogState extends State<FirstRechargeDialog> {
late int _selectedPackageIndex;
@override
void initState() {
super.initState();
_selectedPackageIndex = widget.data.initialPackageIndex;
}
@override
void didUpdateWidget(covariant FirstRechargeDialog oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.data != widget.data) {
_selectedPackageIndex = _clampedPackageIndex(_selectedPackageIndex);
}
}
@override
Widget build(BuildContext context) {
if (widget.data.packages.isEmpty) {
return const SizedBox.shrink();
}
final screenWidth = MediaQuery.sizeOf(context).width;
final dialogWidth = math.min(
screenWidth - 35.w,
FirstRechargeDialog._designWidth.w,
);
return Material(
color: Colors.transparent,
child: Transform.translate(
offset: Offset(0, 11.w),
child: SizedBox(
width: dialogWidth,
child: AspectRatio(
aspectRatio:
FirstRechargeDialog._designWidth /
FirstRechargeDialog._designHeight,
child: LayoutBuilder(
builder: (context, constraints) {
final scale =
constraints.maxWidth / FirstRechargeDialog._designWidth;
final selectedPackage =
widget.data.packages[_clampedPackageIndex(
_selectedPackageIndex,
)];
return Stack(
clipBehavior: Clip.none,
children: [
_positionedImage(
asset: _FirstRechargeAssets.panelTop,
left: 0,
top: 0,
width: 340,
height: 219,
scale: scale,
),
_positionedImage(
asset: _FirstRechargeAssets.panelMiddle,
left: 0,
top: 219,
width: 340,
height: 166,
scale: scale,
),
_positionedImage(
asset: _FirstRechargeAssets.panelBottom,
left: 0,
top: 351,
width: 340,
height: 177,
scale: scale,
),
_positionedImage(
asset: _FirstRechargeAssets.title,
left: 45,
top: 26,
width: 246,
height: 116,
scale: scale,
),
Positioned(
top: 146 * scale,
left: 0,
right: 0,
child: _SingleLineText(
widget.data.timeRemainingText,
height: 12 * scale,
fontSize: 10 * scale,
fontWeight: FontWeight.w400,
color: Colors.black,
),
),
Positioned(
left: 50 * scale,
top: 168 * scale,
width: 240 * scale,
height: 31 * scale,
child: _PriceSelector(
packages: widget.data.packages,
selectedIndex: _clampedPackageIndex(
_selectedPackageIndex,
),
scale: scale,
onSelected: (index) {
setState(() {
_selectedPackageIndex = index;
});
},
),
),
..._buildRewardCards(selectedPackage.rewards, scale),
Positioned(
top: 425 * scale,
left: 0,
right: 0,
child: _SingleLineText(
selectedPackage.totalValueText,
height: 12 * scale,
fontSize: 10 * scale,
fontWeight: FontWeight.w400,
color: Colors.black,
),
),
Positioned(
left: 70 * scale,
top: 447 * scale,
width: 200 * scale,
height: 42 * scale,
child: SCDebounceWidget(
onTap: () => widget.onBuyNow?.call(selectedPackage),
child: Image.asset(
_FirstRechargeAssets.buyNowButton,
fit: BoxFit.fill,
),
),
),
if (selectedPackage.discountText.trim().isNotEmpty)
Positioned(
left: 238 * scale,
top: 435 * scale,
width: 38 * scale,
height: 38 * scale,
child: _DiscountBadge(
text: selectedPackage.discountText,
scale: scale,
),
),
Positioned(
left: 294 * scale,
top: 14 * scale,
width: 44 * scale,
height: 44 * scale,
child: SCDebounceWidget(
onTap:
() => SmartDialog.dismiss(
tag: FirstRechargeDialog.dialogTag,
),
child: Center(
child: Image.asset(
_FirstRechargeAssets.close,
width: 24 * scale,
height: 25 * scale,
fit: BoxFit.fill,
),
),
),
),
],
);
},
),
),
),
),
);
}
int _clampedPackageIndex(int value) {
return value.clamp(0, widget.data.packages.length - 1).toInt();
}
List<Widget> _buildRewardCards(
List<FirstRechargeReward> rewards,
double scale,
) {
const positions = [
Offset(20, 211),
Offset(124, 211),
Offset(227, 211),
Offset(20, 316),
Offset(124, 316),
Offset(227, 316),
];
final visibleRewards = rewards.take(positions.length).toList();
return List<Widget>.generate(visibleRewards.length, (index) {
final reward = visibleRewards[index];
final position = positions[index];
return Positioned(
left: position.dx * scale,
top: position.dy * scale,
width: 93 * scale,
height: 97 * scale,
child: _RewardCard(reward: reward, scale: scale),
);
});
}
Widget _positionedImage({
required String asset,
required double left,
required double top,
required double width,
required double height,
required double scale,
}) {
return Positioned(
left: left * scale,
top: top * scale,
width: width * scale,
height: height * scale,
child: Image.asset(asset, fit: BoxFit.fill),
);
}
}
class _PriceSelector extends StatelessWidget {
const _PriceSelector({
required this.packages,
required this.selectedIndex,
required this.scale,
required this.onSelected,
});
final List<FirstRechargePackage> packages;
final int selectedIndex;
final double scale;
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) {
final visiblePackages = packages.take(2).toList();
final normalizedSelectedIndex = selectedIndex.clamp(0, 1).toInt();
final selectedIsTrailing = normalizedSelectedIndex == 1;
return Stack(
children: [
Positioned.fill(
child: Image.asset(
_FirstRechargeAssets.priceTabsBg,
fit: BoxFit.fill,
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
left: (selectedIsTrailing ? 120 : 5) * scale,
top: 2 * scale,
width: (selectedIsTrailing ? 120 : 110) * scale,
height: 27 * scale,
child: _PriceSelectedBackground(
scale: scale,
trailing: selectedIsTrailing,
),
),
for (var index = 0; index < visiblePackages.length; index++)
Positioned(
left: (index == 0 ? 5 : 125) * scale,
top: 7 * scale,
width: 110 * scale,
height: 16 * scale,
child: SCDebounceWidget(
onTap: () => onSelected(index),
child: _SingleLineText(
visiblePackages[index].priceText,
height: 16 * scale,
fontSize: 14 * scale,
fontWeight: FontWeight.w500,
color:
selectedIndex == index
? Colors.white
: const Color(0xFF6F4C2B),
),
),
),
],
);
}
}
class _PriceSelectedBackground extends StatelessWidget {
const _PriceSelectedBackground({required this.scale, required this.trailing});
final double scale;
final bool trailing;
@override
Widget build(BuildContext context) {
final texture = SizedBox(
width: 188.25 * scale,
height: 39.52 * scale,
child: Image.asset(
_FirstRechargeAssets.priceSelectedTexture,
fit: BoxFit.fill,
),
);
return ClipPath(
clipper: _PriceSelectionClipper(trailing: trailing),
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned(
left: trailing ? null : -19.58 * scale,
right: trailing ? -19.58 * scale : null,
top: -5.37 * scale,
child:
trailing
? Transform(
alignment: Alignment.center,
transform: Matrix4.diagonal3Values(-1.0, 1.0, 1.0),
child: texture,
)
: texture,
),
],
),
);
}
}
class _PriceSelectionClipper extends CustomClipper<Path> {
const _PriceSelectionClipper({required this.trailing});
final bool trailing;
@override
Path getClip(Size size) {
final w = size.width;
final h = size.height;
final curveX = w * (11.2769 / 110);
final centerY = h * (13.3043 / 27);
if (trailing) {
return Path()
..moveTo(0, h)
..lineTo(0, 0)
..lineTo(w - curveX, 0)
..cubicTo(
w - w * (7.51795 / 110),
h * (0.521738 / 27),
w,
h * (3.91303 / 27),
w,
centerY,
)
..cubicTo(
w,
h * (22.6956 / 27),
w - w * (7.51795 / 110),
h * (26.3478 / 27),
w - curveX,
h,
)
..lineTo(0, h)
..close();
}
return Path()
..moveTo(w, h)
..lineTo(w, 0)
..lineTo(curveX, 0)
..cubicTo(
w * (7.51795 / 110),
h * (0.521738 / 27),
0,
h * (3.91303 / 27),
0,
centerY,
)
..cubicTo(
0,
h * (22.6956 / 27),
w * (7.51795 / 110),
h * (26.3478 / 27),
curveX,
h,
)
..lineTo(w, h)
..close();
}
@override
bool shouldReclip(covariant _PriceSelectionClipper oldClipper) {
return oldClipper.trailing != trailing;
}
}
class _RewardCard extends StatelessWidget {
const _RewardCard({required this.reward, required this.scale});
final FirstRechargeReward reward;
final double scale;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: Image.asset(
reward.isCoinReward
? _FirstRechargeAssets.rewardCardCoin
: _FirstRechargeAssets.rewardCard,
fit: BoxFit.fill,
),
),
if (!reward.isCoinReward && reward.badgeText.trim().isNotEmpty)
Positioned(
left: 8 * scale,
top: 3 * scale,
width: 42 * scale,
height: 10 * scale,
child: _SingleLineText(
reward.badgeText,
height: 10 * scale,
fontSize: 8 * scale,
fontWeight: FontWeight.w400,
color: Colors.white,
alignment: Alignment.centerLeft,
),
),
Positioned(
left: 17 * scale,
top: 14 * scale,
width: 59 * scale,
height: 59 * scale,
child: _RewardCover(reward: reward, scale: scale),
),
Positioned(
left: 5 * scale,
right: 5 * scale,
top: 75 * scale,
height: 15 * scale,
child: _SingleLineText(
reward.title,
height: 15 * scale,
fontSize: 12 * scale,
fontWeight: FontWeight.w400,
color: const Color(0xFF6F4C2B),
),
),
],
);
}
}
class _RewardCover extends StatelessWidget {
const _RewardCover({required this.reward, required this.scale});
final FirstRechargeReward reward;
final double scale;
@override
Widget build(BuildContext context) {
final cover = reward.cover.trim();
if (cover.startsWith('http://') || cover.startsWith('https://')) {
return CachedNetworkImage(
imageUrl: cover,
fit: BoxFit.contain,
placeholder: (_, __) => _fallbackCover(),
errorWidget: (_, __, ___) => _fallbackCover(),
);
}
if (cover.isNotEmpty) {
return Image.asset(
cover,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _fallbackCover(),
);
}
return _fallbackCover();
}
Widget _fallbackCover() {
if (reward.isCoinReward) {
return Image.asset(
_FirstRechargeAssets.yumiCoin,
width: 42 * scale,
height: 42 * scale,
fit: BoxFit.contain,
);
}
final fallbackAsset = _fallbackAsset;
if (fallbackAsset.isNotEmpty) {
return Image.asset(fallbackAsset, fit: BoxFit.contain);
}
return Container(
width: 48 * scale,
height: 48 * scale,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFE5A8), Color(0xFFFFB04F)],
),
),
child: Icon(
Icons.auto_awesome_rounded,
size: 28 * scale,
color: const Color(0xFF6F4C2B),
),
);
}
String get _fallbackAsset {
final title = reward.title.trim().toLowerCase();
if (title.contains('vehicle')) {
return _FirstRechargeAssets.rewardVehicle;
}
if (title.contains('frame')) {
return _FirstRechargeAssets.rewardFrame;
}
if (title == 'vip' || title.contains('vip')) {
return _FirstRechargeAssets.rewardVip;
}
if (title.contains('gift')) {
return _FirstRechargeAssets.rewardGift;
}
if (title.contains('badge') || title.contains('medal')) {
return _FirstRechargeAssets.rewardBadge;
}
return '';
}
}
class _DiscountBadge extends StatelessWidget {
const _DiscountBadge({required this.text, required this.scale});
final String text;
final double scale;
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Image.asset(
_FirstRechargeAssets.discountBadge,
fit: BoxFit.fill,
),
),
Positioned(
left: 5 * scale,
right: 5 * scale,
top: 8 * scale,
height: 11 * scale,
child: _SingleLineText(
text,
height: 11 * scale,
fontSize: 10 * scale,
fontWeight: FontWeight.w700,
color: const Color(0xFFFCF0B7),
shadows: [
Shadow(
color: const Color(0xFF01378F).withValues(alpha: 0.89),
offset: Offset(0, 1 * scale),
blurRadius: 1 * scale,
),
],
),
),
Positioned(
left: 10 * scale,
right: 10 * scale,
top: 20 * scale,
height: 9 * scale,
child: _SingleLineText(
'off',
height: 9 * scale,
fontSize: 8 * scale,
fontWeight: FontWeight.w500,
color: const Color(0xFFFCF0B7),
shadows: [
Shadow(
color: const Color(0xFF01378F).withValues(alpha: 0.89),
offset: Offset(0, 1 * scale),
blurRadius: 1 * scale,
),
],
),
),
],
);
}
}
class _SingleLineText extends StatelessWidget {
const _SingleLineText(
this.text, {
required this.height,
required this.fontSize,
required this.fontWeight,
required this.color,
this.alignment = Alignment.center,
this.shadows,
});
final String text;
final double height;
final double fontSize;
final FontWeight fontWeight;
final Color color;
final Alignment alignment;
final List<Shadow>? shadows;
@override
Widget build(BuildContext context) {
return Align(
alignment: alignment,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: alignment,
child: SizedBox(
height: height,
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.visible,
textAlign:
alignment == Alignment.centerLeft
? TextAlign.left
: TextAlign.center,
style: TextStyle(
height: 1,
fontSize: fontSize,
fontWeight: fontWeight,
color: color,
letterSpacing: 0,
shadows: shadows,
),
),
),
),
);
}
}
class _FirstRechargeAssets {
static const String panelTop = 'sc_images/first_recharge/panel_top.png';
static const String panelMiddle = 'sc_images/first_recharge/panel_middle.png';
static const String panelBottom = 'sc_images/first_recharge/panel_bottom.png';
static const String close = 'sc_images/first_recharge/close.png';
static const String title = 'sc_images/first_recharge/title.png';
static const String priceTabsBg =
'sc_images/first_recharge/price_tabs_bg.png';
static const String priceSelectedTexture =
'sc_images/first_recharge/price_selected_texture.png';
static const String rewardCard = 'sc_images/first_recharge/reward_card.png';
static const String rewardCardCoin =
'sc_images/first_recharge/reward_card_coin.png';
static const String rewardAvatarFrame =
'sc_images/first_recharge/reward_avatar_frame_1.png';
static const String rewardVehicle =
'sc_images/first_recharge/reward_vehicle.png';
static const String rewardFrame = 'sc_images/first_recharge/reward_frame.png';
static const String rewardCoinStack =
'sc_images/first_recharge/reward_coin_stack.png';
static const String rewardGift = 'sc_images/first_recharge/reward_gift.png';
static const String rewardVip = 'sc_images/vip/sc_vip_badge_1.png';
static const String rewardBadge = 'sc_images/index/sc_icon_medals.png';
static const String buyNowButton =
'sc_images/first_recharge/buy_now_button.png';
static const String discountBadge =
'sc_images/first_recharge/discount_badge.png';
static const String yumiCoin = 'sc_images/register_reward/yumi_coin.png';
}