merge: 第二轮 App 接口对接
This commit is contained in:
commit
7ca7d20630
45 files changed
+2365
-110
No files matched your search
+16
-1
@@ -30,6 +30,8 @@ import 'core/data/repositories/favorite_repository.dart';
|
|||||||
import 'core/data/repositories/interaction_repository.dart';
|
import 'core/data/repositories/interaction_repository.dart';
|
||||||
import 'core/data/repositories/notice_repository.dart';
|
import 'core/data/repositories/notice_repository.dart';
|
||||||
import 'core/data/repositories/playback_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/repositories/watch_history_repository.dart';
|
||||||
import 'core/data/services/auth_controller.dart';
|
import 'core/data/services/auth_controller.dart';
|
||||||
import 'core/data/services/auth_session_store.dart';
|
import 'core/data/services/auth_session_store.dart';
|
||||||
@@ -62,6 +64,8 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
this.noticeRepository,
|
this.noticeRepository,
|
||||||
this.accountRepository,
|
this.accountRepository,
|
||||||
this.commerceRepository,
|
this.commerceRepository,
|
||||||
|
this.storeRepository,
|
||||||
|
this.searchRepository,
|
||||||
this.haptics = const FlutterHaptics(),
|
this.haptics = const FlutterHaptics(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,6 +83,8 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
final NoticeRepository? noticeRepository;
|
final NoticeRepository? noticeRepository;
|
||||||
final AccountRepository? accountRepository;
|
final AccountRepository? accountRepository;
|
||||||
final CommerceRepository? commerceRepository;
|
final CommerceRepository? commerceRepository;
|
||||||
|
final StoreRepository? storeRepository;
|
||||||
|
final SearchRepository? searchRepository;
|
||||||
final Haptics haptics;
|
final Haptics haptics;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -140,12 +146,19 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
late final CommerceRepository _commerceRepository =
|
late final CommerceRepository _commerceRepository =
|
||||||
widget.commerceRepository ?? HttpCommerceRepository(CommerceApi(_apiClient));
|
widget.commerceRepository ??
|
||||||
|
HttpCommerceRepository(CommerceApi(_apiClient));
|
||||||
|
|
||||||
late final WalletController _walletController = WalletController(
|
late final WalletController _walletController = WalletController(
|
||||||
_commerceRepository,
|
_commerceRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
late final SearchRepository _searchRepository =
|
||||||
|
widget.searchRepository ?? HttpSearchRepository(CatalogApi(_apiClient));
|
||||||
|
|
||||||
|
late final StoreRepository _storeRepository =
|
||||||
|
widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient));
|
||||||
|
|
||||||
late final CommentRepository _commentRepository =
|
late final CommentRepository _commentRepository =
|
||||||
widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient));
|
widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient));
|
||||||
|
|
||||||
@@ -216,6 +229,8 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
Provider<NoticeRepository>.value(value: _noticeRepository),
|
Provider<NoticeRepository>.value(value: _noticeRepository),
|
||||||
Provider<AccountRepository>.value(value: _accountRepository),
|
Provider<AccountRepository>.value(value: _accountRepository),
|
||||||
Provider<CommerceRepository>.value(value: _commerceRepository),
|
Provider<CommerceRepository>.value(value: _commerceRepository),
|
||||||
|
Provider<StoreRepository>.value(value: _storeRepository),
|
||||||
|
Provider<SearchRepository>.value(value: _searchRepository),
|
||||||
Provider<Haptics>.value(value: widget.haptics),
|
Provider<Haptics>.value(value: widget.haptics),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => CatalogController(_catalogRepository)..load(),
|
create: (_) => CatalogController(_catalogRepository)..load(),
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class Drama {
|
|||||||
this.freeEpisodes = 0,
|
this.freeEpisodes = 0,
|
||||||
this.isHot = false,
|
this.isHot = false,
|
||||||
this.viewCount = 0,
|
this.viewCount = 0,
|
||||||
|
this.language = '',
|
||||||
this.detailLoaded = false,
|
this.detailLoaded = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +81,9 @@ class Drama {
|
|||||||
final bool isHot;
|
final bool isHot;
|
||||||
final int viewCount;
|
final int viewCount;
|
||||||
|
|
||||||
|
/// 卡片语种(如 zh-CN / id),用于剧场语言筛选。
|
||||||
|
final String language;
|
||||||
|
|
||||||
/// 详情接口是否已经取过完整剧集列表。
|
/// 详情接口是否已经取过完整剧集列表。
|
||||||
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
|
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
|
||||||
final bool detailLoaded;
|
final bool detailLoaded;
|
||||||
@@ -104,6 +108,7 @@ class Drama {
|
|||||||
isHot: json['isHot'] == true,
|
isHot: json['isHot'] == true,
|
||||||
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
|
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
|
||||||
viewCount: jsonToInt(json['viewCount']),
|
viewCount: jsonToInt(json['viewCount']),
|
||||||
|
language: json['language'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +125,7 @@ class Drama {
|
|||||||
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
||||||
isHot: json['isHot'] == true,
|
isHot: json['isHot'] == true,
|
||||||
viewCount: jsonToInt(json['viewCount']),
|
viewCount: jsonToInt(json['viewCount']),
|
||||||
|
language: json['language'] as String? ?? '',
|
||||||
detailLoaded: true,
|
detailLoaded: true,
|
||||||
episodes: (json['episodes'] as List? ?? const [])
|
episodes: (json['episodes'] as List? ?? const [])
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 充值订单:`POST /app/orders` 创建的 `CREATED` 订单,等待支付。
|
||||||
|
class AppOrder {
|
||||||
|
const AppOrder({
|
||||||
|
required this.id,
|
||||||
|
required this.orderNo,
|
||||||
|
required this.productName,
|
||||||
|
required this.coinAmount,
|
||||||
|
required this.status,
|
||||||
|
this.productId = '',
|
||||||
|
this.type = '',
|
||||||
|
this.amount = '',
|
||||||
|
this.currency = '',
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const String created = 'CREATED';
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String orderNo;
|
||||||
|
final String productId;
|
||||||
|
final String productName;
|
||||||
|
final String type;
|
||||||
|
final int coinAmount;
|
||||||
|
final String amount;
|
||||||
|
final String currency;
|
||||||
|
final String status;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
bool get isCreated => status == created;
|
||||||
|
|
||||||
|
factory AppOrder.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AppOrder(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
orderNo: json['orderNo'] as String? ?? '',
|
||||||
|
productId: (json['productId'] ?? '').toString(),
|
||||||
|
productName: json['productName'] as String? ?? '',
|
||||||
|
type: json['type'] as String? ?? '',
|
||||||
|
coinAmount: jsonToInt(json['coinAmount']),
|
||||||
|
amount: (json['amount'] ?? '').toString(),
|
||||||
|
currency: json['currency'] as String? ?? '',
|
||||||
|
status: json['status'] as String? ?? '',
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ class PlayTicket {
|
|||||||
this.durationSec = 0,
|
this.durationSec = 0,
|
||||||
this.unlockCoin = 0,
|
this.unlockCoin = 0,
|
||||||
this.previewSeconds = 0,
|
this.previewSeconds = 0,
|
||||||
|
this.qualities = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
static const String free = 'FREE';
|
static const String free = 'FREE';
|
||||||
@@ -35,6 +36,9 @@ class PlayTicket {
|
|||||||
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
|
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
|
||||||
final int previewSeconds;
|
final int previewSeconds;
|
||||||
|
|
||||||
|
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。
|
||||||
|
final List<String> qualities;
|
||||||
|
|
||||||
bool get needsUnlock =>
|
bool get needsUnlock =>
|
||||||
accessType == coinRequired || accessType == vipRequired;
|
accessType == coinRequired || accessType == vipRequired;
|
||||||
|
|
||||||
@@ -50,6 +54,10 @@ class PlayTicket {
|
|||||||
durationSec: jsonToInt(json['durationSec']),
|
durationSec: jsonToInt(json['durationSec']),
|
||||||
unlockCoin: jsonToInt(json['unlockCoin']),
|
unlockCoin: jsonToInt(json['unlockCoin']),
|
||||||
previewSeconds: jsonToInt(json['previewSeconds']),
|
previewSeconds: jsonToInt(json['previewSeconds']),
|
||||||
|
qualities: (json['qualities'] as List? ?? const [])
|
||||||
|
.whereType<String>()
|
||||||
|
.where((quality) => quality.isNotEmpty)
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 充值商品:`GET /app/products` 的 `COIN_PACK` / `VIP` 条目。
|
||||||
|
class AppProduct {
|
||||||
|
const AppProduct({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
this.coinAmount = 0,
|
||||||
|
this.bonusCoin = 0,
|
||||||
|
this.totalCoin = 0,
|
||||||
|
this.displayPrice = '',
|
||||||
|
this.currency = '',
|
||||||
|
this.vipDays = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const String coinPackType = 'COIN_PACK';
|
||||||
|
static const String vipType = 'VIP';
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String code;
|
||||||
|
final String name;
|
||||||
|
final String type;
|
||||||
|
final int coinAmount;
|
||||||
|
final int bonusCoin;
|
||||||
|
final int totalCoin;
|
||||||
|
final String displayPrice;
|
||||||
|
final String currency;
|
||||||
|
final int vipDays;
|
||||||
|
|
||||||
|
bool get isVip => type == vipType;
|
||||||
|
|
||||||
|
factory AppProduct.fromJson(Map<String, dynamic> json) {
|
||||||
|
final coinAmount = jsonToInt(json['coinAmount']);
|
||||||
|
final bonusCoin = jsonToInt(json['bonusCoin']);
|
||||||
|
return AppProduct(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
code: json['code'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
type: json['type'] as String? ?? '',
|
||||||
|
coinAmount: coinAmount,
|
||||||
|
bonusCoin: bonusCoin,
|
||||||
|
totalCoin: json['totalCoin'] == null
|
||||||
|
? coinAmount + bonusCoin
|
||||||
|
: jsonToInt(json['totalCoin']),
|
||||||
|
displayPrice: json['displayPrice'] as String? ?? '',
|
||||||
|
currency: json['currency'] as String? ?? '',
|
||||||
|
vipDays: jsonToInt(json['vipDays']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Drama> list;
|
||||||
|
|
||||||
|
factory SearchResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SearchResult(
|
||||||
|
total: jsonToInt(json['total']),
|
||||||
|
list: (json['list'] as List? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(Drama.fromCardJson)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import '../models/drama.dart';
|
import '../models/drama.dart';
|
||||||
import '../models/home_feed.dart';
|
import '../models/home_feed.dart';
|
||||||
|
import '../models/search_result.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
/// 客户端目录接口,对应后端 `/app/**`。
|
/// 客户端目录接口,对应后端 `/app/**`。
|
||||||
@@ -16,6 +17,23 @@ class CatalogApi {
|
|||||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。
|
||||||
|
Future<SearchResult> 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<Drama> dramaDetail(String dramaId) async {
|
Future<Drama> dramaDetail(String dramaId) async {
|
||||||
return Drama.fromDetailJson(
|
return Drama.fromDetailJson(
|
||||||
_asMap(await _client.get('/app/dramas/$dramaId')),
|
_asMap(await _client.get('/app/dramas/$dramaId')),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/order.dart';
|
||||||
|
import '../models/product.dart';
|
||||||
import '../models/wallet.dart';
|
import '../models/wallet.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
@@ -15,6 +17,31 @@ class CommerceApi {
|
|||||||
throw const ApiException(-1, '返回数据格式异常');
|
throw const ApiException(-1, '返回数据格式异常');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 上架商品列表;`type` 传 `COIN_PACK` 或 `VIP`。
|
||||||
|
Future<List<AppProduct>> products({String? type}) async {
|
||||||
|
final data = await _client.get(
|
||||||
|
'/app/products',
|
||||||
|
query: type == null ? null : {'type': type},
|
||||||
|
);
|
||||||
|
if (data is List) {
|
||||||
|
return data
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(AppProduct.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
throw const ApiException(-1, '返回数据格式异常');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建充值订单,返回 `CREATED` 状态订单。
|
||||||
|
Future<AppOrder> createOrder(String productId) async {
|
||||||
|
final data = await _client.post(
|
||||||
|
'/app/orders',
|
||||||
|
body: {'productId': productId},
|
||||||
|
);
|
||||||
|
if (data is Map<String, dynamic>) return AppOrder.fromJson(data);
|
||||||
|
throw const ApiException(-1, '返回数据格式异常');
|
||||||
|
}
|
||||||
|
|
||||||
Future<UnlockResult> unlock(String episodeId) async {
|
Future<UnlockResult> unlock(String episodeId) async {
|
||||||
final data = await _client.post('/app/episodes/$episodeId/unlock');
|
final data = await _client.post('/app/episodes/$episodeId/unlock');
|
||||||
if (data is Map<String, dynamic>) return UnlockResult.fromJson(data);
|
if (data is Map<String, dynamic>) return UnlockResult.fromJson(data);
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ class _CachedTicket {
|
|||||||
final PlayTicket ticket;
|
final PlayTicket ticket;
|
||||||
final DateTime fetchedAt;
|
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,
|
accessible: true,
|
||||||
accessType: PlayTicket.free,
|
accessType: PlayTicket.free,
|
||||||
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
|
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
|
||||||
|
qualities: const ['AUTO', '720P', '1080P'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<SearchResult> search(
|
||||||
|
String keyword, {
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpSearchRepository implements SearchRepository {
|
||||||
|
const HttpSearchRepository(this._api);
|
||||||
|
|
||||||
|
final CatalogApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SearchResult> search(
|
||||||
|
String keyword, {
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
}) => _api.search(keyword, pageIndex: pageIndex, pageSize: pageSize);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/order.dart';
|
||||||
|
import '../models/product.dart';
|
||||||
|
import '../remote/commerce_api.dart';
|
||||||
|
|
||||||
|
/// 商品与订单来源:充值页、会员页共用。
|
||||||
|
abstract interface class StoreRepository {
|
||||||
|
Future<List<AppProduct>> fetchProducts({String? type});
|
||||||
|
|
||||||
|
Future<AppOrder> createOrder(String productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpStoreRepository implements StoreRepository {
|
||||||
|
const HttpStoreRepository(this._api);
|
||||||
|
|
||||||
|
final CommerceApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<AppProduct>> fetchProducts({String? type}) =>
|
||||||
|
_api.products(type: type);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AppOrder> createOrder(String productId) => _api.createOrder(productId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/order.dart';
|
||||||
|
import '../models/product.dart';
|
||||||
|
import '../repositories/store_repository.dart';
|
||||||
|
import '../remote/api_exception.dart';
|
||||||
|
|
||||||
|
/// 商品货架状态:按类型拉取商品并创建订单。
|
||||||
|
class StoreController extends ChangeNotifier {
|
||||||
|
StoreController(this._repository, {required this.type});
|
||||||
|
|
||||||
|
static const String coinPackType = AppProduct.coinPackType;
|
||||||
|
static const String vipType = AppProduct.vipType;
|
||||||
|
|
||||||
|
final StoreRepository _repository;
|
||||||
|
final String type;
|
||||||
|
|
||||||
|
List<AppProduct> products = const [];
|
||||||
|
bool loading = false;
|
||||||
|
String? error;
|
||||||
|
String? purchasingProductId;
|
||||||
|
String? purchaseError;
|
||||||
|
AppOrder? order;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
products = await _repository.fetchProducts(type: type);
|
||||||
|
} on ApiException catch (exception) {
|
||||||
|
error = exception.message;
|
||||||
|
} catch (_) {
|
||||||
|
error = 'productsLoadFailed';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建订单;成功后保留 [order] 供页面展示订单号。
|
||||||
|
Future<AppOrder?> buy(AppProduct product) async {
|
||||||
|
if (purchasingProductId != null) return null;
|
||||||
|
purchasingProductId = product.id;
|
||||||
|
purchaseError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final created = await _repository.createOrder(product.id);
|
||||||
|
order = created;
|
||||||
|
return created;
|
||||||
|
} on ApiException catch (exception) {
|
||||||
|
purchaseError = exception.message;
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
purchaseError = 'orderCreateFailed';
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
purchasingProductId = null;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -516,4 +516,28 @@
|
|||||||
"rankingEmptyTitle": "No ranked stories yet",
|
"rankingEmptyTitle": "No ranked stories yet",
|
||||||
"rankingEmptyBody": "Titles appear here as soon as they are published.",
|
"rankingEmptyBody": "Titles appear here as soon as they are published.",
|
||||||
"rankingTopPick": "TOP PICK"
|
"rankingTopPick": "TOP PICK"
|
||||||
|
, "rechargeTitle": "Top Up Coins",
|
||||||
|
"vipTitle": "VIP Membership",
|
||||||
|
"buy": "Buy",
|
||||||
|
"bonusCoin": "+{bonus} bonus",
|
||||||
|
"@bonusCoin": { "placeholders": { "bonus": { "type": "int" } } },
|
||||||
|
"vipDuration": "{days}-day VIP",
|
||||||
|
"@vipDuration": { "placeholders": { "days": { "type": "int" } } },
|
||||||
|
"noCoinPacks": "No coin packs available right now.",
|
||||||
|
"noVipPlans": "No VIP plans available right now.",
|
||||||
|
"productsLoadFailed": "Could not load products.",
|
||||||
|
"orderCreated": "Order {orderNo} created.",
|
||||||
|
"@orderCreated": { "placeholders": { "orderNo": { "type": "String" } } },
|
||||||
|
"paymentPending": "Waiting for payment.",
|
||||||
|
"orderCreateFailed": "Could not create the order.",
|
||||||
|
"playbackQuality": "Quality",
|
||||||
|
"qualityAuto": "Auto",
|
||||||
|
"qualityAutoHint": "HLS adjusts the bitrate automatically for your connection.",
|
||||||
|
"searchFailed": "Search failed. Please try again.",
|
||||||
|
"languageFilter": "Language",
|
||||||
|
"languageChinese": "Chinese",
|
||||||
|
"languageIndonesian": "Indonesian",
|
||||||
|
"languageEnglish": "English",
|
||||||
|
"searchResultCount": "{count} results",
|
||||||
|
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }
|
||||||
}
|
}
|
||||||
@@ -514,4 +514,28 @@
|
|||||||
"rankingEmptyTitle": "Belum ada peringkat",
|
"rankingEmptyTitle": "Belum ada peringkat",
|
||||||
"rankingEmptyBody": "Judul muncul di sini setelah diterbitkan.",
|
"rankingEmptyBody": "Judul muncul di sini setelah diterbitkan.",
|
||||||
"rankingTopPick": "PILIHAN UTAMA"
|
"rankingTopPick": "PILIHAN UTAMA"
|
||||||
|
, "rechargeTitle": "Isi Ulang Koin",
|
||||||
|
"vipTitle": "Keanggotaan VIP",
|
||||||
|
"buy": "Beli",
|
||||||
|
"bonusCoin": "+{bonus} bonus",
|
||||||
|
"@bonusCoin": { "placeholders": { "bonus": { "type": "int" } } },
|
||||||
|
"vipDuration": "VIP {days} hari",
|
||||||
|
"@vipDuration": { "placeholders": { "days": { "type": "int" } } },
|
||||||
|
"noCoinPacks": "Belum ada paket koin.",
|
||||||
|
"noVipPlans": "Belum ada paket VIP.",
|
||||||
|
"productsLoadFailed": "Gagal memuat produk.",
|
||||||
|
"orderCreated": "Pesanan {orderNo} dibuat.",
|
||||||
|
"@orderCreated": { "placeholders": { "orderNo": { "type": "String" } } },
|
||||||
|
"paymentPending": "Menunggu pembayaran.",
|
||||||
|
"orderCreateFailed": "Gagal membuat pesanan.",
|
||||||
|
"playbackQuality": "Kualitas",
|
||||||
|
"qualityAuto": "Otomatis",
|
||||||
|
"qualityAutoHint": "HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.",
|
||||||
|
"searchFailed": "Pencarian gagal. Coba lagi.",
|
||||||
|
"languageFilter": "Bahasa",
|
||||||
|
"languageChinese": "Mandarin",
|
||||||
|
"languageIndonesian": "Indonesia",
|
||||||
|
"languageEnglish": "Inggris",
|
||||||
|
"searchResultCount": "{count} hasil",
|
||||||
|
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }
|
||||||
}
|
}
|
||||||
@@ -2343,6 +2343,126 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'TOP PICK'**
|
/// **'TOP PICK'**
|
||||||
String get rankingTopPick;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -1199,4 +1199,73 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get rankingTopPick => 'TOP PICK';
|
String get rankingTopPick => 'TOP PICK';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeTitle => 'Top Up Coins';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vipTitle => 'VIP Membership';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get buy => 'Buy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bonusCoin(int bonus) {
|
||||||
|
return '+$bonus bonus';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String vipDuration(int days) {
|
||||||
|
return '$days-day VIP';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noCoinPacks => 'No coin packs available right now.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noVipPlans => 'No VIP plans available right now.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get productsLoadFailed => 'Could not load products.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String orderCreated(String orderNo) {
|
||||||
|
return 'Order $orderNo created.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get paymentPending => 'Waiting for payment.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get orderCreateFailed => 'Could not create the order.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playbackQuality => 'Quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityAutoHint =>
|
||||||
|
'HLS adjusts the bitrate automatically for your connection.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchFailed => 'Search failed. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageFilter => 'Language';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageChinese => 'Chinese';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageIndonesian => 'Indonesian';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageEnglish => 'English';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String searchResultCount(int count) {
|
||||||
|
return '$count results';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1197,4 +1197,73 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get rankingTopPick => 'PILIHAN UTAMA';
|
String get rankingTopPick => 'PILIHAN UTAMA';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeTitle => 'Isi Ulang Koin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vipTitle => 'Keanggotaan VIP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get buy => 'Beli';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bonusCoin(int bonus) {
|
||||||
|
return '+$bonus bonus';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String vipDuration(int days) {
|
||||||
|
return 'VIP $days hari';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noCoinPacks => 'Belum ada paket koin.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noVipPlans => 'Belum ada paket VIP.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get productsLoadFailed => 'Gagal memuat produk.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String orderCreated(String orderNo) {
|
||||||
|
return 'Pesanan $orderNo dibuat.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get paymentPending => 'Menunggu pembayaran.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get orderCreateFailed => 'Gagal membuat pesanan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playbackQuality => 'Kualitas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityAuto => 'Otomatis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityAutoHint =>
|
||||||
|
'HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchFailed => 'Pencarian gagal. Coba lagi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageFilter => 'Bahasa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageChinese => 'Mandarin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageIndonesian => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageEnglish => 'Inggris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String searchResultCount(int count) {
|
||||||
|
return '$count hasil';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1176,4 +1176,72 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get rankingTopPick => '今日榜首';
|
String get rankingTopPick => '今日榜首';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeTitle => '充值金币';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vipTitle => 'VIP 会员';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get buy => '购买';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bonusCoin(int bonus) {
|
||||||
|
return '赠 $bonus';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String vipDuration(int days) {
|
||||||
|
return '$days 天会员';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noCoinPacks => '暂无可购金币包。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noVipPlans => '暂无可开通会员。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get productsLoadFailed => '商品加载失败。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String orderCreated(String orderNo) {
|
||||||
|
return '订单 $orderNo 已创建。';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get paymentPending => '等待支付。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get orderCreateFailed => '订单创建失败。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playbackQuality => '清晰度';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityAuto => '自动';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityAutoHint => 'HLS 会根据当前网络自动切换码率。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchFailed => '搜索失败,请重试。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageFilter => '语言';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageChinese => '中文';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageIndonesian => '印尼语';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get languageEnglish => '英语';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String searchResultCount(int count) {
|
||||||
|
return '$count 个结果';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -476,4 +476,28 @@
|
|||||||
"rankingEmptyTitle": "暂无榜单内容",
|
"rankingEmptyTitle": "暂无榜单内容",
|
||||||
"rankingEmptyBody": "新剧上线后会自动进入榜单。",
|
"rankingEmptyBody": "新剧上线后会自动进入榜单。",
|
||||||
"rankingTopPick": "今日榜首"
|
"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" } } }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/repositories/search_repository.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import 'discover_logic.dart';
|
import 'discover_logic.dart';
|
||||||
|
|
||||||
@@ -8,5 +9,6 @@ class DiscoverBinding {
|
|||||||
DiscoverLogic create({
|
DiscoverLogic create({
|
||||||
required Drama drama,
|
required Drama drama,
|
||||||
required WatchHistoryRepository history,
|
required WatchHistoryRepository history,
|
||||||
}) => DiscoverLogic(drama: drama, history: history);
|
SearchRepository? search,
|
||||||
|
}) => DiscoverLogic(drama: drama, history: history, searchRepository: search);
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/models/watch_progress.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 '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
enum DiscoverPageAction { home, my, watchlist, detail, resume }
|
enum DiscoverPageAction { home, my, watchlist, detail, resume }
|
||||||
|
|
||||||
class DiscoverLogic extends ChangeNotifier {
|
class DiscoverLogic extends ChangeNotifier {
|
||||||
DiscoverLogic({required this.history, required this.drama}) {
|
DiscoverLogic({
|
||||||
|
required this.history,
|
||||||
|
required this.drama,
|
||||||
|
this.searchRepository,
|
||||||
|
}) {
|
||||||
refreshPosition();
|
refreshPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
final WatchHistoryRepository history;
|
final WatchHistoryRepository history;
|
||||||
|
final SearchRepository? searchRepository;
|
||||||
Drama drama;
|
Drama drama;
|
||||||
late Future<Duration?> position;
|
late Future<Duration?> position;
|
||||||
WatchProgress? resumeRecord;
|
WatchProgress? resumeRecord;
|
||||||
@@ -22,6 +31,18 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
final FocusNode searchFocus = FocusNode();
|
final FocusNode searchFocus = FocusNode();
|
||||||
final ScrollController scroll = ScrollController();
|
final ScrollController scroll = ScrollController();
|
||||||
bool searchOpen = false;
|
bool searchOpen = false;
|
||||||
|
|
||||||
|
/// 搜索防抖与分页口径:与后端 `/app/search` 一致。
|
||||||
|
Duration searchDebounce = const Duration(milliseconds: 350);
|
||||||
|
static const int searchPageSize = 20;
|
||||||
|
List<Drama> searchResults = const [];
|
||||||
|
String? selectedLanguage;
|
||||||
|
int searchTotal = 0;
|
||||||
|
bool searchLoading = false;
|
||||||
|
String? searchError;
|
||||||
|
Timer? _searchTimer;
|
||||||
|
String _lastRequestedQuery = '';
|
||||||
|
int _searchRequest = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
void notify() {
|
void notify() {
|
||||||
@@ -51,20 +72,114 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
void onSearchChanged() => notify();
|
bool get hasSearchQuery => search.text.trim().isNotEmpty;
|
||||||
|
|
||||||
List<Drama> filtered(List<Drama> dramas) {
|
/// 输入变化只触发后端查询;本地不再做标题/简介匹配。
|
||||||
final query = search.text.trim().toLowerCase();
|
void onSearchChanged() {
|
||||||
if (query.isEmpty) return dramas;
|
final query = search.text.trim();
|
||||||
return dramas.where((drama) {
|
if (query.isEmpty) {
|
||||||
final haystack = [
|
_resetSearch();
|
||||||
drama.title,
|
notify();
|
||||||
drama.genre,
|
return;
|
||||||
drama.category,
|
}
|
||||||
drama.synopsis,
|
notify();
|
||||||
].join(' ').toLowerCase();
|
_scheduleSearch(query);
|
||||||
return haystack.contains(query);
|
}
|
||||||
}).toList();
|
|
||||||
|
/// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。
|
||||||
|
List<Drama> visibleDramas(List<Drama> fallback) {
|
||||||
|
if (!hasSearchQuery) return _filterByLanguage(fallback);
|
||||||
|
return _filterByLanguage(searchResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前 feed 中出现过的语种,按首次出现顺序去重。
|
||||||
|
List<String> languagesOf(List<Drama> dramas) {
|
||||||
|
final languages = <String>[];
|
||||||
|
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<Drama> _filterByLanguage(List<Drama> dramas) {
|
||||||
|
final code = selectedLanguage;
|
||||||
|
if (code == null) return dramas;
|
||||||
|
return dramas.where((drama) => drama.language == code).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> _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<Drama> dramas) {
|
Drama? resumeDrama(List<Drama> dramas) {
|
||||||
@@ -165,6 +280,7 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
_searchTimer?.cancel();
|
||||||
search.dispose();
|
search.dispose();
|
||||||
searchFocus.dispose();
|
searchFocus.dispose();
|
||||||
scroll.dispose();
|
scroll.dispose();
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import '../../core/config/app_config.dart';
|
|||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/models/home_feed.dart';
|
import '../../core/data/models/home_feed.dart';
|
||||||
import '../../core/data/models/watch_progress.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/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/services/category_controller.dart';
|
import '../../core/data/services/category_controller.dart';
|
||||||
|
import '../../core/formatting/error_text.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_dimens.dart';
|
import '../../core/theme/app_dimens.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
@@ -30,6 +32,7 @@ class DiscoverPage extends StatefulWidget {
|
|||||||
required this.duration,
|
required this.duration,
|
||||||
this.focusSearch = false,
|
this.focusSearch = false,
|
||||||
this.history,
|
this.history,
|
||||||
|
this.searchRepository,
|
||||||
this.embedded = false,
|
this.embedded = false,
|
||||||
this.active = true,
|
this.active = true,
|
||||||
this.searchRequest = 0,
|
this.searchRequest = 0,
|
||||||
@@ -48,6 +51,7 @@ class DiscoverPage extends StatefulWidget {
|
|||||||
final Duration duration;
|
final Duration duration;
|
||||||
final bool focusSearch;
|
final bool focusSearch;
|
||||||
final WatchHistoryRepository? history;
|
final WatchHistoryRepository? history;
|
||||||
|
final SearchRepository? searchRepository;
|
||||||
final bool embedded;
|
final bool embedded;
|
||||||
final bool active;
|
final bool active;
|
||||||
final int searchRequest;
|
final int searchRequest;
|
||||||
@@ -74,6 +78,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
logic = DiscoverBinding().create(
|
logic = DiscoverBinding().create(
|
||||||
drama: widget.drama,
|
drama: widget.drama,
|
||||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||||
|
search: widget.searchRepository ?? context.read<SearchRepository>(),
|
||||||
);
|
);
|
||||||
if (widget.focusSearch) {
|
if (widget.focusSearch) {
|
||||||
logic.searchOpen = true;
|
logic.searchOpen = true;
|
||||||
@@ -115,7 +120,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final categories = context.watch<CategoryController>();
|
final categories = context.watch<CategoryController>();
|
||||||
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
||||||
final visible = logic.filtered(categories.visibleDramas);
|
final visible = logic.visibleDramas(categories.visibleDramas);
|
||||||
final content = SafeArea(
|
final content = SafeArea(
|
||||||
bottom: !widget.embedded,
|
bottom: !widget.embedded,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -171,6 +176,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
child: _categories(l10n, categories),
|
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)
|
if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -235,7 +246,10 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
categories: categories,
|
categories: categories,
|
||||||
dramas: visible,
|
dramas: visible,
|
||||||
searching: logic.search.text.trim().isNotEmpty,
|
searching: logic.hasSearchQuery,
|
||||||
|
searchLoading: logic.searchLoading,
|
||||||
|
searchError: logic.searchError,
|
||||||
|
onRetrySearch: () => logic.retrySearch(),
|
||||||
onOpen: (drama) => logic.openCard(
|
onOpen: (drama) => logic.openCard(
|
||||||
context,
|
context,
|
||||||
drama,
|
drama,
|
||||||
@@ -329,6 +343,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
controller: logic.search,
|
controller: logic.search,
|
||||||
focusNode: logic.searchFocus,
|
focusNode: logic.searchFocus,
|
||||||
onChanged: (_) => logic.onSearchChanged(),
|
onChanged: (_) => logic.onSearchChanged(),
|
||||||
|
|
||||||
onTapOutside: (_) => logic.searchFocus.unfocus(),
|
onTapOutside: (_) => logic.searchFocus.unfocus(),
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
|
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
|
||||||
@@ -343,6 +358,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
color: AppColors.subtle,
|
color: AppColors.subtle,
|
||||||
size: 17,
|
size: 17,
|
||||||
),
|
),
|
||||||
|
suffixIcon: logic.searchLoading
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(11),
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -412,6 +433,70 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _languages(AppLocalizations l10n, List<Drama> 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(
|
Widget _continueCard(
|
||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
Drama drama,
|
Drama drama,
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ class _HomeDramaGrid extends StatelessWidget {
|
|||||||
required this.categories,
|
required this.categories,
|
||||||
required this.dramas,
|
required this.dramas,
|
||||||
required this.searching,
|
required this.searching,
|
||||||
|
required this.searchLoading,
|
||||||
|
required this.searchError,
|
||||||
|
required this.onRetrySearch,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -15,49 +18,85 @@ class _HomeDramaGrid extends StatelessWidget {
|
|||||||
final CategoryController categories;
|
final CategoryController categories;
|
||||||
final List<Drama> dramas;
|
final List<Drama> dramas;
|
||||||
final bool searching;
|
final bool searching;
|
||||||
|
final bool searchLoading;
|
||||||
|
final String? searchError;
|
||||||
|
final VoidCallback onRetrySearch;
|
||||||
final ValueChanged<Drama> onOpen;
|
final ValueChanged<Drama> onOpen;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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) {
|
if (categories.loading && categories.feed.sections.isEmpty) {
|
||||||
return const SliverFillRemaining(
|
return const SliverFillRemaining(
|
||||||
hasScrollBody: false,
|
hasScrollBody: false,
|
||||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (categories.error != null) {
|
if (categories.error == null) return null;
|
||||||
return _fill(
|
return _fill(
|
||||||
EmptyState(
|
EmptyState(
|
||||||
key: const Key('home-categories-error'),
|
key: const Key('home-categories-error'),
|
||||||
icon: Icons.cloud_off_rounded,
|
icon: Icons.cloud_off_rounded,
|
||||||
title: l10n.categoriesError,
|
title: l10n.categoriesError,
|
||||||
subtitle: l10n.emptyHint,
|
subtitle: l10n.emptyHint,
|
||||||
actionLabel: l10n.retry,
|
actionLabel: l10n.retry,
|
||||||
onAction: categories.load,
|
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) {
|
if (!searching || searchError == null) return null;
|
||||||
return _fill(
|
return _fill(
|
||||||
EmptyState(
|
EmptyState(
|
||||||
key: const Key('home-all-dramas-empty'),
|
key: const Key('discover-search-error'),
|
||||||
icon: searching
|
icon: Icons.cloud_off_rounded,
|
||||||
? Icons.search_off_rounded
|
title: errorText(l10n, searchError, l10n.searchFailed),
|
||||||
: Icons.movie_filter_outlined,
|
actionLabel: l10n.retry,
|
||||||
title: searching
|
actionKey: const Key('discover-search-retry'),
|
||||||
? l10n.noSearchResults
|
onAction: onRetrySearch,
|
||||||
: categories.selectedSectionMissing
|
),
|
||||||
? l10n.noCategoryDramas
|
);
|
||||||
: l10n.noSearchResults,
|
}
|
||||||
subtitle: l10n.emptyHint,
|
|
||||||
),
|
Widget _emptyState() {
|
||||||
);
|
return _fill(
|
||||||
}
|
EmptyState(
|
||||||
return SliverMainAxisGroup(
|
key: const Key('home-all-dramas-empty'),
|
||||||
slivers: [
|
icon: searching
|
||||||
SliverToBoxAdapter(child: _masonry(dramas)),
|
? Icons.search_off_rounded
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
: Icons.movie_filter_outlined,
|
||||||
],
|
title: searching
|
||||||
|
? l10n.noSearchResults
|
||||||
|
: categories.selectedSectionMissing
|
||||||
|
? l10n.noCategoryDramas
|
||||||
|
: l10n.noSearchResults,
|
||||||
|
subtitle: l10n.emptyHint,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import '../account/account_entry_card.dart';
|
|||||||
import '../downloads/downloads_page.dart';
|
import '../downloads/downloads_page.dart';
|
||||||
import '../favorites/favorites_page.dart';
|
import '../favorites/favorites_page.dart';
|
||||||
import '../notices/notices_page.dart';
|
import '../notices/notices_page.dart';
|
||||||
|
import '../store/vip_page.dart';
|
||||||
import '../wallet/wallet_page.dart';
|
import '../wallet/wallet_page.dart';
|
||||||
import '../watch_records/watch_records_page.dart';
|
import '../watch_records/watch_records_page.dart';
|
||||||
import 'mine_binding.dart';
|
import 'mine_binding.dart';
|
||||||
@@ -373,6 +374,21 @@ class _MyPageState extends State<MyPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openVip() {
|
||||||
|
if (!widget.signedIn) {
|
||||||
|
widget.onSignIn?.call();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final wallet = widget.wallet;
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => VipPage(
|
||||||
|
onPurchased: wallet == null ? null : () => unawaited(wallet.load()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _openWallet(BuildContext context) {
|
void _openWallet(BuildContext context) {
|
||||||
if (!widget.signedIn) {
|
if (!widget.signedIn) {
|
||||||
widget.onSignIn?.call();
|
widget.onSignIn?.call();
|
||||||
@@ -835,6 +851,12 @@ class _MyPageState extends State<MyPage> {
|
|||||||
MaterialPageRoute(builder: (_) => const DownloadsPage()),
|
MaterialPageRoute(builder: (_) => const DownloadsPage()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_menuTile(
|
||||||
|
key: const Key('mine-vip'),
|
||||||
|
icon: Icons.workspace_premium_outlined,
|
||||||
|
title: l10n.vipMembership,
|
||||||
|
onTap: _openVip,
|
||||||
|
),
|
||||||
_menuTile(
|
_menuTile(
|
||||||
icon: Icons.settings_rounded,
|
icon: Icons.settings_rounded,
|
||||||
title: l10n.settings,
|
title: l10n.settings,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
PlayTicket? blockedTicket;
|
PlayTicket? blockedTicket;
|
||||||
String? blockedEpisodeId;
|
String? blockedEpisodeId;
|
||||||
List<Episode> sessionEpisodes = const [];
|
List<Episode> sessionEpisodes = const [];
|
||||||
|
List<String> qualities = const [];
|
||||||
int pageRequest = 0;
|
int pageRequest = 0;
|
||||||
bool muted = false;
|
bool muted = false;
|
||||||
double playbackSpeed = 1.0;
|
double playbackSpeed = 1.0;
|
||||||
@@ -217,6 +218,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
if (localPath != null) {
|
if (localPath != null) {
|
||||||
blockedTicket = null;
|
blockedTicket = null;
|
||||||
blockedEpisodeId = null;
|
blockedEpisodeId = null;
|
||||||
|
qualities = const [];
|
||||||
sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath));
|
sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -227,6 +229,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
if (request != pageRequest) return false;
|
if (request != pageRequest) return false;
|
||||||
blockedTicket = null;
|
blockedTicket = null;
|
||||||
blockedEpisodeId = null;
|
blockedEpisodeId = null;
|
||||||
|
qualities = ticket.qualities;
|
||||||
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
|
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
|
||||||
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
|
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
|
||||||
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
|
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
|
||||||
@@ -280,10 +283,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
final ready = await ensureTicket(index, forceRefresh: true);
|
final ready = await ensureTicket(index, forceRefresh: true);
|
||||||
if (ready) {
|
if (ready) {
|
||||||
await session.select(
|
await session.select(index, preloadIndex: nextVisibleIndex(index));
|
||||||
index,
|
|
||||||
preloadIndex: nextVisibleIndex(index),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return ready;
|
return ready;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import 'player_logic.dart';
|
|||||||
import 'widgets/home_feed_overlay.dart';
|
import 'widgets/home_feed_overlay.dart';
|
||||||
import 'widgets/exit_clear_screen_button.dart';
|
import 'widgets/exit_clear_screen_button.dart';
|
||||||
import 'widgets/player_center_toggle.dart';
|
import 'widgets/player_center_toggle.dart';
|
||||||
|
import 'widgets/quality_sheet.dart';
|
||||||
import 'widgets/player_featured_comment.dart';
|
import 'widgets/player_featured_comment.dart';
|
||||||
import 'widgets/seek_progress_bar.dart';
|
import 'widgets/seek_progress_bar.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
@@ -402,6 +403,8 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
playing: logic.session.userWantsPlay,
|
playing: logic.session.userWantsPlay,
|
||||||
playbackSpeed: _playbackSpeed,
|
playbackSpeed: _playbackSpeed,
|
||||||
onSpeedTap: () => unawaited(_openSpeedSheet(context)),
|
onSpeedTap: () => unawaited(_openSpeedSheet(context)),
|
||||||
|
qualities: logic.qualities,
|
||||||
|
onQualityTap: () => unawaited(_openQualitySheet(context)),
|
||||||
controlsVisible: _chromeVisible,
|
controlsVisible: _chromeVisible,
|
||||||
onClear: () => setState(() => _chromeVisible = false),
|
onClear: () => setState(() => _chromeVisible = false),
|
||||||
onExitClear: () => setState(() => _chromeVisible = true),
|
onExitClear: () => setState(() => _chromeVisible = true),
|
||||||
@@ -500,6 +503,15 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
await logic.setPlaybackSpeed(selected);
|
await logic.setPlaybackSpeed(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openQualitySheet(BuildContext context) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: AppColors.card,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (_) => QualitySheet(qualities: logic.qualities),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _unlockEpisode(BuildContext context, int index) async {
|
Future<void> _unlockEpisode(BuildContext context, int index) async {
|
||||||
if (!await ensureSignedIn(context)) return;
|
if (!await ensureSignedIn(context)) return;
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -693,6 +705,8 @@ class _PlaybackOverlay extends StatefulWidget {
|
|||||||
required this.playing,
|
required this.playing,
|
||||||
required this.playbackSpeed,
|
required this.playbackSpeed,
|
||||||
required this.onSpeedTap,
|
required this.onSpeedTap,
|
||||||
|
required this.qualities,
|
||||||
|
required this.onQualityTap,
|
||||||
required this.controlsVisible,
|
required this.controlsVisible,
|
||||||
required this.onTogglePlay,
|
required this.onTogglePlay,
|
||||||
required this.onClear,
|
required this.onClear,
|
||||||
@@ -720,6 +734,8 @@ class _PlaybackOverlay extends StatefulWidget {
|
|||||||
final bool playing;
|
final bool playing;
|
||||||
final double playbackSpeed;
|
final double playbackSpeed;
|
||||||
final VoidCallback onSpeedTap;
|
final VoidCallback onSpeedTap;
|
||||||
|
final List<String> qualities;
|
||||||
|
final VoidCallback onQualityTap;
|
||||||
final bool controlsVisible;
|
final bool controlsVisible;
|
||||||
final VoidCallback onTogglePlay;
|
final VoidCallback onTogglePlay;
|
||||||
final VoidCallback onClear;
|
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(
|
ListTile(
|
||||||
key: const Key('player-more-download'),
|
key: const Key('player-more-download'),
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
|
|||||||
@@ -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<String> 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/data/repositories/store_repository.dart';
|
||||||
|
import '../../core/data/services/store_controller.dart';
|
||||||
|
import 'store_page.dart';
|
||||||
|
|
||||||
|
/// 金币充值页:展示上架金币包并在点击购买时创建订单。
|
||||||
|
class RechargePage extends StatelessWidget {
|
||||||
|
const RechargePage({super.key, this.repository, this.onPurchased});
|
||||||
|
|
||||||
|
final StoreRepository? repository;
|
||||||
|
final VoidCallback? onPurchased;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return StorePage(
|
||||||
|
type: StoreController.coinPackType,
|
||||||
|
repository: repository,
|
||||||
|
onPurchased: onPurchased,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<StorePage> createState() => _StorePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StorePageState extends State<StorePage> {
|
||||||
|
late final StoreController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
controller = StoreController(
|
||||||
|
widget.repository ?? context.read<StoreRepository>(),
|
||||||
|
type: widget.type,
|
||||||
|
);
|
||||||
|
unawaited(controller.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final isVip = widget.type == StoreController.vipType;
|
||||||
|
return Theme(
|
||||||
|
data: AppTheme.dark(),
|
||||||
|
child: Scaffold(
|
||||||
|
key: const Key('store-page'),
|
||||||
|
backgroundColor: AppColors.ink,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppColors.ink,
|
||||||
|
foregroundColor: AppColors.onSurface,
|
||||||
|
title: Text(isVip ? l10n.vipTitle : l10n.rechargeTitle),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: controller,
|
||||||
|
builder: (context, _) => _body(l10n, isVip),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _body(AppLocalizations l10n, bool isVip) {
|
||||||
|
if (controller.loading && controller.products.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(key: Key('store-loading')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (controller.error != null && controller.products.isEmpty) {
|
||||||
|
return EmptyState(
|
||||||
|
key: const Key('store-error'),
|
||||||
|
icon: Icons.cloud_off_rounded,
|
||||||
|
title: errorText(l10n, controller.error, l10n.productsLoadFailed),
|
||||||
|
actionLabel: l10n.retry,
|
||||||
|
actionKey: const Key('store-retry'),
|
||||||
|
onAction: () => unawaited(controller.load()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (controller.products.isEmpty) {
|
||||||
|
return EmptyState(
|
||||||
|
key: const Key('store-empty'),
|
||||||
|
icon: isVip ? Icons.workspace_premium_outlined : Icons.savings_outlined,
|
||||||
|
title: isVip ? l10n.noVipPlans : l10n.noCoinPacks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||||
|
itemCount: controller.products.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) => _ProductTile(
|
||||||
|
product: controller.products[index],
|
||||||
|
purchasing:
|
||||||
|
controller.purchasingProductId == controller.products[index].id,
|
||||||
|
onBuy: () => _buy(controller.products[index]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _buy(AppProduct product) async {
|
||||||
|
final order = await controller.buy(product);
|
||||||
|
if (!mounted) return;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
if (order == null) {
|
||||||
|
final message = errorText(
|
||||||
|
l10n,
|
||||||
|
controller.purchaseError,
|
||||||
|
l10n.orderCreateFailed,
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
widget.onPurchased?.call();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'${l10n.orderCreated(order.orderNo)} ${l10n.paymentPending}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductTile extends StatelessWidget {
|
||||||
|
const _ProductTile({
|
||||||
|
required this.product,
|
||||||
|
required this.purchasing,
|
||||||
|
required this.onBuy,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppProduct product;
|
||||||
|
final bool purchasing;
|
||||||
|
final VoidCallback onBuy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return Container(
|
||||||
|
key: Key('store-product-${product.id}'),
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 14, 12, 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.card,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppColors.gold.withValues(alpha: 0.26)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_ProductBadge(vip: product.isVip),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/data/repositories/store_repository.dart';
|
||||||
|
import '../../core/data/services/store_controller.dart';
|
||||||
|
import 'store_page.dart';
|
||||||
|
|
||||||
|
/// 会员页:展示 VIP 档位并在点击开通时创建订单。
|
||||||
|
class VipPage extends StatelessWidget {
|
||||||
|
const VipPage({super.key, this.repository, this.onPurchased});
|
||||||
|
|
||||||
|
final StoreRepository? repository;
|
||||||
|
final VoidCallback? onPurchased;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return StorePage(
|
||||||
|
type: StoreController.vipType,
|
||||||
|
repository: repository,
|
||||||
|
onPurchased: onPurchased,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import '../../core/theme/app_colors.dart';
|
|||||||
import '../../core/theme/app_dimens.dart';
|
import '../../core/theme/app_dimens.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/empty_state.dart';
|
import '../../core/widgets/empty_state.dart';
|
||||||
|
import '../store/recharge_page.dart';
|
||||||
|
import '../store/vip_page.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// 金币钱包:余额、累计收支与最近流水。
|
/// 金币钱包:余额、累计收支与最近流水。
|
||||||
@@ -56,6 +58,52 @@ class _WalletPageState extends State<WalletPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _storeActions(AppLocalizations l10n, WalletController wallet) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
key: const Key('wallet-top-up'),
|
||||||
|
onPressed: () => _openRecharge(wallet),
|
||||||
|
icon: const Icon(Icons.add_card_rounded, size: 16),
|
||||||
|
label: Text(l10n.topUpWallet),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.blue,
|
||||||
|
foregroundColor: AppColors.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
key: const Key('wallet-vip'),
|
||||||
|
onPressed: () => _openVip(wallet),
|
||||||
|
icon: const Icon(Icons.workspace_premium_outlined, size: 16),
|
||||||
|
label: Text(l10n.vipMembership),
|
||||||
|
style: OutlinedButton.styleFrom(foregroundColor: AppColors.gold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openRecharge(WalletController wallet) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) =>
|
||||||
|
RechargePage(onPurchased: () => unawaited(wallet.load())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openVip(WalletController wallet) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _body(AppLocalizations l10n, WalletController wallet) {
|
Widget _body(AppLocalizations l10n, WalletController wallet) {
|
||||||
if (wallet.loading && wallet.snapshot == null) {
|
if (wallet.loading && wallet.snapshot == null) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@@ -77,6 +125,8 @@ class _WalletPageState extends State<WalletPage> {
|
|||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
|
||||||
children: [
|
children: [
|
||||||
_BalanceHero(wallet: snapshot.wallet),
|
_BalanceHero(wallet: snapshot.wallet),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_storeActions(l10n, wallet),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -269,7 +319,9 @@ class _LedgerTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final positive = entry.changeAmount >= 0;
|
final positive = entry.changeAmount >= 0;
|
||||||
final color = positive ? AppColors.success : AppColors.accent;
|
final color = positive ? AppColors.success : AppColors.accent;
|
||||||
final amount = positive ? '+${entry.changeAmount}' : '${entry.changeAmount}';
|
final amount = positive
|
||||||
|
? '+${entry.changeAmount}'
|
||||||
|
: '${entry.changeAmount}';
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ void main() {
|
|||||||
'totalEpisodes': 77,
|
'totalEpisodes': 77,
|
||||||
'isHot': true,
|
'isHot': true,
|
||||||
'viewCount': 3100000,
|
'viewCount': 3100000,
|
||||||
|
'language': 'zh-CN',
|
||||||
'preview': {
|
'preview': {
|
||||||
'id': 9,
|
'id': 9,
|
||||||
'number': 1,
|
'number': 1,
|
||||||
@@ -27,6 +28,7 @@ void main() {
|
|||||||
expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4');
|
expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4');
|
||||||
expect(drama.episodeCount, 77);
|
expect(drama.episodeCount, 77);
|
||||||
expect(drama.isHot, isTrue);
|
expect(drama.isHot, isTrue);
|
||||||
|
expect(drama.language, 'zh-CN');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_have_no_episodes_when_list_card_has_no_playable_preview', () {
|
test('should_have_no_episodes_when_list_card_has_no_playable_preview', () {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<AppProduct> products;
|
||||||
|
final AppOrder? order;
|
||||||
|
final ApiException? orderError;
|
||||||
|
final List<String?> requestedTypes = [];
|
||||||
|
final List<String> orderedProductIds = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<AppProduct>> fetchProducts({String? type}) async {
|
||||||
|
requestedTypes.add(type);
|
||||||
|
return products;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AppOrder> createOrder(String productId) async {
|
||||||
|
orderedProductIds.add(productId);
|
||||||
|
if (orderError != null) throw orderError!;
|
||||||
|
return order!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<ContentCategory>> fetchCategories() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomeFeed> 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,
|
||||||
|
);
|
||||||
@@ -8,7 +8,12 @@ import 'package:fruitshorts/pages/discover/discover_logic.dart';
|
|||||||
|
|
||||||
import '../support/fake_watch_history.dart';
|
import '../support/fake_watch_history.dart';
|
||||||
|
|
||||||
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
|
Drama _drama(
|
||||||
|
String id, {
|
||||||
|
List<int> numbers = const [1, 2, 3],
|
||||||
|
String language = '',
|
||||||
|
}) => Drama(
|
||||||
|
language: language,
|
||||||
id: id,
|
id: id,
|
||||||
title: id,
|
title: id,
|
||||||
synopsis: 'synopsis',
|
synopsis: 'synopsis',
|
||||||
@@ -28,15 +33,18 @@ Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
|
|||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
test('should_resolve_resume_position_of_episode_one_when_constructed', () async {
|
test(
|
||||||
final history = FakeWatchHistory()..saved = const Duration(seconds: 4);
|
'should_resolve_resume_position_of_episode_one_when_constructed',
|
||||||
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
|
() 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(await logic.position, const Duration(seconds: 4));
|
||||||
expect(history.reads, ['signal']);
|
expect(history.reads, ['signal']);
|
||||||
expect(logic.searchOpen, isFalse);
|
expect(logic.searchOpen, isFalse);
|
||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('should_reread_resume_position_when_drama_is_swapped', () async {
|
test('should_reread_resume_position_when_drama_is_swapped', () async {
|
||||||
final history = FakeWatchHistory();
|
final history = FakeWatchHistory();
|
||||||
@@ -50,20 +58,59 @@ void main() {
|
|||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_resume_from_last_watch_when_list_card_has_no_episodes', () async {
|
test(
|
||||||
final history = FakeWatchHistory()
|
'should_resume_from_last_watch_when_list_card_has_no_episodes',
|
||||||
..saved = const Duration(seconds: 9)
|
() async {
|
||||||
..lastEpisode = 'signal-2';
|
final history = FakeWatchHistory()
|
||||||
final logic = DiscoverLogic(
|
..saved = const Duration(seconds: 9)
|
||||||
history: history,
|
..lastEpisode = 'signal-2';
|
||||||
drama: _drama('signal', numbers: const []),
|
final logic = DiscoverLogic(
|
||||||
);
|
history: history,
|
||||||
|
drama: _drama('signal', numbers: const []),
|
||||||
|
);
|
||||||
|
|
||||||
expect(await logic.position, const Duration(seconds: 9));
|
expect(await logic.position, const Duration(seconds: 9));
|
||||||
expect(history.reads, ['signal']);
|
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();
|
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 {
|
test('should_have_no_resume_position_when_server_has_no_record', () async {
|
||||||
final history = FakeWatchHistory();
|
final history = FakeWatchHistory();
|
||||||
final logic = DiscoverLogic(
|
final logic = DiscoverLogic(
|
||||||
@@ -78,7 +125,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should_show_search_when_flag_is_on_or_text_is_typed', () {
|
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);
|
expect(logic.showSearch, isFalse);
|
||||||
|
|
||||||
@@ -95,7 +145,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should_match_by_id_and_tolerate_missing_id_when_looking_up_drama', () {
|
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')];
|
final catalog = [_drama('signal'), _drama('city')];
|
||||||
|
|
||||||
expect(logic.dramaFor('city', catalog)?.id, 'city');
|
expect(logic.dramaFor('city', catalog)?.id, 'city');
|
||||||
@@ -136,7 +189,12 @@ void main() {
|
|||||||
|
|
||||||
// Staying active with a changed drama re-reads history.
|
// Staying active with a changed drama re-reads history.
|
||||||
history.reads.clear();
|
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(logic.drama.id, 'vengeance');
|
||||||
expect(history.reads, ['vengeance']);
|
expect(history.reads, ['vengeance']);
|
||||||
|
|
||||||
@@ -148,43 +206,50 @@ void main() {
|
|||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_open_and_focus_field_when_search_is_requested_and_page_is_active', (
|
testWidgets(
|
||||||
tester,
|
'should_open_and_focus_field_when_search_is_requested_and_page_is_active',
|
||||||
) async {
|
(tester) async {
|
||||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
final logic = DiscoverLogic(
|
||||||
await tester.pumpWidget(
|
history: FakeWatchHistory(),
|
||||||
MaterialApp(
|
drama: _drama('s'),
|
||||||
home: Scaffold(
|
);
|
||||||
body: TextField(
|
await tester.pumpWidget(
|
||||||
controller: logic.search,
|
MaterialApp(
|
||||||
focusNode: logic.searchFocus,
|
home: Scaffold(
|
||||||
|
body: TextField(
|
||||||
|
controller: logic.search,
|
||||||
|
focusNode: logic.searchFocus,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
logic.syncDrama(
|
logic.syncDrama(
|
||||||
active: true,
|
active: true,
|
||||||
wasActive: true,
|
wasActive: true,
|
||||||
nextDrama: logic.drama,
|
nextDrama: logic.drama,
|
||||||
dramaChanged: false,
|
dramaChanged: false,
|
||||||
searchRequest: 1,
|
searchRequest: 1,
|
||||||
previousSearchRequest: 0,
|
previousSearchRequest: 0,
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(logic.searchOpen, isTrue);
|
expect(logic.searchOpen, isTrue);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(logic.searchFocus.hasFocus, isTrue);
|
expect(logic.searchFocus.hasFocus, isTrue);
|
||||||
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('should_keep_search_field_closed_when_page_is_hidden', (
|
testWidgets('should_keep_search_field_closed_when_page_is_hidden', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
final logic = DiscoverLogic(
|
||||||
|
history: FakeWatchHistory(),
|
||||||
|
drama: _drama('s'),
|
||||||
|
);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
@@ -219,7 +284,10 @@ void main() {
|
|||||||
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', (
|
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
final logic = DiscoverLogic(
|
||||||
|
history: FakeWatchHistory(),
|
||||||
|
drama: _drama('s'),
|
||||||
|
);
|
||||||
final forwarded = <DiscoverPageAction>[];
|
final forwarded = <DiscoverPageAction>[];
|
||||||
final popped = Completer<DiscoverPageAction?>();
|
final popped = Completer<DiscoverPageAction?>();
|
||||||
late BuildContext pageContext;
|
late BuildContext pageContext;
|
||||||
@@ -266,7 +334,10 @@ void main() {
|
|||||||
testWidgets('should_show_artwork_notice_when_card_has_no_playable_media', (
|
testWidgets('should_show_artwork_notice_when_card_has_no_playable_media', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
final logic = DiscoverLogic(
|
||||||
|
history: FakeWatchHistory(),
|
||||||
|
drama: _drama('s'),
|
||||||
|
);
|
||||||
late BuildContext context;
|
late BuildContext context;
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -291,8 +362,13 @@ void main() {
|
|||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_forward_chosen_drama_when_card_is_embedded', (tester) async {
|
testWidgets('should_forward_chosen_drama_when_card_is_embedded', (
|
||||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
tester,
|
||||||
|
) async {
|
||||||
|
final logic = DiscoverLogic(
|
||||||
|
history: FakeWatchHistory(),
|
||||||
|
drama: _drama('s'),
|
||||||
|
);
|
||||||
late BuildContext context;
|
late BuildContext context;
|
||||||
Drama? selected;
|
Drama? selected;
|
||||||
|
|
||||||
@@ -319,7 +395,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should_do_nothing_when_notifying_after_dispose', () {
|
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;
|
var notifications = 0;
|
||||||
logic.addListener(() => notifications++);
|
logic.addListener(() => notifications++);
|
||||||
|
|
||||||
|
|||||||
@@ -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<Drama> results;
|
||||||
|
final ApiException? error;
|
||||||
|
final List<({String keyword, int pageIndex, int pageSize})> calls = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SearchResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -428,6 +428,7 @@ Future<void> _pumpApp(
|
|||||||
LumaDramaApp(
|
LumaDramaApp(
|
||||||
playerFactory: createWidgetPlayer,
|
playerFactory: createWidgetPlayer,
|
||||||
catalogRepository: catalog,
|
catalogRepository: catalog,
|
||||||
|
searchRepository: catalog,
|
||||||
categoryRepository: categoryRepository,
|
categoryRepository: categoryRepository,
|
||||||
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
|
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
|
||||||
favoriteRepository: InMemoryFavoriteRepository(),
|
favoriteRepository: InMemoryFavoriteRepository(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -38,8 +38,15 @@ void main() {
|
|||||||
expect(find.byType(PageView), findsOneWidget);
|
expect(find.byType(PageView), findsOneWidget);
|
||||||
await tester.tap(find.byKey(const Key('player-more')));
|
await tester.tap(find.byKey(const Key('player-more')));
|
||||||
await tester.pumpAndSettle();
|
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-download')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('player-more-pip')), 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);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<String?> requestedTypes = [];
|
||||||
|
final List<String> orderedProductIds = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<AppProduct>> 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<AppOrder> createOrder(String productId) async {
|
||||||
|
orderedProductIds.add(productId);
|
||||||
|
return const AppOrder(
|
||||||
|
id: 'order-1',
|
||||||
|
orderNo: 'ORD1',
|
||||||
|
productName: 'Coin pack',
|
||||||
|
coinAmount: 550,
|
||||||
|
status: AppOrder.created,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
import 'package:fruitshorts/core/data/models/home_feed.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/catalog_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/search_repository.dart';
|
||||||
|
|
||||||
/// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。
|
/// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。
|
||||||
class FakeCatalogRepository implements CatalogRepository {
|
class FakeCatalogRepository implements CatalogRepository, SearchRepository {
|
||||||
FakeCatalogRepository({List<Drama>? dramas, this.banners = const []})
|
FakeCatalogRepository({List<Drama>? dramas, this.banners = const []})
|
||||||
: dramas = dramas ?? demoDramas;
|
: dramas = dramas ?? demoDramas;
|
||||||
|
|
||||||
@@ -21,6 +23,20 @@ class FakeCatalogRepository implements CatalogRepository {
|
|||||||
return dramas.firstWhere((drama) => drama.id == dramaId);
|
return dramas.firstWhere((drama) => drama.id == dramaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SearchResult> 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(
|
HomeFeed _feed() => HomeFeed(
|
||||||
banners: banners,
|
banners: banners,
|
||||||
sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)],
|
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',
|
synopsis: '$title synopsis',
|
||||||
posterUrl: '/files/$id.png',
|
posterUrl: '/files/$id.png',
|
||||||
genre: genre,
|
genre: genre,
|
||||||
|
language: 'id',
|
||||||
detailLoaded: true,
|
detailLoaded: true,
|
||||||
episodes: [
|
episodes: [
|
||||||
for (var index = 0; index < 7; index++)
|
for (var index = 0; index < 7; index++)
|
||||||
|
|||||||
Reference in new issue
Block a user