feat(app): 接入金币充值与会员商品下单

This commit is contained in:
Zhou Weidong committed 2026-10-20 04:32:03 +08:00
1 parent ec2db6842e
commit 730e38622f
21 files changed
+1325 -1

No files matched your search

+7
View File
@@ -30,6 +30,7 @@ import 'core/data/repositories/favorite_repository.dart';
import 'core/data/repositories/interaction_repository.dart';
import 'core/data/repositories/notice_repository.dart';
import 'core/data/repositories/playback_repository.dart';
import 'core/data/repositories/store_repository.dart';
import 'core/data/repositories/watch_history_repository.dart';
import 'core/data/services/auth_controller.dart';
import 'core/data/services/auth_session_store.dart';
@@ -62,6 +63,7 @@ class LumaDramaApp extends StatefulWidget {
this.noticeRepository,
this.accountRepository,
this.commerceRepository,
this.storeRepository,
this.haptics = const FlutterHaptics(),
});
@@ -79,6 +81,7 @@ class LumaDramaApp extends StatefulWidget {
final NoticeRepository? noticeRepository;
final AccountRepository? accountRepository;
final CommerceRepository? commerceRepository;
final StoreRepository? storeRepository;
final Haptics haptics;
@override
@@ -146,6 +149,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
_commerceRepository,
);
late final StoreRepository _storeRepository =
widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient));
late final CommentRepository _commentRepository =
widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient));
@@ -216,6 +222,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
Provider<NoticeRepository>.value(value: _noticeRepository),
Provider<AccountRepository>.value(value: _accountRepository),
Provider<CommerceRepository>.value(value: _commerceRepository),
Provider<StoreRepository>.value(value: _storeRepository),
Provider<Haptics>.value(value: widget.haptics),
ChangeNotifierProvider(
create: (_) => CatalogController(_catalogRepository)..load(),
+49
View File
@@ -0,0 +1,49 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 充值订单:`POST /app/orders` 创建的 `CREATED` 订单,等待支付。
class AppOrder {
const AppOrder({
required this.id,
required this.orderNo,
required this.productName,
required this.coinAmount,
required this.status,
this.productId = '',
this.type = '',
this.amount = '',
this.currency = '',
this.createdAt,
});
static const String created = 'CREATED';
final String id;
final String orderNo;
final String productId;
final String productName;
final String type;
final int coinAmount;
final String amount;
final String currency;
final String status;
final DateTime? createdAt;
bool get isCreated => status == created;
factory AppOrder.fromJson(Map<String, dynamic> json) {
return AppOrder(
id: (json['id'] ?? '').toString(),
orderNo: json['orderNo'] as String? ?? '',
productId: (json['productId'] ?? '').toString(),
productName: json['productName'] as String? ?? '',
type: json['type'] as String? ?? '',
coinAmount: jsonToInt(json['coinAmount']),
amount: (json['amount'] ?? '').toString(),
currency: json['currency'] as String? ?? '',
status: json['status'] as String? ?? '',
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''),
);
}
}
+54
View File
@@ -0,0 +1,54 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 充值商品:`GET /app/products` 的 `COIN_PACK` / `VIP` 条目。
class AppProduct {
const AppProduct({
required this.id,
required this.code,
required this.name,
required this.type,
this.coinAmount = 0,
this.bonusCoin = 0,
this.totalCoin = 0,
this.displayPrice = '',
this.currency = '',
this.vipDays = 0,
});
static const String coinPackType = 'COIN_PACK';
static const String vipType = 'VIP';
final String id;
final String code;
final String name;
final String type;
final int coinAmount;
final int bonusCoin;
final int totalCoin;
final String displayPrice;
final String currency;
final int vipDays;
bool get isVip => type == vipType;
factory AppProduct.fromJson(Map<String, dynamic> json) {
final coinAmount = jsonToInt(json['coinAmount']);
final bonusCoin = jsonToInt(json['bonusCoin']);
return AppProduct(
id: (json['id'] ?? '').toString(),
code: json['code'] as String? ?? '',
name: json['name'] as String? ?? '',
type: json['type'] as String? ?? '',
coinAmount: coinAmount,
bonusCoin: bonusCoin,
totalCoin: json['totalCoin'] == null
? coinAmount + bonusCoin
: jsonToInt(json['totalCoin']),
displayPrice: json['displayPrice'] as String? ?? '',
currency: json['currency'] as String? ?? '',
vipDays: jsonToInt(json['vipDays']),
);
}
}
+28 -1
View File
@@ -1,5 +1,7 @@
// Author: fruit.com.cn
// Author: fruit.com.cn
import '../models/order.dart';
import '../models/product.dart';
import '../models/wallet.dart';
import 'api_client.dart';
@@ -15,6 +17,31 @@ class CommerceApi {
throw const ApiException(-1, '返回数据格式异常');
}
/// 上架商品列表;`type` 传 `COIN_PACK` 或 `VIP`。
Future<List<AppProduct>> products({String? type}) async {
final data = await _client.get(
'/app/products',
query: type == null ? null : {'type': type},
);
if (data is List) {
return data
.whereType<Map<String, dynamic>>()
.map(AppProduct.fromJson)
.toList();
}
throw const ApiException(-1, '返回数据格式异常');
}
/// 创建充值订单,返回 `CREATED` 状态订单。
Future<AppOrder> createOrder(String productId) async {
final data = await _client.post(
'/app/orders',
body: {'productId': productId},
);
if (data is Map<String, dynamic>) return AppOrder.fromJson(data);
throw const ApiException(-1, '返回数据格式异常');
}
Future<UnlockResult> unlock(String episodeId) async {
final data = await _client.post('/app/episodes/$episodeId/unlock');
if (data is Map<String, dynamic>) return UnlockResult.fromJson(data);
@@ -0,0 +1,25 @@
// Author: fruit.com.cn
import '../models/order.dart';
import '../models/product.dart';
import '../remote/commerce_api.dart';
/// 商品与订单来源:充值页、会员页共用。
abstract interface class StoreRepository {
Future<List<AppProduct>> fetchProducts({String? type});
Future<AppOrder> createOrder(String productId);
}
class HttpStoreRepository implements StoreRepository {
const HttpStoreRepository(this._api);
final CommerceApi _api;
@override
Future<List<AppProduct>> fetchProducts({String? type}) =>
_api.products(type: type);
@override
Future<AppOrder> createOrder(String productId) => _api.createOrder(productId);
}
@@ -0,0 +1,75 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/order.dart';
import '../models/product.dart';
import '../repositories/store_repository.dart';
import '../remote/api_exception.dart';
/// 商品货架状态:按类型拉取商品并创建订单。
class StoreController extends ChangeNotifier {
StoreController(this._repository, {required this.type});
static const String coinPackType = AppProduct.coinPackType;
static const String vipType = AppProduct.vipType;
final StoreRepository _repository;
final String type;
List<AppProduct> products = const [];
bool loading = false;
String? error;
String? purchasingProductId;
String? purchaseError;
AppOrder? order;
bool _disposed = false;
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
products = await _repository.fetchProducts(type: type);
} on ApiException catch (exception) {
error = exception.message;
} catch (_) {
error = 'productsLoadFailed';
} finally {
loading = false;
_notify();
}
}
/// 创建订单;成功后保留 [order] 供页面展示订单号。
Future<AppOrder?> buy(AppProduct product) async {
if (purchasingProductId != null) return null;
purchasingProductId = product.id;
purchaseError = null;
_notify();
try {
final created = await _repository.createOrder(product.id);
order = created;
return created;
} on ApiException catch (exception) {
purchaseError = exception.message;
return null;
} catch (_) {
purchaseError = 'orderCreateFailed';
return null;
} finally {
purchasingProductId = null;
_notify();
}
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+24
View File
@@ -516,4 +516,28 @@
"rankingEmptyTitle": "No ranked stories yet",
"rankingEmptyBody": "Titles appear here as soon as they are published.",
"rankingTopPick": "TOP PICK"
, "rechargeTitle": "Top Up Coins",
"vipTitle": "VIP Membership",
"buy": "Buy",
"bonusCoin": "+{bonus} bonus",
"@bonusCoin": { "placeholders": { "bonus": { "type": "int" } } },
"vipDuration": "{days}-day VIP",
"@vipDuration": { "placeholders": { "days": { "type": "int" } } },
"noCoinPacks": "No coin packs available right now.",
"noVipPlans": "No VIP plans available right now.",
"productsLoadFailed": "Could not load products.",
"orderCreated": "Order {orderNo} created.",
"@orderCreated": { "placeholders": { "orderNo": { "type": "String" } } },
"paymentPending": "Waiting for payment.",
"orderCreateFailed": "Could not create the order.",
"playbackQuality": "Quality",
"qualityAuto": "Auto",
"qualityAutoHint": "HLS adjusts the bitrate automatically for your connection.",
"searchFailed": "Search failed. Please try again.",
"languageFilter": "Language",
"languageChinese": "Chinese",
"languageIndonesian": "Indonesian",
"languageEnglish": "English",
"searchResultCount": "{count} results",
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }
}
+24
View File
@@ -514,4 +514,28 @@
"rankingEmptyTitle": "Belum ada peringkat",
"rankingEmptyBody": "Judul muncul di sini setelah diterbitkan.",
"rankingTopPick": "PILIHAN UTAMA"
, "rechargeTitle": "Isi Ulang Koin",
"vipTitle": "Keanggotaan VIP",
"buy": "Beli",
"bonusCoin": "+{bonus} bonus",
"@bonusCoin": { "placeholders": { "bonus": { "type": "int" } } },
"vipDuration": "VIP {days} hari",
"@vipDuration": { "placeholders": { "days": { "type": "int" } } },
"noCoinPacks": "Belum ada paket koin.",
"noVipPlans": "Belum ada paket VIP.",
"productsLoadFailed": "Gagal memuat produk.",
"orderCreated": "Pesanan {orderNo} dibuat.",
"@orderCreated": { "placeholders": { "orderNo": { "type": "String" } } },
"paymentPending": "Menunggu pembayaran.",
"orderCreateFailed": "Gagal membuat pesanan.",
"playbackQuality": "Kualitas",
"qualityAuto": "Otomatis",
"qualityAutoHint": "HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.",
"searchFailed": "Pencarian gagal. Coba lagi.",
"languageFilter": "Bahasa",
"languageChinese": "Mandarin",
"languageIndonesian": "Indonesia",
"languageEnglish": "Inggris",
"searchResultCount": "{count} hasil",
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }
}
+120
View File
@@ -2343,6 +2343,126 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'TOP PICK'**
String get rankingTopPick;
/// No description provided for @rechargeTitle.
///
/// In en, this message translates to:
/// **'Top Up Coins'**
String get rechargeTitle;
/// No description provided for @vipTitle.
///
/// In en, this message translates to:
/// **'VIP Membership'**
String get vipTitle;
/// No description provided for @buy.
///
/// In en, this message translates to:
/// **'Buy'**
String get buy;
/// No description provided for @bonusCoin.
///
/// In en, this message translates to:
/// **'+{bonus} bonus'**
String bonusCoin(int bonus);
/// No description provided for @vipDuration.
///
/// In en, this message translates to:
/// **'{days}-day VIP'**
String vipDuration(int days);
/// No description provided for @noCoinPacks.
///
/// In en, this message translates to:
/// **'No coin packs available right now.'**
String get noCoinPacks;
/// No description provided for @noVipPlans.
///
/// In en, this message translates to:
/// **'No VIP plans available right now.'**
String get noVipPlans;
/// No description provided for @productsLoadFailed.
///
/// In en, this message translates to:
/// **'Could not load products.'**
String get productsLoadFailed;
/// No description provided for @orderCreated.
///
/// In en, this message translates to:
/// **'Order {orderNo} created.'**
String orderCreated(String orderNo);
/// No description provided for @paymentPending.
///
/// In en, this message translates to:
/// **'Waiting for payment.'**
String get paymentPending;
/// No description provided for @orderCreateFailed.
///
/// In en, this message translates to:
/// **'Could not create the order.'**
String get orderCreateFailed;
/// No description provided for @playbackQuality.
///
/// In en, this message translates to:
/// **'Quality'**
String get playbackQuality;
/// No description provided for @qualityAuto.
///
/// In en, this message translates to:
/// **'Auto'**
String get qualityAuto;
/// No description provided for @qualityAutoHint.
///
/// In en, this message translates to:
/// **'HLS adjusts the bitrate automatically for your connection.'**
String get qualityAutoHint;
/// No description provided for @searchFailed.
///
/// In en, this message translates to:
/// **'Search failed. Please try again.'**
String get searchFailed;
/// No description provided for @languageFilter.
///
/// In en, this message translates to:
/// **'Language'**
String get languageFilter;
/// No description provided for @languageChinese.
///
/// In en, this message translates to:
/// **'Chinese'**
String get languageChinese;
/// No description provided for @languageIndonesian.
///
/// In en, this message translates to:
/// **'Indonesian'**
String get languageIndonesian;
/// No description provided for @languageEnglish.
///
/// In en, this message translates to:
/// **'English'**
String get languageEnglish;
/// No description provided for @searchResultCount.
///
/// In en, this message translates to:
/// **'{count} results'**
String searchResultCount(int count);
}
class _AppLocalizationsDelegate
+69
View File
@@ -1199,4 +1199,73 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get rankingTopPick => 'TOP PICK';
@override
String get rechargeTitle => 'Top Up Coins';
@override
String get vipTitle => 'VIP Membership';
@override
String get buy => 'Buy';
@override
String bonusCoin(int bonus) {
return '+$bonus bonus';
}
@override
String vipDuration(int days) {
return '$days-day VIP';
}
@override
String get noCoinPacks => 'No coin packs available right now.';
@override
String get noVipPlans => 'No VIP plans available right now.';
@override
String get productsLoadFailed => 'Could not load products.';
@override
String orderCreated(String orderNo) {
return 'Order $orderNo created.';
}
@override
String get paymentPending => 'Waiting for payment.';
@override
String get orderCreateFailed => 'Could not create the order.';
@override
String get playbackQuality => 'Quality';
@override
String get qualityAuto => 'Auto';
@override
String get qualityAutoHint =>
'HLS adjusts the bitrate automatically for your connection.';
@override
String get searchFailed => 'Search failed. Please try again.';
@override
String get languageFilter => 'Language';
@override
String get languageChinese => 'Chinese';
@override
String get languageIndonesian => 'Indonesian';
@override
String get languageEnglish => 'English';
@override
String searchResultCount(int count) {
return '$count results';
}
}
+69
View File
@@ -1197,4 +1197,73 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get rankingTopPick => 'PILIHAN UTAMA';
@override
String get rechargeTitle => 'Isi Ulang Koin';
@override
String get vipTitle => 'Keanggotaan VIP';
@override
String get buy => 'Beli';
@override
String bonusCoin(int bonus) {
return '+$bonus bonus';
}
@override
String vipDuration(int days) {
return 'VIP $days hari';
}
@override
String get noCoinPacks => 'Belum ada paket koin.';
@override
String get noVipPlans => 'Belum ada paket VIP.';
@override
String get productsLoadFailed => 'Gagal memuat produk.';
@override
String orderCreated(String orderNo) {
return 'Pesanan $orderNo dibuat.';
}
@override
String get paymentPending => 'Menunggu pembayaran.';
@override
String get orderCreateFailed => 'Gagal membuat pesanan.';
@override
String get playbackQuality => 'Kualitas';
@override
String get qualityAuto => 'Otomatis';
@override
String get qualityAutoHint =>
'HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.';
@override
String get searchFailed => 'Pencarian gagal. Coba lagi.';
@override
String get languageFilter => 'Bahasa';
@override
String get languageChinese => 'Mandarin';
@override
String get languageIndonesian => 'Indonesia';
@override
String get languageEnglish => 'Inggris';
@override
String searchResultCount(int count) {
return '$count hasil';
}
}
+68
View File
@@ -1176,4 +1176,72 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get rankingTopPick => '今日榜首';
@override
String get rechargeTitle => '充值金币';
@override
String get vipTitle => 'VIP 会员';
@override
String get buy => '购买';
@override
String bonusCoin(int bonus) {
return '赠 $bonus';
}
@override
String vipDuration(int days) {
return '$days 天会员';
}
@override
String get noCoinPacks => '暂无可购金币包。';
@override
String get noVipPlans => '暂无可开通会员。';
@override
String get productsLoadFailed => '商品加载失败。';
@override
String orderCreated(String orderNo) {
return '订单 $orderNo 已创建。';
}
@override
String get paymentPending => '等待支付。';
@override
String get orderCreateFailed => '订单创建失败。';
@override
String get playbackQuality => '清晰度';
@override
String get qualityAuto => '自动';
@override
String get qualityAutoHint => 'HLS 会根据当前网络自动切换码率。';
@override
String get searchFailed => '搜索失败,请重试。';
@override
String get languageFilter => '语言';
@override
String get languageChinese => '中文';
@override
String get languageIndonesian => '印尼语';
@override
String get languageEnglish => '英语';
@override
String searchResultCount(int count) {
return '$count 个结果';
}
}
+24
View File
@@ -476,4 +476,28 @@
"rankingEmptyTitle": "暂无榜单内容",
"rankingEmptyBody": "新剧上线后会自动进入榜单。",
"rankingTopPick": "今日榜首"
, "rechargeTitle": "充值金币",
"vipTitle": "VIP 会员",
"buy": "购买",
"bonusCoin": "赠 {bonus}",
"@bonusCoin": { "placeholders": { "bonus": { "type": "int" } } },
"vipDuration": "{days} 天会员",
"@vipDuration": { "placeholders": { "days": { "type": "int" } } },
"noCoinPacks": "暂无可购金币包。",
"noVipPlans": "暂无可开通会员。",
"productsLoadFailed": "商品加载失败。",
"orderCreated": "订单 {orderNo} 已创建。",
"@orderCreated": { "placeholders": { "orderNo": { "type": "String" } } },
"paymentPending": "等待支付。",
"orderCreateFailed": "订单创建失败。",
"playbackQuality": "清晰度",
"qualityAuto": "自动",
"qualityAutoHint": "HLS 会根据当前网络自动切换码率。",
"searchFailed": "搜索失败,请重试。",
"languageFilter": "语言",
"languageChinese": "中文",
"languageIndonesian": "印尼语",
"languageEnglish": "英语",
"searchResultCount": "{count} 个结果",
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }
}
+22
View File
@@ -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,
+24
View File
@@ -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,
);
}
}
+268
View File
@@ -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,
),
);
}
}
+24
View File
@@ -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,
);
}
}
+51
View File
@@ -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: [