From 730e38622f84f56518468be962e38c5779593d27 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 04:32:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E6=8E=A5=E5=85=A5=E9=87=91?= =?UTF-8?q?=E5=B8=81=E5=85=85=E5=80=BC=E4=B8=8E=E4=BC=9A=E5=91=98=E5=95=86?= =?UTF-8?q?=E5=93=81=E4=B8=8B=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 7 + lib/core/data/models/order.dart | 49 ++++ lib/core/data/models/product.dart | 54 ++++ lib/core/data/remote/commerce_api.dart | 29 +- .../data/repositories/store_repository.dart | 25 ++ lib/core/data/services/store_controller.dart | 75 +++++ lib/l10n/app_en.arb | 24 ++ lib/l10n/app_id.arb | 24 ++ lib/l10n/app_localizations.dart | 120 ++++++++ lib/l10n/app_localizations_en.dart | 69 +++++ lib/l10n/app_localizations_id.dart | 69 +++++ lib/l10n/app_localizations_zh.dart | 68 +++++ lib/l10n/app_zh.arb | 24 ++ lib/pages/mine/mine_view.dart | 22 ++ lib/pages/store/recharge_page.dart | 24 ++ lib/pages/store/store_page.dart | 268 ++++++++++++++++++ lib/pages/store/vip_page.dart | 24 ++ lib/pages/wallet/wallet_page.dart | 51 ++++ test/commerce/store_api_test.dart | 87 ++++++ test/commerce/store_controller_test.dart | 108 +++++++ test/store/store_pages_test.dart | 105 +++++++ 21 files changed, 1325 insertions(+), 1 deletion(-) create mode 100644 lib/core/data/models/order.dart create mode 100644 lib/core/data/models/product.dart create mode 100644 lib/core/data/repositories/store_repository.dart create mode 100644 lib/core/data/services/store_controller.dart create mode 100644 lib/pages/store/recharge_page.dart create mode 100644 lib/pages/store/store_page.dart create mode 100644 lib/pages/store/vip_page.dart create mode 100644 test/commerce/store_api_test.dart create mode 100644 test/commerce/store_controller_test.dart create mode 100644 test/store/store_pages_test.dart diff --git a/lib/app.dart b/lib/app.dart index 970c467..a8c35ea 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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 { _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 { Provider.value(value: _noticeRepository), Provider.value(value: _accountRepository), Provider.value(value: _commerceRepository), + Provider.value(value: _storeRepository), Provider.value(value: widget.haptics), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), diff --git a/lib/core/data/models/order.dart b/lib/core/data/models/order.dart new file mode 100644 index 0000000..27c7501 --- /dev/null +++ b/lib/core/data/models/order.dart @@ -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 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? ?? ''), + ); + } +} diff --git a/lib/core/data/models/product.dart b/lib/core/data/models/product.dart new file mode 100644 index 0000000..e9072a9 --- /dev/null +++ b/lib/core/data/models/product.dart @@ -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 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']), + ); + } +} diff --git a/lib/core/data/remote/commerce_api.dart b/lib/core/data/remote/commerce_api.dart index 3dedc68..7364130 100644 --- a/lib/core/data/remote/commerce_api.dart +++ b/lib/core/data/remote/commerce_api.dart @@ -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> 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(AppProduct.fromJson) + .toList(); + } + throw const ApiException(-1, '返回数据格式异常'); + } + + /// 创建充值订单,返回 `CREATED` 状态订单。 + Future createOrder(String productId) async { + final data = await _client.post( + '/app/orders', + body: {'productId': productId}, + ); + if (data is Map) return AppOrder.fromJson(data); + throw const ApiException(-1, '返回数据格式异常'); + } + Future unlock(String episodeId) async { final data = await _client.post('/app/episodes/$episodeId/unlock'); if (data is Map) return UnlockResult.fromJson(data); diff --git a/lib/core/data/repositories/store_repository.dart b/lib/core/data/repositories/store_repository.dart new file mode 100644 index 0000000..d2d2e77 --- /dev/null +++ b/lib/core/data/repositories/store_repository.dart @@ -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> fetchProducts({String? type}); + + Future createOrder(String productId); +} + +class HttpStoreRepository implements StoreRepository { + const HttpStoreRepository(this._api); + + final CommerceApi _api; + + @override + Future> fetchProducts({String? type}) => + _api.products(type: type); + + @override + Future createOrder(String productId) => _api.createOrder(productId); +} diff --git a/lib/core/data/services/store_controller.dart b/lib/core/data/services/store_controller.dart new file mode 100644 index 0000000..5e8adb2 --- /dev/null +++ b/lib/core/data/services/store_controller.dart @@ -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 products = const []; + bool loading = false; + String? error; + String? purchasingProductId; + String? purchaseError; + AppOrder? order; + bool _disposed = false; + + Future 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 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(); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e63895a..94333ca 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" } } } } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 8a84486..c8b540d 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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" } } } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 18215cf..0a9e9d1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 24f9aa8..b45cf0c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index d45a22e..6004c6a 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d384d2b..53e7a74 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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 个结果'; + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 60b8761..f637bac 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -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" } } } } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 4174e76..0e2d6cf 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -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 { ); } + void _openVip() { + if (!widget.signedIn) { + widget.onSignIn?.call(); + return; + } + final wallet = widget.wallet; + Navigator.of(context).push( + MaterialPageRoute( + 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 { 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, diff --git a/lib/pages/store/recharge_page.dart b/lib/pages/store/recharge_page.dart new file mode 100644 index 0000000..007d971 --- /dev/null +++ b/lib/pages/store/recharge_page.dart @@ -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, + ); + } +} diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart new file mode 100644 index 0000000..cffb358 --- /dev/null +++ b/lib/pages/store/store_page.dart @@ -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 createState() => _StorePageState(); +} + +class _StorePageState extends State { + late final StoreController controller; + + @override + void initState() { + super.initState(); + controller = StoreController( + widget.repository ?? context.read(), + 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 _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, + ), + ); + } +} diff --git a/lib/pages/store/vip_page.dart b/lib/pages/store/vip_page.dart new file mode 100644 index 0000000..f6430b9 --- /dev/null +++ b/lib/pages/store/vip_page.dart @@ -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, + ); + } +} diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart index fcb12de..bc2f055 100644 --- a/lib/pages/wallet/wallet_page.dart +++ b/lib/pages/wallet/wallet_page.dart @@ -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 { ); } + 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( + builder: (_) => RechargePage( + onPurchased: () => unawaited(wallet.load()), + ), + ), + ); + } + + void _openVip(WalletController wallet) { + Navigator.of(context).push( + MaterialPageRoute( + 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 { 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: [ diff --git a/test/commerce/store_api_test.dart b/test/commerce/store_api_test.dart new file mode 100644 index 0000000..89b18fb --- /dev/null +++ b/test/commerce/store_api_test.dart @@ -0,0 +1,87 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/commerce_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test('should_request_coin_packs_when_type_is_given', () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': [ + { + 'id': '2111012753262120962', + 'code': 'coin_550', + 'name': '550 Coins', + 'type': 'COIN_PACK', + 'coinAmount': '500', + 'bonusCoin': '50', + 'totalCoin': '550', + 'displayPrice': 'Rp 15.000', + 'currency': 'IDR', + 'vipDays': 0, + }, + ], + }), + ), + ); + final api = CommerceApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final products = await api.products(type: 'COIN_PACK'); + + final request = adapter.requests.single; + expect(request.method, 'GET'); + expect(request.uri.path, '/app/products'); + expect(request.queryParameters['type'], 'COIN_PACK'); + expect(products.single.id, '2111012753262120962'); + expect(products.single.coinAmount, 500); + expect(products.single.bonusCoin, 50); + expect(products.single.displayPrice, 'Rp 15.000'); + }); + + test('should_post_product_id_when_creating_an_order', () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'id': '2111012753262120999', + 'orderNo': 'ORD20261020001', + 'productId': '2111012753262120962', + 'productName': '550 Coins', + 'type': 'COIN_PACK', + 'coinAmount': '550', + 'status': 'CREATED', + 'createdAt': '2026-10-20 10:00:00', + }, + }), + ), + ); + final api = CommerceApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final order = await api.createOrder('2111012753262120962'); + + final request = adapter.requests.single; + expect(request.method, 'POST'); + expect(request.uri.path, '/app/orders'); + final body = request.data is String + ? jsonDecode(request.data as String) + : request.data; + expect(body, {'productId': '2111012753262120962'}); + expect(order.orderNo, 'ORD20261020001'); + expect(order.coinAmount, 550); + expect(order.status, 'CREATED'); + }); +} diff --git a/test/commerce/store_controller_test.dart b/test/commerce/store_controller_test.dart new file mode 100644 index 0000000..d1b83df --- /dev/null +++ b/test/commerce/store_controller_test.dart @@ -0,0 +1,108 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/order.dart'; +import 'package:fruitshorts/core/data/models/product.dart'; +import 'package:fruitshorts/core/data/remote/api_exception.dart'; +import 'package:fruitshorts/core/data/repositories/store_repository.dart'; +import 'package:fruitshorts/core/data/services/store_controller.dart'; + +void main() { + test('should_expose_coin_packs_when_store_loads', () async { + final repository = _FakeStoreRepository( + products: [_product('550 Coins', coinAmount: 500, bonusCoin: 50)], + ); + final controller = StoreController( + repository, + type: StoreController.coinPackType, + ); + + await controller.load(); + + expect(repository.requestedTypes, [StoreController.coinPackType]); + expect(controller.products.single.name, '550 Coins'); + expect(controller.error, isNull); + }); + + test('should_create_order_and_keep_purchase_when_buying', () async { + final repository = _FakeStoreRepository( + products: [_product('550 Coins')], + order: const AppOrder( + id: '99', + orderNo: 'ORD1', + productName: '550 Coins', + coinAmount: 550, + status: 'CREATED', + ), + ); + final controller = StoreController( + repository, + type: StoreController.coinPackType, + ); + await controller.load(); + + final order = await controller.buy(controller.products.single); + + expect(repository.orderedProductIds, ['2111012753262120962']); + expect(order?.orderNo, 'ORD1'); + expect(controller.purchaseError, isNull); + expect(controller.purchasingProductId, isNull); + }); + + test('should_expose_server_message_when_order_is_rejected', () async { + final repository = _FakeStoreRepository( + products: [_product('550 Coins')], + orderError: const ApiException(400, 'Product is offline'), + ); + final controller = StoreController( + repository, + type: StoreController.coinPackType, + ); + await controller.load(); + + final order = await controller.buy(controller.products.single); + + expect(order, isNull); + expect(controller.purchaseError, 'Product is offline'); + }); +} + +AppProduct _product(String name, {int coinAmount = 550, int bonusCoin = 0}) => + AppProduct( + id: '2111012753262120962', + code: 'coin_550', + name: name, + type: StoreController.coinPackType, + coinAmount: coinAmount, + bonusCoin: bonusCoin, + totalCoin: coinAmount + bonusCoin, + displayPrice: 'Rp 15.000', + vipDays: 0, + ); + +class _FakeStoreRepository implements StoreRepository { + _FakeStoreRepository({ + this.products = const [], + this.order, + this.orderError, + }); + + final List products; + final AppOrder? order; + final ApiException? orderError; + final List requestedTypes = []; + final List orderedProductIds = []; + + @override + Future> fetchProducts({String? type}) async { + requestedTypes.add(type); + return products; + } + + @override + Future createOrder(String productId) async { + orderedProductIds.add(productId); + if (orderError != null) throw orderError!; + return order!; + } +} diff --git a/test/store/store_pages_test.dart b/test/store/store_pages_test.dart new file mode 100644 index 0000000..c851692 --- /dev/null +++ b/test/store/store_pages_test.dart @@ -0,0 +1,105 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/order.dart'; +import 'package:fruitshorts/core/data/models/product.dart'; +import 'package:fruitshorts/core/data/repositories/store_repository.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/store/recharge_page.dart'; +import 'package:fruitshorts/pages/store/vip_page.dart'; + +void main() { + testWidgets('should_show_coin_packs_and_create_order_when_buying', ( + tester, + ) async { + final repository = _FakeStoreRepository(); + + await tester.pumpWidget(_app(RechargePage(repository: repository))); + await tester.pumpAndSettle(); + + expect(find.text('550 Coins'), findsOneWidget); + expect(find.textContaining('500 coins'), findsOneWidget); + expect(find.textContaining('+50 bonus'), findsOneWidget); + expect(find.text('Rp 15.000'), findsOneWidget); + + await tester.tap(find.byKey(const Key('store-buy-pack-1'))); + await tester.pumpAndSettle(); + + expect(repository.requestedTypes, ['COIN_PACK']); + expect(repository.orderedProductIds, ['pack-1']); + expect(find.textContaining('Order ORD1 created.'), findsOneWidget); + }); + + testWidgets('should_show_vip_tiers_and_create_order_when_joining', ( + tester, + ) async { + final repository = _FakeStoreRepository(); + + await tester.pumpWidget(_app(VipPage(repository: repository))); + await tester.pumpAndSettle(); + + expect(find.text('30-day VIP'), findsOneWidget); + expect(find.text('Rp 99.000'), findsOneWidget); + + await tester.tap(find.byKey(const Key('store-buy-vip-1'))); + await tester.pumpAndSettle(); + + expect(repository.requestedTypes, ['VIP']); + expect(repository.orderedProductIds, ['vip-1']); + expect(find.textContaining('Order ORD1 created.'), findsOneWidget); + }); +} + +Widget _app(Widget home) => MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: home, +); + +class _FakeStoreRepository implements StoreRepository { + final List requestedTypes = []; + final List orderedProductIds = []; + + @override + Future> fetchProducts({String? type}) async { + requestedTypes.add(type); + if (type == AppProduct.vipType) { + return const [ + AppProduct( + id: 'vip-1', + code: 'vip_30', + name: 'Monthly VIP', + type: AppProduct.vipType, + displayPrice: 'Rp 99.000', + vipDays: 30, + ), + ]; + } + return const [ + AppProduct( + id: 'pack-1', + code: 'coin_550', + name: '550 Coins', + type: AppProduct.coinPackType, + coinAmount: 500, + bonusCoin: 50, + totalCoin: 550, + displayPrice: 'Rp 15.000', + ), + ]; + } + + @override + Future createOrder(String productId) async { + orderedProductIds.add(productId); + return const AppOrder( + id: 'order-1', + orderNo: 'ORD1', + productName: 'Coin pack', + coinAmount: 550, + status: AppOrder.created, + ); + } +}