Files
ReelShort/lib/pages/store/store_page.dart
T

270 lines
7.4 KiB
Dart

// 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 '../../core/widgets/plain_page_scaffold.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: PlainPageScaffold(
pageKey: const Key('store-page'),
backKey: const Key('store-back'),
title: isVip ? l10n.vipTitle : l10n.rechargeTitle,
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: _info(l10n)),
const SizedBox(width: 10),
_price(l10n),
],
),
);
}
Widget _info(AppLocalizations l10n) {
return 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),
),
],
);
}
Widget _price(AppLocalizations l10n) {
return 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 ? _buyProgress() : Text(l10n.buy),
),
),
],
);
}
Widget _buyProgress() {
return const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.onPrimary,
),
);
}
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,
),
);
}
}