feat(app): 接入金币充值与会员商品下单
This commit is contained in:
1 parent
ec2db6842e
commit
730e38622f
21 files changed
+1325
-1
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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? ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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" } } }
|
||||
}
|
||||
@@ -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" } } }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 个结果';
|
||||
}
|
||||
}
|
||||
@@ -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" } } }
|
||||
}
|
||||
@@ -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