feat(app): 接入金币充值与会员商品下单
This commit is contained in:
1 parent
ec2db6842e
commit
730e38622f
21 files changed
+1325
-1
No files matched your search
@@ -25,6 +25,7 @@ import '../account/account_entry_card.dart';
|
||||
import '../downloads/downloads_page.dart';
|
||||
import '../favorites/favorites_page.dart';
|
||||
import '../notices/notices_page.dart';
|
||||
import '../store/vip_page.dart';
|
||||
import '../wallet/wallet_page.dart';
|
||||
import '../watch_records/watch_records_page.dart';
|
||||
import 'mine_binding.dart';
|
||||
@@ -373,6 +374,21 @@ class _MyPageState extends State<MyPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _openVip() {
|
||||
if (!widget.signedIn) {
|
||||
widget.onSignIn?.call();
|
||||
return;
|
||||
}
|
||||
final wallet = widget.wallet;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => VipPage(
|
||||
onPurchased: wallet == null ? null : () => unawaited(wallet.load()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openWallet(BuildContext context) {
|
||||
if (!widget.signedIn) {
|
||||
widget.onSignIn?.call();
|
||||
@@ -835,6 +851,12 @@ class _MyPageState extends State<MyPage> {
|
||||
MaterialPageRoute(builder: (_) => const DownloadsPage()),
|
||||
),
|
||||
),
|
||||
_menuTile(
|
||||
key: const Key('mine-vip'),
|
||||
icon: Icons.workspace_premium_outlined,
|
||||
title: l10n.vipMembership,
|
||||
onTap: _openVip,
|
||||
),
|
||||
_menuTile(
|
||||
icon: Icons.settings_rounded,
|
||||
title: l10n.settings,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/repositories/store_repository.dart';
|
||||
import '../../core/data/services/store_controller.dart';
|
||||
import 'store_page.dart';
|
||||
|
||||
/// 金币充值页:展示上架金币包并在点击购买时创建订单。
|
||||
class RechargePage extends StatelessWidget {
|
||||
const RechargePage({super.key, this.repository, this.onPurchased});
|
||||
|
||||
final StoreRepository? repository;
|
||||
final VoidCallback? onPurchased;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StorePage(
|
||||
type: StoreController.coinPackType,
|
||||
repository: repository,
|
||||
onPurchased: onPurchased,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/product.dart';
|
||||
import '../../core/data/repositories/store_repository.dart';
|
||||
import '../../core/data/services/store_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 商品货架:金币包与会员档位共用一套真实接口渲染。
|
||||
class StorePage extends StatefulWidget {
|
||||
const StorePage({
|
||||
super.key,
|
||||
required this.type,
|
||||
this.repository,
|
||||
this.onPurchased,
|
||||
});
|
||||
|
||||
final String type;
|
||||
final StoreRepository? repository;
|
||||
final VoidCallback? onPurchased;
|
||||
|
||||
@override
|
||||
State<StorePage> createState() => _StorePageState();
|
||||
}
|
||||
|
||||
class _StorePageState extends State<StorePage> {
|
||||
late final StoreController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = StoreController(
|
||||
widget.repository ?? context.read<StoreRepository>(),
|
||||
type: widget.type,
|
||||
);
|
||||
unawaited(controller.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final isVip = widget.type == StoreController.vipType;
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: Scaffold(
|
||||
key: const Key('store-page'),
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: AppColors.onSurface,
|
||||
title: Text(isVip ? l10n.vipTitle : l10n.rechargeTitle),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) => _body(l10n, isVip),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(AppLocalizations l10n, bool isVip) {
|
||||
if (controller.loading && controller.products.isEmpty) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(key: Key('store-loading')),
|
||||
);
|
||||
}
|
||||
if (controller.error != null && controller.products.isEmpty) {
|
||||
return EmptyState(
|
||||
key: const Key('store-error'),
|
||||
icon: Icons.cloud_off_rounded,
|
||||
title: errorText(l10n, controller.error, l10n.productsLoadFailed),
|
||||
actionLabel: l10n.retry,
|
||||
actionKey: const Key('store-retry'),
|
||||
onAction: () => unawaited(controller.load()),
|
||||
);
|
||||
}
|
||||
if (controller.products.isEmpty) {
|
||||
return EmptyState(
|
||||
key: const Key('store-empty'),
|
||||
icon: isVip ? Icons.workspace_premium_outlined : Icons.savings_outlined,
|
||||
title: isVip ? l10n.noVipPlans : l10n.noCoinPacks,
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
itemCount: controller.products.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) => _ProductTile(
|
||||
product: controller.products[index],
|
||||
purchasing:
|
||||
controller.purchasingProductId == controller.products[index].id,
|
||||
onBuy: () => _buy(controller.products[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _buy(AppProduct product) async {
|
||||
final order = await controller.buy(product);
|
||||
if (!mounted) return;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (order == null) {
|
||||
final message = errorText(
|
||||
l10n,
|
||||
controller.purchaseError,
|
||||
l10n.orderCreateFailed,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
return;
|
||||
}
|
||||
widget.onPurchased?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${l10n.orderCreated(order.orderNo)} ${l10n.paymentPending}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProductTile extends StatelessWidget {
|
||||
const _ProductTile({
|
||||
required this.product,
|
||||
required this.purchasing,
|
||||
required this.onBuy,
|
||||
});
|
||||
|
||||
final AppProduct product;
|
||||
final bool purchasing;
|
||||
final VoidCallback onBuy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Container(
|
||||
key: Key('store-product-${product.id}'),
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 12, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.gold.withValues(alpha: 0.26)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_ProductBadge(vip: product.isVip),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.name,
|
||||
key: Key('store-product-name-${product.id}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_detail(l10n),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
product.displayPrice,
|
||||
key: Key('store-product-price-${product.id}'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.gold,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: 88,
|
||||
height: 32,
|
||||
child: FilledButton(
|
||||
key: Key('store-buy-${product.id}'),
|
||||
onPressed: purchasing ? null : onBuy,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: purchasing
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
)
|
||||
: Text(l10n.buy),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _detail(AppLocalizations l10n) {
|
||||
if (product.isVip) return l10n.vipDuration(product.vipDays);
|
||||
final base = l10n.walletBalanceShort(product.coinAmount);
|
||||
if (product.bonusCoin <= 0) return base;
|
||||
return '$base · ${l10n.bonusCoin(product.bonusCoin)}';
|
||||
}
|
||||
}
|
||||
|
||||
class _ProductBadge extends StatelessWidget {
|
||||
const _ProductBadge({required this.vip});
|
||||
|
||||
final bool vip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.goldCard,
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: Icon(
|
||||
vip ? Icons.workspace_premium_rounded : Icons.monetization_on_rounded,
|
||||
color: AppColors.gold,
|
||||
size: AppDimens.sectionTitle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/repositories/store_repository.dart';
|
||||
import '../../core/data/services/store_controller.dart';
|
||||
import 'store_page.dart';
|
||||
|
||||
/// 会员页:展示 VIP 档位并在点击开通时创建订单。
|
||||
class VipPage extends StatelessWidget {
|
||||
const VipPage({super.key, this.repository, this.onPurchased});
|
||||
|
||||
final StoreRepository? repository;
|
||||
final VoidCallback? onPurchased;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StorePage(
|
||||
type: StoreController.vipType,
|
||||
repository: repository,
|
||||
onPurchased: onPurchased,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../store/recharge_page.dart';
|
||||
import '../store/vip_page.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 金币钱包:余额、累计收支与最近流水。
|
||||
@@ -56,6 +58,53 @@ class _WalletPageState extends State<WalletPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _storeActions(AppLocalizations l10n, WalletController wallet) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
key: const Key('wallet-top-up'),
|
||||
onPressed: () => _openRecharge(wallet),
|
||||
icon: const Icon(Icons.add_card_rounded, size: 16),
|
||||
label: Text(l10n.topUpWallet),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
key: const Key('wallet-vip'),
|
||||
onPressed: () => _openVip(wallet),
|
||||
icon: const Icon(Icons.workspace_premium_outlined, size: 16),
|
||||
label: Text(l10n.vipMembership),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: AppColors.gold),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _openRecharge(WalletController wallet) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => RechargePage(
|
||||
onPurchased: () => unawaited(wallet.load()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openVip(WalletController wallet) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(AppLocalizations l10n, WalletController wallet) {
|
||||
if (wallet.loading && wallet.snapshot == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -77,6 +126,8 @@ class _WalletPageState extends State<WalletPage> {
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
|
||||
children: [
|
||||
_BalanceHero(wallet: snapshot.wallet),
|
||||
const SizedBox(height: 12),
|
||||
_storeActions(l10n, wallet),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Reference in new issue
Block a user