diff --git a/lib/app.dart b/lib/app.dart index 970c467..84f1e7f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -30,6 +30,8 @@ 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/search_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 +64,8 @@ class LumaDramaApp extends StatefulWidget { this.noticeRepository, this.accountRepository, this.commerceRepository, + this.storeRepository, + this.searchRepository, this.haptics = const FlutterHaptics(), }); @@ -79,6 +83,8 @@ class LumaDramaApp extends StatefulWidget { final NoticeRepository? noticeRepository; final AccountRepository? accountRepository; final CommerceRepository? commerceRepository; + final StoreRepository? storeRepository; + final SearchRepository? searchRepository; final Haptics haptics; @override @@ -140,12 +146,19 @@ class _LumaDramaAppState extends State { ); late final CommerceRepository _commerceRepository = - widget.commerceRepository ?? HttpCommerceRepository(CommerceApi(_apiClient)); + widget.commerceRepository ?? + HttpCommerceRepository(CommerceApi(_apiClient)); late final WalletController _walletController = WalletController( _commerceRepository, ); + late final SearchRepository _searchRepository = + widget.searchRepository ?? HttpSearchRepository(CatalogApi(_apiClient)); + + late final StoreRepository _storeRepository = + widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient)); + late final CommentRepository _commentRepository = widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient)); @@ -216,6 +229,8 @@ class _LumaDramaAppState extends State { Provider.value(value: _noticeRepository), Provider.value(value: _accountRepository), Provider.value(value: _commerceRepository), + Provider.value(value: _storeRepository), + Provider.value(value: _searchRepository), Provider.value(value: widget.haptics), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index fed6cfa..d1b41c3 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -65,6 +65,7 @@ class Drama { this.freeEpisodes = 0, this.isHot = false, this.viewCount = 0, + this.language = '', this.detailLoaded = false, }); @@ -80,6 +81,9 @@ class Drama { final bool isHot; final int viewCount; + /// 卡片语种(如 zh-CN / id),用于剧场语言筛选。 + final String language; + /// 详情接口是否已经取过完整剧集列表。 /// 列表卡片只带一集可播预览,需要完整剧集时再进详情。 final bool detailLoaded; @@ -104,6 +108,7 @@ class Drama { isHot: json['isHot'] == true, // viewCount 后端是 Long,按字符串下发(见 json_numbers.dart) viewCount: jsonToInt(json['viewCount']), + language: json['language'] as String? ?? '', ); } @@ -120,6 +125,7 @@ class Drama { freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0, isHot: json['isHot'] == true, viewCount: jsonToInt(json['viewCount']), + language: json['language'] as String? ?? '', detailLoaded: true, episodes: (json['episodes'] as List? ?? const []) .whereType>() diff --git a/lib/core/data/models/order.dart b/lib/core/data/models/order.dart new file mode 100644 index 0000000..39a9dcf --- /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/play_ticket.dart b/lib/core/data/models/play_ticket.dart index bb4a899..a585079 100644 --- a/lib/core/data/models/play_ticket.dart +++ b/lib/core/data/models/play_ticket.dart @@ -13,6 +13,7 @@ class PlayTicket { this.durationSec = 0, this.unlockCoin = 0, this.previewSeconds = 0, + this.qualities = const [], }); static const String free = 'FREE'; @@ -35,6 +36,9 @@ class PlayTicket { /// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。 final int previewSeconds; + /// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。 + final List qualities; + bool get needsUnlock => accessType == coinRequired || accessType == vipRequired; @@ -50,6 +54,10 @@ class PlayTicket { durationSec: jsonToInt(json['durationSec']), unlockCoin: jsonToInt(json['unlockCoin']), previewSeconds: jsonToInt(json['previewSeconds']), + qualities: (json['qualities'] as List? ?? const []) + .whereType() + .where((quality) => quality.isNotEmpty) + .toList(), ); } } diff --git a/lib/core/data/models/product.dart b/lib/core/data/models/product.dart new file mode 100644 index 0000000..5f277e6 --- /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/models/search_result.dart b/lib/core/data/models/search_result.dart new file mode 100644 index 0000000..9e7b852 --- /dev/null +++ b/lib/core/data/models/search_result.dart @@ -0,0 +1,22 @@ +// Author: fruit.com.cn + +import 'drama.dart'; +import 'json_numbers.dart'; + +/// 搜索分页结果:`GET /app/search` 的 `{total, list}`。 +class SearchResult { + const SearchResult({this.total = 0, this.list = const []}); + + final int total; + final List list; + + factory SearchResult.fromJson(Map json) { + return SearchResult( + total: jsonToInt(json['total']), + list: (json['list'] as List? ?? const []) + .whereType>() + .map(Drama.fromCardJson) + .toList(), + ); + } +} diff --git a/lib/core/data/remote/catalog_api.dart b/lib/core/data/remote/catalog_api.dart index 4346460..666c56f 100644 --- a/lib/core/data/remote/catalog_api.dart +++ b/lib/core/data/remote/catalog_api.dart @@ -1,5 +1,6 @@ import '../models/drama.dart'; import '../models/home_feed.dart'; +import '../models/search_result.dart'; import 'api_client.dart'; /// 客户端目录接口,对应后端 `/app/**`。 @@ -16,6 +17,23 @@ class CatalogApi { return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed'))); } + /// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。 + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) async { + final data = await _client.get( + '/app/search', + query: { + 'keyword': keyword, + 'pageIndex': '$pageIndex', + 'pageSize': '$pageSize', + }, + ); + return SearchResult.fromJson(_asMap(data)); + } + Future dramaDetail(String dramaId) async { return Drama.fromDetailJson( _asMap(await _client.get('/app/dramas/$dramaId')), diff --git a/lib/core/data/remote/commerce_api.dart b/lib/core/data/remote/commerce_api.dart index 3dedc68..5728a69 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 +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/playback_repository.dart b/lib/core/data/repositories/playback_repository.dart index 24bc443..d68065a 100644 --- a/lib/core/data/repositories/playback_repository.dart +++ b/lib/core/data/repositories/playback_repository.dart @@ -59,7 +59,8 @@ class _CachedTicket { final PlayTicket ticket; final DateTime fetchedAt; - bool isExpired(DateTime now, Duration ttl) => now.difference(fetchedAt) >= ttl; + bool isExpired(DateTime now, Duration ttl) => + now.difference(fetchedAt) >= ttl; } /// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。 @@ -91,6 +92,7 @@ class FakePlaybackRepository implements PlaybackRepository { accessible: true, accessType: PlayTicket.free, playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc', + qualities: const ['AUTO', '720P', '1080P'], ); } } diff --git a/lib/core/data/repositories/search_repository.dart b/lib/core/data/repositories/search_repository.dart new file mode 100644 index 0000000..c59816e --- /dev/null +++ b/lib/core/data/repositories/search_repository.dart @@ -0,0 +1,26 @@ +// Author: fruit.com.cn + +import '../models/search_result.dart'; +import '../remote/catalog_api.dart'; + +/// 搜索数据来源:只调用后端 `/app/search`,不在客户端做本地过滤。 +abstract interface class SearchRepository { + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }); +} + +class HttpSearchRepository implements SearchRepository { + const HttpSearchRepository(this._api); + + final CatalogApi _api; + + @override + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) => _api.search(keyword, pageIndex: pageIndex, pageSize: pageSize); +} diff --git a/lib/core/data/repositories/store_repository.dart b/lib/core/data/repositories/store_repository.dart new file mode 100644 index 0000000..71cb76b --- /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..e941c13 --- /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/discover/discover_binding.dart b/lib/pages/discover/discover_binding.dart index 89024ce..0f62d70 100644 --- a/lib/pages/discover/discover_binding.dart +++ b/lib/pages/discover/discover_binding.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import 'discover_logic.dart'; @@ -8,5 +9,6 @@ class DiscoverBinding { DiscoverLogic create({ required Drama drama, required WatchHistoryRepository history, - }) => DiscoverLogic(drama: drama, history: history); + SearchRepository? search, + }) => DiscoverLogic(drama: drama, history: history, searchRepository: search); } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index bff295a..55e5972 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -1,20 +1,29 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/watch_progress.dart'; +import '../../core/data/remote/api_exception.dart'; +import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../l10n/app_localizations.dart'; enum DiscoverPageAction { home, my, watchlist, detail, resume } class DiscoverLogic extends ChangeNotifier { - DiscoverLogic({required this.history, required this.drama}) { + DiscoverLogic({ + required this.history, + required this.drama, + this.searchRepository, + }) { refreshPosition(); } final WatchHistoryRepository history; + final SearchRepository? searchRepository; Drama drama; late Future position; WatchProgress? resumeRecord; @@ -22,6 +31,18 @@ class DiscoverLogic extends ChangeNotifier { final FocusNode searchFocus = FocusNode(); final ScrollController scroll = ScrollController(); bool searchOpen = false; + + /// 搜索防抖与分页口径:与后端 `/app/search` 一致。 + Duration searchDebounce = const Duration(milliseconds: 350); + static const int searchPageSize = 20; + List searchResults = const []; + String? selectedLanguage; + int searchTotal = 0; + bool searchLoading = false; + String? searchError; + Timer? _searchTimer; + String _lastRequestedQuery = ''; + int _searchRequest = 0; bool _disposed = false; void notify() { @@ -51,20 +72,114 @@ class DiscoverLogic extends ChangeNotifier { notify(); } - void onSearchChanged() => notify(); + bool get hasSearchQuery => search.text.trim().isNotEmpty; - List filtered(List dramas) { - final query = search.text.trim().toLowerCase(); - if (query.isEmpty) return dramas; - return dramas.where((drama) { - final haystack = [ - drama.title, - drama.genre, - drama.category, - drama.synopsis, - ].join(' ').toLowerCase(); - return haystack.contains(query); - }).toList(); + /// 输入变化只触发后端查询;本地不再做标题/简介匹配。 + void onSearchChanged() { + final query = search.text.trim(); + if (query.isEmpty) { + _resetSearch(); + notify(); + return; + } + notify(); + _scheduleSearch(query); + } + + /// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。 + List visibleDramas(List fallback) { + if (!hasSearchQuery) return _filterByLanguage(fallback); + return _filterByLanguage(searchResults); + } + + /// 当前 feed 中出现过的语种,按首次出现顺序去重。 + List languagesOf(List dramas) { + final languages = []; + for (final drama in dramas) { + final language = drama.language; + if (language.isNotEmpty && !languages.contains(language)) { + languages.add(language); + } + } + return languages; + } + + /// 按语种筛选;null 表示全部。 + void selectLanguage(String? code) { + selectedLanguage = code; + notify(); + } + + List _filterByLanguage(List dramas) { + final code = selectedLanguage; + if (code == null) return dramas; + return dramas.where((drama) => drama.language == code).toList(); + } + + Future retrySearch() async { + final query = search.text.trim(); + if (query.isEmpty) return; + await _runSearch(query); + } + + void _resetSearch() { + _searchTimer?.cancel(); + _searchRequest++; + searchResults = const []; + searchTotal = 0; + searchLoading = false; + searchError = null; + _lastRequestedQuery = ''; + } + + void _scheduleSearch(String query) { + final alreadyRequested = query == _lastRequestedQuery; + if (alreadyRequested && + (searchLoading || searchResults.isNotEmpty || searchError != null)) { + return; + } + _lastRequestedQuery = query; + _searchTimer?.cancel(); + if (searchDebounce == Duration.zero) { + unawaited(_runSearch(query)); + return; + } + _searchTimer = Timer(searchDebounce, () => unawaited(_runSearch(query))); + } + + Future _runSearch(String query) async { + final repository = searchRepository; + if (repository == null) return; + final request = ++_searchRequest; + searchLoading = true; + searchError = null; + notify(); + try { + final result = await repository.search( + query, + pageIndex: 1, + pageSize: searchPageSize, + ); + if (request != _searchRequest) return; + searchResults = result.list; + searchTotal = result.total; + } on ApiException catch (exception) { + _applySearchError(request, exception.message); + } catch (_) { + _applySearchError(request, 'searchFailed'); + } finally { + if (request == _searchRequest) { + searchLoading = false; + notify(); + } + } + } + + void _applySearchError(int request, String message) { + if (request != _searchRequest) return; + searchError = message; + searchResults = const []; + searchTotal = 0; } Drama? resumeDrama(List dramas) { @@ -165,6 +280,7 @@ class DiscoverLogic extends ChangeNotifier { @override void dispose() { _disposed = true; + _searchTimer?.cancel(); search.dispose(); searchFocus.dispose(); scroll.dispose(); diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 059cd19..35f0198 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -8,8 +8,10 @@ import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/home_feed.dart'; import '../../core/data/models/watch_progress.dart'; +import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/category_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'; @@ -30,6 +32,7 @@ class DiscoverPage extends StatefulWidget { required this.duration, this.focusSearch = false, this.history, + this.searchRepository, this.embedded = false, this.active = true, this.searchRequest = 0, @@ -48,6 +51,7 @@ class DiscoverPage extends StatefulWidget { final Duration duration; final bool focusSearch; final WatchHistoryRepository? history; + final SearchRepository? searchRepository; final bool embedded; final bool active; final int searchRequest; @@ -74,6 +78,7 @@ class _DiscoverPageState extends State { logic = DiscoverBinding().create( drama: widget.drama, history: widget.history ?? context.read(), + search: widget.searchRepository ?? context.read(), ); if (widget.focusSearch) { logic.searchOpen = true; @@ -115,7 +120,7 @@ class _DiscoverPageState extends State { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); final allDramas = [...?widget.dramas, ...categories.visibleDramas]; - final visible = logic.filtered(categories.visibleDramas); + final visible = logic.visibleDramas(categories.visibleDramas); final content = SafeArea( bottom: !widget.embedded, child: Column( @@ -171,6 +176,12 @@ class _DiscoverPageState extends State { child: _categories(l10n, categories), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 12), + child: _languages(l10n, allDramas), + ), + ), if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) SliverToBoxAdapter( child: Padding( @@ -235,7 +246,10 @@ class _DiscoverPageState extends State { l10n: l10n, categories: categories, dramas: visible, - searching: logic.search.text.trim().isNotEmpty, + searching: logic.hasSearchQuery, + searchLoading: logic.searchLoading, + searchError: logic.searchError, + onRetrySearch: () => logic.retrySearch(), onOpen: (drama) => logic.openCard( context, drama, @@ -329,6 +343,7 @@ class _DiscoverPageState extends State { controller: logic.search, focusNode: logic.searchFocus, onChanged: (_) => logic.onSearchChanged(), + onTapOutside: (_) => logic.searchFocus.unfocus(), textInputAction: TextInputAction.search, style: const TextStyle(color: AppColors.onSurface, fontSize: 13), @@ -343,6 +358,12 @@ class _DiscoverPageState extends State { color: AppColors.subtle, size: 17, ), + suffixIcon: logic.searchLoading + ? const Padding( + padding: EdgeInsets.all(11), + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(20), @@ -412,6 +433,70 @@ class _DiscoverPageState extends State { ); } + Widget _languages(AppLocalizations l10n, List dramas) { + final languages = logic.languagesOf(dramas); + if (languages.isEmpty) return const SizedBox.shrink(); + final selected = logic.selectedLanguage; + return SizedBox( + key: const Key('discover-language-chips'), + height: AppDimens.tapTarget, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 14), + scrollDirection: Axis.horizontal, + children: [ + _languageChip( + key: const Key('discover-language-all'), + label: l10n.all, + selected: selected == null, + onTap: () => logic.selectLanguage(null), + ), + for (final language in languages) + _languageChip( + key: Key('discover-language-$language'), + label: _languageLabel(l10n, language), + selected: selected == language, + onTap: () => logic.selectLanguage(language), + ), + ], + ), + ); + } + + Widget _languageChip({ + required Key key, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Center( + child: ChoiceChip( + key: key, + label: Text(label), + selected: selected, + showCheckmark: false, + onSelected: (_) => onTap(), + selectedColor: AppColors.blue, + backgroundColor: const Color(0xFF29272B), + side: BorderSide.none, + labelStyle: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: AppDimens.chipFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ); + } + + String _languageLabel(AppLocalizations l10n, String code) { + if (code == 'zh-CN' || code == 'zh') return l10n.languageChinese; + if (code == 'id') return l10n.languageIndonesian; + if (code == 'en') return l10n.languageEnglish; + return code; + } + Widget _continueCard( AppLocalizations l10n, Drama drama, diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index d2bd04c..f1ca78a 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -8,6 +8,9 @@ class _HomeDramaGrid extends StatelessWidget { required this.categories, required this.dramas, required this.searching, + required this.searchLoading, + required this.searchError, + required this.onRetrySearch, required this.onOpen, }); @@ -15,49 +18,85 @@ class _HomeDramaGrid extends StatelessWidget { final CategoryController categories; final List dramas; final bool searching; + final bool searchLoading; + final String? searchError; + final VoidCallback onRetrySearch; final ValueChanged onOpen; @override Widget build(BuildContext context) { + final loading = _loadingState(); + if (loading != null) return loading; + final search = _searchState(); + if (search != null) return search; + if (dramas.isEmpty) return _emptyState(); + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter(child: _masonry(dramas)), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + } + + Widget? _loadingState() { if (categories.loading && categories.feed.sections.isEmpty) { return const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } - if (categories.error != null) { - return _fill( - EmptyState( - key: const Key('home-categories-error'), - icon: Icons.cloud_off_rounded, - title: l10n.categoriesError, - subtitle: l10n.emptyHint, - actionLabel: l10n.retry, - onAction: categories.load, + if (categories.error == null) return null; + return _fill( + EmptyState( + key: const Key('home-categories-error'), + icon: Icons.cloud_off_rounded, + title: l10n.categoriesError, + subtitle: l10n.emptyHint, + actionLabel: l10n.retry, + onAction: categories.load, + ), + ); + } + + Widget? _searchState() { + if (searching && searchLoading) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: CircularProgressIndicator( + key: Key('discover-search-loading'), + strokeWidth: 2, + ), ), ); } - if (dramas.isEmpty) { - return _fill( - EmptyState( - key: const Key('home-all-dramas-empty'), - icon: searching - ? Icons.search_off_rounded - : Icons.movie_filter_outlined, - title: searching - ? l10n.noSearchResults - : categories.selectedSectionMissing - ? l10n.noCategoryDramas - : l10n.noSearchResults, - subtitle: l10n.emptyHint, - ), - ); - } - return SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter(child: _masonry(dramas)), - const SliverToBoxAdapter(child: SizedBox(height: 24)), - ], + if (!searching || searchError == null) return null; + return _fill( + EmptyState( + key: const Key('discover-search-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, searchError, l10n.searchFailed), + actionLabel: l10n.retry, + actionKey: const Key('discover-search-retry'), + onAction: onRetrySearch, + ), + ); + } + + Widget _emptyState() { + return _fill( + EmptyState( + key: const Key('home-all-dramas-empty'), + icon: searching + ? Icons.search_off_rounded + : Icons.movie_filter_outlined, + title: searching + ? l10n.noSearchResults + : categories.selectedSectionMissing + ? l10n.noCategoryDramas + : l10n.noSearchResults, + subtitle: l10n.emptyHint, + ), ); } 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/player/player_logic.dart b/lib/pages/player/player_logic.dart index 910aff0..f7f8ed9 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -72,6 +72,7 @@ class PlayerLogic extends ChangeNotifier { PlayTicket? blockedTicket; String? blockedEpisodeId; List sessionEpisodes = const []; + List qualities = const []; int pageRequest = 0; bool muted = false; double playbackSpeed = 1.0; @@ -217,6 +218,7 @@ class PlayerLogic extends ChangeNotifier { if (localPath != null) { blockedTicket = null; blockedEpisodeId = null; + qualities = const []; sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath)); return true; } @@ -227,6 +229,7 @@ class PlayerLogic extends ChangeNotifier { if (request != pageRequest) return false; blockedTicket = null; blockedEpisodeId = null; + qualities = ticket.qualities; if (ticket.accessible && ticket.playUrl.isNotEmpty) { // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl); @@ -280,10 +283,7 @@ class PlayerLogic extends ChangeNotifier { } final ready = await ensureTicket(index, forceRefresh: true); if (ready) { - await session.select( - index, - preloadIndex: nextVisibleIndex(index), - ); + await session.select(index, preloadIndex: nextVisibleIndex(index)); } return ready; } finally { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 0528611..986f88c 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -28,6 +28,7 @@ import 'player_logic.dart'; import 'widgets/home_feed_overlay.dart'; import 'widgets/exit_clear_screen_button.dart'; import 'widgets/player_center_toggle.dart'; +import 'widgets/quality_sheet.dart'; import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; @@ -402,6 +403,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, onSpeedTap: () => unawaited(_openSpeedSheet(context)), + qualities: logic.qualities, + onQualityTap: () => unawaited(_openQualitySheet(context)), controlsVisible: _chromeVisible, onClear: () => setState(() => _chromeVisible = false), onExitClear: () => setState(() => _chromeVisible = true), @@ -500,6 +503,15 @@ class _PlayerPageState extends State with WidgetsBindingObserver { await logic.setPlaybackSpeed(selected); } + Future _openQualitySheet(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (_) => QualitySheet(qualities: logic.qualities), + ); + } + Future _unlockEpisode(BuildContext context, int index) async { if (!await ensureSignedIn(context)) return; if (!context.mounted) return; @@ -693,6 +705,8 @@ class _PlaybackOverlay extends StatefulWidget { required this.playing, required this.playbackSpeed, required this.onSpeedTap, + required this.qualities, + required this.onQualityTap, required this.controlsVisible, required this.onTogglePlay, required this.onClear, @@ -720,6 +734,8 @@ class _PlaybackOverlay extends StatefulWidget { final bool playing; final double playbackSpeed; final VoidCallback onSpeedTap; + final List qualities; + final VoidCallback onQualityTap; final bool controlsVisible; final VoidCallback onTogglePlay; final VoidCallback onClear; @@ -953,6 +969,29 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { }); }, ), + if (widget.qualities.isNotEmpty) + ListTile( + key: const Key('player-more-quality'), + leading: const Icon( + Icons.high_quality_rounded, + color: AppColors.secondary, + ), + title: Text(widget.l10n.playbackQuality), + trailing: Text( + widget.qualities.contains(QualitySheet.autoQuality) + ? widget.l10n.qualityAuto + : widget.qualities.first, + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + widget.onQualityTap(); + }, + ), ListTile( key: const Key('player-more-download'), leading: const Icon( diff --git a/lib/pages/player/widgets/quality_sheet.dart b/lib/pages/player/widgets/quality_sheet.dart new file mode 100644 index 0000000..fcb6c50 --- /dev/null +++ b/lib/pages/player/widgets/quality_sheet.dart @@ -0,0 +1,85 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 清晰度档位面板:展示后端下发档位;HLS 多码率由播放器自动切换。 +class QualitySheet extends StatelessWidget { + const QualitySheet({ + super.key, + required this.qualities, + this.selected = autoQuality, + }); + + static const String autoQuality = 'AUTO'; + + final List qualities; + final String selected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SafeArea( + child: Column( + key: const Key('player-quality-sheet'), + mainAxisSize: MainAxisSize.min, + children: [ + _header(l10n), + for (final quality in qualities) _qualityTile(l10n, quality), + _hint(l10n), + ], + ), + ); + } + + Widget _header(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.playbackQuality, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + Widget _qualityTile(AppLocalizations l10n, String quality) { + final active = quality == selected; + return ListTile( + key: Key('player-quality-$quality'), + dense: true, + title: Text( + _label(l10n, quality), + style: TextStyle( + color: active ? AppColors.blue : AppColors.cream, + fontSize: 13, + fontWeight: active ? FontWeight.w700 : FontWeight.w500, + ), + ), + trailing: active + ? const Icon(Icons.check_rounded, color: AppColors.blue, size: 18) + : null, + ); + } + + Widget _hint(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 14), + child: Text( + l10n.qualityAutoHint, + style: const TextStyle(color: AppColors.muted, fontSize: 11), + ), + ); + } + + String _label(AppLocalizations l10n, String quality) => + quality == autoQuality ? l10n.qualityAuto : quality; +} diff --git a/lib/pages/store/recharge_page.dart b/lib/pages/store/recharge_page.dart new file mode 100644 index 0000000..50645d7 --- /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..e5501bd --- /dev/null +++ b/lib/pages/store/store_page.dart @@ -0,0 +1,275 @@ +// 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: _info(l10n)), + const SizedBox(width: 10), + _price(l10n), + ], + ), + ); + } + + Widget _info(AppLocalizations l10n) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + key: Key('store-product-name-${product.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + _detail(l10n), + style: const TextStyle(color: AppColors.muted, fontSize: 11), + ), + ], + ); + } + + Widget _price(AppLocalizations l10n) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + product.displayPrice, + key: Key('store-product-price-${product.id}'), + style: const TextStyle( + color: AppColors.gold, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: 88, + height: 32, + child: FilledButton( + key: Key('store-buy-${product.id}'), + onPressed: purchasing ? null : onBuy, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: purchasing ? _buyProgress() : Text(l10n.buy), + ), + ), + ], + ); + } + + Widget _buyProgress() { + return const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.onPrimary, + ), + ); + } + + String _detail(AppLocalizations l10n) { + if (product.isVip) return l10n.vipDuration(product.vipDays); + final base = l10n.walletBalanceShort(product.coinAmount); + if (product.bonusCoin <= 0) return base; + return '$base · ${l10n.bonusCoin(product.bonusCoin)}'; + } +} + +class _ProductBadge extends StatelessWidget { + const _ProductBadge({required this.vip}); + + final bool vip; + + @override + Widget build(BuildContext context) { + return Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: AppColors.goldCard, + borderRadius: BorderRadius.circular(13), + ), + child: Icon( + vip ? Icons.workspace_premium_rounded : Icons.monetization_on_rounded, + color: AppColors.gold, + size: AppDimens.sectionTitle, + ), + ); + } +} diff --git a/lib/pages/store/vip_page.dart b/lib/pages/store/vip_page.dart new file mode 100644 index 0000000..4df4e10 --- /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..b9361de 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,52 @@ 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 +125,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: [ @@ -269,7 +319,9 @@ class _LedgerTile extends StatelessWidget { Widget build(BuildContext context) { final positive = entry.changeAmount >= 0; final color = positive ? AppColors.success : AppColors.accent; - final amount = positive ? '+${entry.changeAmount}' : '${entry.changeAmount}'; + final amount = positive + ? '+${entry.changeAmount}' + : '${entry.changeAmount}'; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( diff --git a/test/catalog/drama_model_test.dart b/test/catalog/drama_model_test.dart index 7b715cb..bb99b81 100644 --- a/test/catalog/drama_model_test.dart +++ b/test/catalog/drama_model_test.dart @@ -13,6 +13,7 @@ void main() { 'totalEpisodes': 77, 'isHot': true, 'viewCount': 3100000, + 'language': 'zh-CN', 'preview': { 'id': 9, 'number': 1, @@ -27,6 +28,7 @@ void main() { expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4'); expect(drama.episodeCount, 77); expect(drama.isHot, isTrue); + expect(drama.language, 'zh-CN'); }); test('should_have_no_episodes_when_list_card_has_no_playable_preview', () { diff --git a/test/catalog/search_api_test.dart b/test/catalog/search_api_test.dart new file mode 100644 index 0000000..e9ff252 --- /dev/null +++ b/test/catalog/search_api_test.dart @@ -0,0 +1,54 @@ +// 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/catalog_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test( + 'should_request_search_with_keyword_and_paging_when_searching', + () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'total': 1, + 'list': [ + { + 'id': '2111012753262120962', + 'title': 'Velvet Nights', + 'synopsis': 'A city romance', + 'cover': '/files/velvet.png', + 'genre': 'Romance', + 'totalEpisodes': 12, + 'isHot': true, + 'language': 'id', + }, + ], + }, + }), + ), + ); + final api = CatalogApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.search('velvet'); + + final request = adapter.requests.single; + expect(request.uri.path, '/app/search'); + expect(request.queryParameters['keyword'], 'velvet'); + expect(request.queryParameters['pageIndex'], '1'); + expect(request.queryParameters['pageSize'], '20'); + expect(page.total, 1); + expect(page.list.single.title, 'Velvet Nights'); + expect(page.list.single.language, 'id'); + }, + ); +} diff --git a/test/commerce/store_api_test.dart b/test/commerce/store_api_test.dart new file mode 100644 index 0000000..7e623c6 --- /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..726fb9d --- /dev/null +++ b/test/commerce/store_controller_test.dart @@ -0,0 +1,104 @@ +// 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/discover/discover_language_filter_test.dart b/test/discover/discover_language_filter_test.dart new file mode 100644 index 0000000..003d3db --- /dev/null +++ b/test/discover/discover_language_filter_test.dart @@ -0,0 +1,90 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/category_repository.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_filter_theater_grid_when_language_chip_is_tapped', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: FakeCatalogRepository(), + categoryRepository: _MixedLanguageCategoryRepository(), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget); + expect(find.byKey(const Key('home-drama-card-id-demo')), findsOneWidget); + + await tester.tap(find.byKey(const Key('discover-language-zh-CN'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget); + expect(find.byKey(const Key('home-drama-card-id-demo')), findsNothing); + }); +} + +class _MixedLanguageCategoryRepository implements CategoryRepository { + @override + Future> fetchCategories() async => const []; + + @override + Future fetchDiscoverFeed() async => HomeFeed( + sections: [ + FeedSection( + code: 'hot', + title: 'Hot', + dramas: [ + _drama('zh-demo', 'Chinese Demo', 'zh-CN'), + _drama('id-demo', 'Indonesian Demo', 'id'), + ], + ), + ], + ); +} + +Drama _drama(String id, String title, String language) => Drama( + id: id, + title: title, + synopsis: '$title synopsis', + posterUrl: '/files/$id.png', + language: language, + detailLoaded: true, +); diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart index 55fc790..08c02fa 100644 --- a/test/discover/discover_logic_test.dart +++ b/test/discover/discover_logic_test.dart @@ -8,7 +8,12 @@ import 'package:fruitshorts/pages/discover/discover_logic.dart'; import '../support/fake_watch_history.dart'; -Drama _drama(String id, {List numbers = const [1, 2, 3]}) => Drama( +Drama _drama( + String id, { + List numbers = const [1, 2, 3], + String language = '', +}) => Drama( + language: language, id: id, title: id, synopsis: 'synopsis', @@ -28,15 +33,18 @@ Drama _drama(String id, {List numbers = const [1, 2, 3]}) => Drama( void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('should_resolve_resume_position_of_episode_one_when_constructed', () async { - final history = FakeWatchHistory()..saved = const Duration(seconds: 4); - final logic = DiscoverLogic(history: history, drama: _drama('signal')); + test( + 'should_resolve_resume_position_of_episode_one_when_constructed', + () async { + final history = FakeWatchHistory()..saved = const Duration(seconds: 4); + final logic = DiscoverLogic(history: history, drama: _drama('signal')); - expect(await logic.position, const Duration(seconds: 4)); - expect(history.reads, ['signal']); - expect(logic.searchOpen, isFalse); - logic.dispose(); - }); + expect(await logic.position, const Duration(seconds: 4)); + expect(history.reads, ['signal']); + expect(logic.searchOpen, isFalse); + logic.dispose(); + }, + ); test('should_reread_resume_position_when_drama_is_swapped', () async { final history = FakeWatchHistory(); @@ -50,20 +58,59 @@ void main() { logic.dispose(); }); - test('should_resume_from_last_watch_when_list_card_has_no_episodes', () async { - final history = FakeWatchHistory() - ..saved = const Duration(seconds: 9) - ..lastEpisode = 'signal-2'; - final logic = DiscoverLogic( - history: history, - drama: _drama('signal', numbers: const []), - ); + test( + 'should_resume_from_last_watch_when_list_card_has_no_episodes', + () async { + final history = FakeWatchHistory() + ..saved = const Duration(seconds: 9) + ..lastEpisode = 'signal-2'; + final logic = DiscoverLogic( + history: history, + drama: _drama('signal', numbers: const []), + ); - expect(await logic.position, const Duration(seconds: 9)); - expect(history.reads, ['signal']); + expect(await logic.position, const Duration(seconds: 9)); + expect(history.reads, ['signal']); + logic.dispose(); + }, + ); + + test('should_filter_visible_dramas_when_language_is_selected', () { + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); + final dramas = [ + _drama('zh', language: 'zh-CN'), + _drama('id', language: 'id'), + _drama('unknown'), + ]; + + expect(logic.visibleDramas(dramas), hasLength(3)); + + logic.selectLanguage('id'); + expect(logic.visibleDramas(dramas).map((item) => item.id), ['id']); + + logic.selectLanguage(null); + expect(logic.visibleDramas(dramas), hasLength(3)); logic.dispose(); }); + test('should_list_distinct_languages_when_feed_has_cards', () { + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); + final dramas = [ + _drama('zh', language: 'zh-CN'), + _drama('id', language: 'id'), + _drama('id2', language: 'id'), + _drama('unknown'), + ]; + + expect(logic.languagesOf(dramas), ['zh-CN', 'id']); + logic.dispose(); + }); test('should_have_no_resume_position_when_server_has_no_record', () async { final history = FakeWatchHistory(); final logic = DiscoverLogic( @@ -78,7 +125,10 @@ void main() { }); test('should_show_search_when_flag_is_on_or_text_is_typed', () { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); expect(logic.showSearch, isFalse); @@ -95,7 +145,10 @@ void main() { }); test('should_match_by_id_and_tolerate_missing_id_when_looking_up_drama', () { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); final catalog = [_drama('signal'), _drama('city')]; expect(logic.dramaFor('city', catalog)?.id, 'city'); @@ -136,7 +189,12 @@ void main() { // Staying active with a changed drama re-reads history. history.reads.clear(); - sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true); + sync( + _drama('vengeance'), + active: true, + wasActive: true, + dramaChanged: true, + ); expect(logic.drama.id, 'vengeance'); expect(history.reads, ['vengeance']); @@ -148,43 +206,50 @@ void main() { logic.dispose(); }); - testWidgets('should_open_and_focus_field_when_search_is_requested_and_page_is_active', ( - tester, - ) async { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: TextField( - controller: logic.search, - focusNode: logic.searchFocus, + testWidgets( + 'should_open_and_focus_field_when_search_is_requested_and_page_is_active', + (tester) async { + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + controller: logic.search, + focusNode: logic.searchFocus, + ), ), ), - ), - ); + ); - logic.syncDrama( - active: true, - wasActive: true, - nextDrama: logic.drama, - dramaChanged: false, - searchRequest: 1, - previousSearchRequest: 0, - isActive: () => true, - ); + logic.syncDrama( + active: true, + wasActive: true, + nextDrama: logic.drama, + dramaChanged: false, + searchRequest: 1, + previousSearchRequest: 0, + isActive: () => true, + ); - expect(logic.searchOpen, isTrue); - await tester.pumpAndSettle(); - expect(logic.searchFocus.hasFocus, isTrue); + expect(logic.searchOpen, isTrue); + await tester.pumpAndSettle(); + expect(logic.searchFocus.hasFocus, isTrue); - await tester.pumpWidget(const SizedBox.shrink()); - logic.dispose(); - }); + await tester.pumpWidget(const SizedBox.shrink()); + logic.dispose(); + }, + ); testWidgets('should_keep_search_field_closed_when_page_is_hidden', ( tester, ) async { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -219,7 +284,10 @@ void main() { testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', ( tester, ) async { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); final forwarded = []; final popped = Completer(); late BuildContext pageContext; @@ -266,7 +334,10 @@ void main() { testWidgets('should_show_artwork_notice_when_card_has_no_playable_media', ( tester, ) async { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); late BuildContext context; await tester.pumpWidget( @@ -291,8 +362,13 @@ void main() { logic.dispose(); }); - testWidgets('should_forward_chosen_drama_when_card_is_embedded', (tester) async { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + testWidgets('should_forward_chosen_drama_when_card_is_embedded', ( + tester, + ) async { + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); late BuildContext context; Drama? selected; @@ -319,7 +395,10 @@ void main() { }); test('should_do_nothing_when_notifying_after_dispose', () { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); var notifications = 0; logic.addListener(() => notifications++); diff --git a/test/discover/discover_search_test.dart b/test/discover/discover_search_test.dart new file mode 100644 index 0000000..0ddd7e1 --- /dev/null +++ b/test/discover/discover_search_test.dart @@ -0,0 +1,121 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/search_result.dart'; +import 'package:fruitshorts/core/data/remote/api_exception.dart'; +import 'package:fruitshorts/core/data/repositories/search_repository.dart'; +import 'package:fruitshorts/pages/discover/discover_logic.dart'; + +import '../support/fake_watch_history.dart'; + +Drama _drama(String id, {String language = 'id'}) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/$id.png', + language: language, + episodes: const [], +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'should_query_backend_and_render_results_when_search_text_changes', + () async { + final repository = _FakeSearchRepository(results: [_drama('velvet')]); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: repository, + )..searchDebounce = Duration.zero; + + logic.search.text = 'velvet'; + logic.onSearchChanged(); + await pumpEventQueue(); + + expect(repository.calls, [ + (keyword: 'velvet', pageIndex: 1, pageSize: 20), + ]); + expect(logic.searchLoading, isFalse); + expect(logic.visibleDramas(const []).single.id, 'velvet'); + logic.dispose(); + }, + ); + + test('should_keep_server_results_when_text_only_loses_whitespace', () async { + final repository = _FakeSearchRepository(results: [_drama('velvet')]); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: repository, + )..searchDebounce = Duration.zero; + + logic.search.text = 'velvet '; + logic.onSearchChanged(); + await pumpEventQueue(); + logic.search.text = 'velvet'; + logic.onSearchChanged(); + await pumpEventQueue(); + + expect(repository.calls, hasLength(1)); + expect(logic.visibleDramas(const []).single.id, 'velvet'); + logic.dispose(); + }); + + test('should_expose_error_message_when_search_fails', () async { + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: _FakeSearchRepository( + error: const ApiException(400, 'Keyword is invalid'), + ), + )..searchDebounce = Duration.zero; + + logic.search.text = '%%%'; + logic.onSearchChanged(); + await pumpEventQueue(); + + expect(logic.searchError, 'Keyword is invalid'); + expect(logic.searchLoading, isFalse); + logic.dispose(); + }); + + test('should_clear_server_results_when_search_text_is_empty', () async { + final repository = _FakeSearchRepository(results: [_drama('velvet')]); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: repository, + )..searchDebounce = Duration.zero; + + logic.search.text = 'velvet'; + logic.onSearchChanged(); + await pumpEventQueue(); + logic.search.clear(); + logic.onSearchChanged(); + + expect(logic.visibleDramas([_drama('local')]).single.id, 'local'); + logic.dispose(); + }); +} + +class _FakeSearchRepository implements SearchRepository { + _FakeSearchRepository({this.results = const [], this.error}); + + final List results; + final ApiException? error; + final List<({String keyword, int pageIndex, int pageSize})> calls = []; + + @override + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) async { + calls.add((keyword: keyword, pageIndex: pageIndex, pageSize: pageSize)); + if (error != null) throw error!; + return SearchResult(total: results.length, list: results); + } +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index de51edf..e515fc5 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -428,6 +428,7 @@ Future _pumpApp( LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, + searchRepository: catalog, categoryRepository: categoryRepository, watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(), diff --git a/test/playback/play_ticket_quality_test.dart b/test/playback/play_ticket_quality_test.dart new file mode 100644 index 0000000..809a7b5 --- /dev/null +++ b/test/playback/play_ticket_quality_test.dart @@ -0,0 +1,29 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; + +void main() { + test('should_parse_all_qualities_when_ticket_returns_multiple_tracks', () { + final ticket = PlayTicket.fromJson(const { + 'episodeId': '12', + 'accessible': true, + 'accessType': 'FREE', + 'playUrl': '/files/episode/12.m3u8?e=1&s=abc', + 'qualities': ['AUTO', '720P', '1080P'], + }); + + expect(ticket.qualities, ['AUTO', '720P', '1080P']); + }); + + test('should_default_to_empty_qualities_when_field_is_missing', () { + final ticket = PlayTicket.fromJson(const { + 'episodeId': '12', + 'accessible': true, + 'accessType': 'FREE', + 'playUrl': '/files/episode/12.mp4', + }); + + expect(ticket.qualities, isEmpty); + }); +} diff --git a/test/playback/player_quality_test.dart b/test/playback/player_quality_test.dart new file mode 100644 index 0000000..b470753 --- /dev/null +++ b/test/playback/player_quality_test.dart @@ -0,0 +1,39 @@ +// Author: fruit.com.cn + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; +import 'package:fruitshorts/pages/player/player_logic.dart'; + +import '../support/fake_watch_history.dart'; + +void main() { + test('should_expose_ticket_qualities_when_ticket_is_loaded', () async { + final logic = PlayerLogic( + history: FakeWatchHistory(), + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: '12', + accessible: true, + accessType: PlayTicket.free, + playUrl: '/files/episode/12.m3u8?e=1&s=abc', + qualities: ['AUTO', '720P', '1080P'], + ), + ), + pictureInPicture: AndroidPictureInPicture( + channel: const MethodChannel('test/player_quality/pip'), + ), + ); + logic.sessionEpisodes = [ + Episode(id: '12', number: 1, title: 'Episode 1', isFree: true), + ]; + + final ready = await logic.ensureTicket(0); + + expect(ready, isTrue); + expect(logic.qualities, ['AUTO', '720P', '1080P']); + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 45fc167..b5ecb15 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -38,8 +38,15 @@ void main() { expect(find.byType(PageView), findsOneWidget); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-more-quality')), findsOneWidget); expect(find.byKey(const Key('player-more-download')), findsOneWidget); expect(find.byKey(const Key('player-more-pip')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more-quality'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-quality-sheet')), findsOneWidget); + expect(find.text('720P'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); diff --git a/test/player/quality_sheet_test.dart b/test/player/quality_sheet_test.dart new file mode 100644 index 0000000..8ff9f6f --- /dev/null +++ b/test/player/quality_sheet_test.dart @@ -0,0 +1,32 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/quality_sheet.dart'; + +void main() { + testWidgets('should_list_every_returned_quality_when_sheet_opens', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: QualitySheet(qualities: ['AUTO', '720P', '1080P']), + ), + ), + ); + + expect(find.text('Auto'), findsOneWidget); + expect(find.text('720P'), findsOneWidget); + expect(find.text('1080P'), findsOneWidget); + expect(find.byIcon(Icons.check_rounded), findsOneWidget); + expect( + find.text('HLS adjusts the bitrate automatically for your connection.'), + findsOneWidget, + ); + }); +} diff --git a/test/store/store_pages_test.dart b/test/store/store_pages_test.dart new file mode 100644 index 0000000..de4fe9e --- /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, + ); + } +} diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 7c41eed..4b49b52 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -1,9 +1,11 @@ import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/search_result.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/search_repository.dart'; /// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。 -class FakeCatalogRepository implements CatalogRepository { +class FakeCatalogRepository implements CatalogRepository, SearchRepository { FakeCatalogRepository({List? dramas, this.banners = const []}) : dramas = dramas ?? demoDramas; @@ -21,6 +23,20 @@ class FakeCatalogRepository implements CatalogRepository { return dramas.firstWhere((drama) => drama.id == dramaId); } + @override + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) async { + final query = keyword.trim().toLowerCase(); + final hits = dramas.where((drama) { + final haystack = [drama.title, drama.synopsis, drama.genre].join(' '); + return haystack.toLowerCase().contains(query); + }).toList(); + return SearchResult(total: hits.length, list: hits); + } + HomeFeed _feed() => HomeFeed( banners: banners, sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)], @@ -45,6 +61,7 @@ Drama _drama(String id, String title, String genre, {required int format}) => synopsis: '$title synopsis', posterUrl: '/files/$id.png', genre: genre, + language: 'id', detailLoaded: true, episodes: [ for (var index = 0; index < 7; index++)