From 136ee7461895f445c1f9108956d92ccde4fac681 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 23:46:47 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(app):=20=E5=AE=8C=E6=88=90=20P0=20?= =?UTF-8?q?=E9=92=B1=E5=8C=85=E8=A7=A3=E9=94=81=E3=80=81=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E8=90=BD=E7=9B=98=E4=B8=8E=E6=89=8B=E6=9C=BA=E5=8F=B7=E7=99=BB?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 19 + lib/core/config/server_endpoint.dart | 12 +- lib/core/data/models/wallet.dart | 107 +++++ lib/core/data/remote/auth_api.dart | 61 +++ lib/core/data/remote/commerce_api.dart | 23 + .../repositories/commerce_repository.dart | 23 + lib/core/data/services/auth_controller.dart | 37 ++ .../data/services/auth_session_store.dart | 31 +- .../data/services/download_controller.dart | 138 +++++- lib/core/data/services/download_storage.dart | 182 ++++++++ lib/core/data/services/video_player_port.dart | 14 +- lib/core/data/services/wallet_controller.dart | 96 +++++ lib/core/widgets/download_action.dart | 8 +- lib/l10n/app_en.arb | 33 +- lib/l10n/app_id.arb | 31 +- lib/l10n/app_localizations.dart | 174 ++++++++ lib/l10n/app_localizations_en.dart | 91 ++++ lib/l10n/app_localizations_id.dart | 91 ++++ lib/l10n/app_localizations_zh.dart | 91 ++++ lib/l10n/app_zh.arb | 31 +- lib/pages/auth/auth_view.dart | 24 +- lib/pages/auth/phone_auth_sheet.dart | 358 ++++++++++++++++ lib/pages/downloads/downloads_page.dart | 74 +++- .../drama_detail/drama_detail_logic.dart | 10 +- lib/pages/home/home_logic.dart | 9 +- lib/pages/home/home_view.dart | 5 + lib/pages/mine/mine_view.dart | 36 +- lib/pages/player/player_binding.dart | 6 + lib/pages/player/player_logic.dart | 65 ++- lib/pages/player/player_view.dart | 75 +++- lib/pages/wallet/wallet_page.dart | 400 ++++++++++++++++++ pubspec.lock | 114 ++++- pubspec.yaml | 2 + test/auth/auth_controller_test.dart | 52 +++ test/auth/auth_landing_test.dart | 11 +- test/auth/auth_visual_test.dart | 2 +- test/auth/phone_auth_sheet_test.dart | 127 ++++++ test/commerce/commerce_api_test.dart | 81 ++++ test/commerce/wallet_controller_test.dart | 100 +++++ test/config/locale_controller_test.dart | 1 + test/core/haptics_scan_test.dart | 3 +- .../download_controller_local_test.dart | 149 +++++++ test/download/download_storage_test.dart | 42 ++ test/drama_detail/drama_detail_view_test.dart | 78 ++++ test/integration/live_catalog_test.dart | 14 +- test/player/player_logic_test.dart | 102 +++++ test/wallet/wallet_page_test.dart | 101 +++++ 47 files changed, 3264 insertions(+), 70 deletions(-) create mode 100644 lib/core/data/models/wallet.dart create mode 100644 lib/core/data/remote/commerce_api.dart create mode 100644 lib/core/data/repositories/commerce_repository.dart create mode 100644 lib/core/data/services/download_storage.dart create mode 100644 lib/core/data/services/wallet_controller.dart create mode 100644 lib/pages/auth/phone_auth_sheet.dart create mode 100644 lib/pages/wallet/wallet_page.dart create mode 100644 test/auth/phone_auth_sheet_test.dart create mode 100644 test/commerce/commerce_api_test.dart create mode 100644 test/commerce/wallet_controller_test.dart create mode 100644 test/download/download_controller_local_test.dart create mode 100644 test/download/download_storage_test.dart create mode 100644 test/wallet/wallet_page_test.dart diff --git a/lib/app.dart b/lib/app.dart index ba3c8c7..970c467 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -13,6 +13,7 @@ import 'core/data/remote/auth_api.dart'; import 'core/data/remote/catalog_api.dart'; import 'core/data/remote/category_api.dart'; import 'core/data/remote/comment_api.dart'; +import 'core/data/remote/commerce_api.dart'; import 'core/data/remote/download_api.dart'; import 'core/data/remote/interaction_api.dart'; import 'core/data/remote/library_api.dart'; @@ -23,6 +24,7 @@ import 'core/data/repositories/catalog_repository.dart'; import 'core/data/repositories/account_repository.dart'; import 'core/data/repositories/category_repository.dart'; import 'core/data/repositories/comment_repository.dart'; +import 'core/data/repositories/commerce_repository.dart'; import 'core/data/repositories/download_repository.dart'; import 'core/data/repositories/favorite_repository.dart'; import 'core/data/repositories/interaction_repository.dart'; @@ -38,6 +40,7 @@ import 'core/data/services/favorite_controller.dart'; import 'core/data/services/interaction_controller.dart'; import 'core/data/services/playback_session.dart'; import 'core/data/services/video_player_port.dart'; +import 'core/data/services/wallet_controller.dart'; import 'core/platform/haptics.dart'; import 'core/theme/app_theme.dart'; import 'core/theme/app_colors.dart'; @@ -58,6 +61,7 @@ class LumaDramaApp extends StatefulWidget { this.interactionRepository, this.noticeRepository, this.accountRepository, + this.commerceRepository, this.haptics = const FlutterHaptics(), }); @@ -74,6 +78,7 @@ class LumaDramaApp extends StatefulWidget { final InteractionRepository? interactionRepository; final NoticeRepository? noticeRepository; final AccountRepository? accountRepository; + final CommerceRepository? commerceRepository; final Haptics haptics; @override @@ -134,6 +139,13 @@ class _LumaDramaAppState extends State { _downloadRepository, ); + late final CommerceRepository _commerceRepository = + widget.commerceRepository ?? HttpCommerceRepository(CommerceApi(_apiClient)); + + late final WalletController _walletController = WalletController( + _commerceRepository, + ); + late final CommentRepository _commentRepository = widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient)); @@ -158,6 +170,8 @@ class _LumaDramaAppState extends State { // 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联 _auth.addListener(_onAuthChanged); if (_auth.isSignedIn) unawaited(_favoriteController.load()); + if (_auth.isSignedIn) unawaited(_walletController.load()); + unawaited(_downloadController.restore()); } bool _wasSignedIn = false; @@ -168,9 +182,11 @@ class _LumaDramaAppState extends State { _interactionController.clear(); if (_auth.isSignedIn) { unawaited(_favoriteController.load()); + unawaited(_walletController.load()); } else { _favoriteController.clear(); _downloadController.reset(); + _walletController.reset(); } } @@ -179,6 +195,7 @@ class _LumaDramaAppState extends State { _auth.removeListener(_onAuthChanged); _favoriteController.dispose(); _downloadController.dispose(); + _walletController.dispose(); _interactionController.dispose(); _apiClient.close(); super.dispose(); @@ -198,6 +215,7 @@ class _LumaDramaAppState extends State { Provider.value(value: _interactionRepository), Provider.value(value: _noticeRepository), Provider.value(value: _accountRepository), + Provider.value(value: _commerceRepository), Provider.value(value: widget.haptics), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), @@ -208,6 +226,7 @@ class _LumaDramaAppState extends State { ChangeNotifierProvider(create: (_) => LocaleController()..load()), ChangeNotifierProvider.value(value: _favoriteController), ChangeNotifierProvider.value(value: _downloadController), + ChangeNotifierProvider.value(value: _walletController), ChangeNotifierProvider.value(value: _interactionController), ChangeNotifierProvider.value(value: _auth), ], diff --git a/lib/core/config/server_endpoint.dart b/lib/core/config/server_endpoint.dart index 37355fc..332eb6b 100644 --- a/lib/core/config/server_endpoint.dart +++ b/lib/core/config/server_endpoint.dart @@ -39,11 +39,11 @@ abstract final class ServerEndpoint { /// In release builds this returns immediately without reading preferences. static Future load({ bool debug = kDebugMode, - SharedPreferences? preferences, + SharedPreferencesAsync? preferences, }) async { if (!debug) return; try { - final prefs = preferences ?? await SharedPreferences.getInstance(); + final prefs = preferences ?? SharedPreferencesAsync(); final stored = await prefs.getString(preferenceKey); AppConfig.applyRuntimeBaseUrl( resolve( @@ -60,12 +60,12 @@ abstract final class ServerEndpoint { /// Persists a normalized runtime endpoint. static Future save( String raw, { - SharedPreferences? preferences, + SharedPreferencesAsync? preferences, }) async { final normalized = normalize(raw); if (normalized == null) return false; try { - await (preferences ?? await SharedPreferences.getInstance()).setString( + await (preferences ?? SharedPreferencesAsync()).setString( preferenceKey, normalized, ); @@ -76,9 +76,9 @@ abstract final class ServerEndpoint { } /// Removes the persisted runtime endpoint. - static Future clear({SharedPreferences? preferences}) async { + static Future clear({SharedPreferencesAsync? preferences}) async { try { - await (preferences ?? await SharedPreferences.getInstance()).remove(preferenceKey); + await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey); } catch (_) { // Clearing an optional developer override is best effort. } diff --git a/lib/core/data/models/wallet.dart b/lib/core/data/models/wallet.dart new file mode 100644 index 0000000..1819391 --- /dev/null +++ b/lib/core/data/models/wallet.dart @@ -0,0 +1,107 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 金币钱包与流水:对应 `GET /app/wallet` 的 `wallet` / `ledger`。 +class CoinWallet { + const CoinWallet({ + required this.coinBalance, + required this.totalRechargedCoin, + required this.totalConsumedCoin, + required this.totalRefundedCoin, + }); + + final int coinBalance; + final int totalRechargedCoin; + final int totalConsumedCoin; + final int totalRefundedCoin; + + factory CoinWallet.fromJson(Map json) { + return CoinWallet( + coinBalance: jsonToInt(json['coinBalance']), + totalRechargedCoin: jsonToInt(json['totalRechargedCoin']), + totalConsumedCoin: jsonToInt(json['totalConsumedCoin']), + totalRefundedCoin: jsonToInt(json['totalRefundedCoin']), + ); + } +} + +/// 一笔金币变动;[changeAmount] 正数入账、负数出账。 +class CoinLedgerEntry { + const CoinLedgerEntry({ + required this.id, + required this.changeAmount, + required this.balanceAfter, + required this.bizType, + required this.remark, + required this.createdAt, + }); + + final String id; + final int changeAmount; + final int balanceAfter; + final String bizType; + final String remark; + final DateTime? createdAt; + + factory CoinLedgerEntry.fromJson(Map json) { + return CoinLedgerEntry( + id: (json['id'] ?? '').toString(), + changeAmount: jsonToInt(json['changeAmount']), + balanceAfter: jsonToInt(json['balanceAfter']), + bizType: json['bizType'] as String? ?? '', + remark: json['remark'] as String? ?? '', + createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''), + ); + } +} + +/// 钱包页一次渲染所需的余额与最近流水。 +class WalletSnapshot { + const WalletSnapshot({required this.wallet, this.ledger = const []}); + + final CoinWallet wallet; + final List ledger; + + factory WalletSnapshot.fromJson(Map json) { + final wallet = json['wallet']; + return WalletSnapshot( + wallet: wallet is Map + ? CoinWallet.fromJson(wallet) + : const CoinWallet( + coinBalance: 0, + totalRechargedCoin: 0, + totalConsumedCoin: 0, + totalRefundedCoin: 0, + ), + ledger: (json['ledger'] as List? ?? const []) + .whereType>() + .map(CoinLedgerEntry.fromJson) + .toList(), + ); + } +} + +/// 金币解锁结果:`POST /app/episodes/{id}/unlock`。 +class UnlockResult { + const UnlockResult({ + required this.unlocked, + required this.accessType, + required this.coinCost, + required this.coinBalance, + }); + + final bool unlocked; + final String accessType; + final int coinCost; + final int coinBalance; + + factory UnlockResult.fromJson(Map json) { + return UnlockResult( + unlocked: json['unlocked'] == true, + accessType: json['accessType'] as String? ?? '', + coinCost: jsonToInt(json['coinCost']), + coinBalance: jsonToInt(json['coinBalance']), + ); + } +} diff --git a/lib/core/data/remote/auth_api.dart b/lib/core/data/remote/auth_api.dart index 175f753..b54855d 100644 --- a/lib/core/data/remote/auth_api.dart +++ b/lib/core/data/remote/auth_api.dart @@ -10,6 +10,67 @@ class AuthApi { final ApiClient _client; + /// 发送短信验证码;[scene] 为 REGISTER / LOGIN / RESET_PWD / BIND。 + Future sendOtp({ + required String target, + required String scene, + required String deviceId, + }) async { + await _client.post( + '/app/auth/otp', + body: { + 'scene': scene, + 'targetType': 'PHONE', + 'target': target, + 'deviceId': deviceId, + }, + ); + } + + /// 手机号 + 验证码登录。 + Future loginWithPhone({ + required String target, + required String code, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + final data = await _client.post( + '/app/auth/login', + body: { + 'targetType': 'PHONE', + 'target': target, + 'code': code, + 'deviceId': deviceId, + 'platform': platform, + 'appVersion': appVersion, + }, + ); + return AuthSession.fromJson((data as Map).cast()); + } + + /// 手机号 + 验证码注册。 + Future registerWithPhone({ + required String target, + required String code, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + final data = await _client.post( + '/app/auth/register', + body: { + 'targetType': 'PHONE', + 'target': target, + 'code': code, + 'deviceId': deviceId, + 'platform': platform, + 'appVersion': appVersion, + }, + ); + return AuthSession.fromJson((data as Map).cast()); + } + Future register({ required String email, required String password, diff --git a/lib/core/data/remote/commerce_api.dart b/lib/core/data/remote/commerce_api.dart new file mode 100644 index 0000000..3dedc68 --- /dev/null +++ b/lib/core/data/remote/commerce_api.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import '../models/wallet.dart'; +import 'api_client.dart'; + +/// 金币与解锁接口,对应契约 §1.8。 +class CommerceApi { + const CommerceApi(this._client); + + final ApiClient _client; + + Future wallet() async { + final data = await _client.get('/app/wallet'); + if (data is Map) return WalletSnapshot.fromJson(data); + throw const ApiException(-1, '返回数据格式异常'); + } + + Future unlock(String episodeId) async { + final data = await _client.post('/app/episodes/$episodeId/unlock'); + if (data is Map) return UnlockResult.fromJson(data); + throw const ApiException(-1, '返回数据格式异常'); + } +} diff --git a/lib/core/data/repositories/commerce_repository.dart b/lib/core/data/repositories/commerce_repository.dart new file mode 100644 index 0000000..93436fc --- /dev/null +++ b/lib/core/data/repositories/commerce_repository.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import '../models/wallet.dart'; +import '../remote/commerce_api.dart'; + +/// 钱包与金币解锁的数据来源。 +abstract interface class CommerceRepository { + Future fetchWallet(); + + Future unlock(String episodeId); +} + +class HttpCommerceRepository implements CommerceRepository { + const HttpCommerceRepository(this._api); + + final CommerceApi _api; + + @override + Future fetchWallet() => _api.wallet(); + + @override + Future unlock(String episodeId) => _api.unlock(episodeId); +} diff --git a/lib/core/data/services/auth_controller.dart b/lib/core/data/services/auth_controller.dart index 32713ba..2ce72dd 100644 --- a/lib/core/data/services/auth_controller.dart +++ b/lib/core/data/services/auth_controller.dart @@ -57,6 +57,43 @@ class AuthController extends ChangeNotifier { await _apply(session); } + Future sendOtp({ + required String target, + String scene = 'LOGIN', + }) async { + await _api.sendOtp( + target: target, + scene: scene, + deviceId: await _store.deviceId(), + ); + } + + Future signInWithPhone({ + required String target, + required String code, + }) async { + final session = await _api.loginWithPhone( + target: target, + code: code, + deviceId: await _store.deviceId(), + platform: _platform, + ); + await _apply(session); + } + + Future registerWithPhone({ + required String target, + required String code, + }) async { + final session = await _api.registerWithPhone( + target: target, + code: code, + deviceId: await _store.deviceId(), + platform: _platform, + ); + await _apply(session); + } + Future signOut() async { final refreshToken = _session?.refreshToken ?? ''; _session = null; diff --git a/lib/core/data/services/auth_session_store.dart b/lib/core/data/services/auth_session_store.dart index e03cc6b..20afccb 100644 --- a/lib/core/data/services/auth_session_store.dart +++ b/lib/core/data/services/auth_session_store.dart @@ -14,6 +14,11 @@ import '../models/auth_session.dart'; /// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**; /// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。 class AuthSessionStore { + AuthSessionStore({SharedPreferencesAsync? preferences}) + : _preferences = preferences ?? SharedPreferencesAsync(); + + final SharedPreferencesAsync _preferences; + static const _kAccessToken = 'auth.accessToken'; static const _kRefreshToken = 'auth.refreshToken'; static const _kUser = 'auth.user'; @@ -23,25 +28,23 @@ class AuthSessionStore { Future deviceId() async { if (_cachedDeviceId != null) return _cachedDeviceId!; - final prefs = await SharedPreferences.getInstance(); - var value = await prefs.getString(_kDeviceId) ?? ''; + var value = await _preferences.getString(_kDeviceId) ?? ''; if (value.isEmpty) { final random = Random(); value = 'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-' '${random.nextInt(1 << 32).toRadixString(36)}'; - await prefs.setString(_kDeviceId, value); + await _preferences.setString(_kDeviceId, value); } _cachedDeviceId = value; return value; } Future read() async { - final prefs = await SharedPreferences.getInstance(); - final accessToken = await prefs.getString(_kAccessToken) ?? ''; - final refreshToken = await prefs.getString(_kRefreshToken) ?? ''; + final accessToken = await _preferences.getString(_kAccessToken) ?? ''; + final refreshToken = await _preferences.getString(_kRefreshToken) ?? ''; if (accessToken.isEmpty || refreshToken.isEmpty) return null; - final rawUser = await prefs.getString(_kUser); + final rawUser = await _preferences.getString(_kUser); Map user = const {}; if (rawUser != null && rawUser.isNotEmpty) { try { @@ -58,17 +61,15 @@ class AuthSessionStore { } Future save(AuthSession session) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kAccessToken, session.accessToken); - await prefs.setString(_kRefreshToken, session.refreshToken); - await prefs.setString(_kUser, jsonEncode(session.user.toJson())); + await _preferences.setString(_kAccessToken, session.accessToken); + await _preferences.setString(_kRefreshToken, session.refreshToken); + await _preferences.setString(_kUser, jsonEncode(session.user.toJson())); } Future clear() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_kAccessToken); - await prefs.remove(_kRefreshToken); - await prefs.remove(_kUser); + await _preferences.remove(_kAccessToken); + await _preferences.remove(_kRefreshToken); + await _preferences.remove(_kUser); // 设备号不清:它代表"这台设备",与登录态无关 } } diff --git a/lib/core/data/services/download_controller.dart b/lib/core/data/services/download_controller.dart index 1e38ef8..b8108e7 100644 --- a/lib/core/data/services/download_controller.dart +++ b/lib/core/data/services/download_controller.dart @@ -1,11 +1,15 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/foundation.dart'; import '../models/download_license.dart'; import '../models/download_policy.dart'; +import '../../config/app_config.dart'; import '../remote/api_exception.dart'; import '../repositories/download_repository.dart'; +import 'download_storage.dart'; /// 一次下载授权申请的结果;后端业务拒绝时保留原始 message。 class DownloadActionResult { @@ -18,9 +22,15 @@ class DownloadActionResult { /// 下载授权与策略状态。 class DownloadController extends ChangeNotifier { - DownloadController(this._repository); + DownloadController(this._repository, {DownloadStorage? storage}) + : _storage = storage ?? FileDownloadStorage(); final DownloadRepository _repository; + final DownloadStorage _storage; + final Map _localPaths = {}; + final Set _downloading = {}; + final Map _progress = {}; + final Map _lastReport = {}; DownloadPolicy? policy; List licenses = const []; @@ -30,6 +40,18 @@ class DownloadController extends ChangeNotifier { String? actionError; bool _disposed = false; + bool isDownloaded(String episodeId) => _localPaths.containsKey(episodeId); + + bool isDownloading(String episodeId) => _downloading.contains(episodeId); + + double progressFor(String episodeId) => _progress[episodeId] ?? 0; + + String? localPathFor(String episodeId) => _localPaths[episodeId]; + + /// 播放器使用:只读已同步的本地缓存;下载页加载后会把磁盘文件补进来。 + Future findLocalPath(String episodeId) async => + _localPaths[episodeId]; + /// 同时拉取策略与当前设备授权列表。 Future load() async { loading = true; @@ -42,6 +64,7 @@ class DownloadController extends ChangeNotifier { ]); policy = results[0] as DownloadPolicy; licenses = results[1] as List; + await _syncLocalFiles(); } catch (_) { error = 'downloadsError'; } finally { @@ -50,6 +73,22 @@ class DownloadController extends ChangeNotifier { } } + Future _syncLocalFiles() async => restore(); + + /// 冷启动时恢复已落盘文件,播放器只依赖这份内存索引。 + Future restore() async { + try { + final paths = await _storage.restore(); + _localPaths.addAll(paths); + for (final id in paths.keys) { + _progress[id] = 1; + } + _notify(); + } catch (_) { + // 本地目录不可用时保持未下载状态。 + } + } + /// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。 Future grant( String episodeId, { @@ -76,6 +115,93 @@ class DownloadController extends ChangeNotifier { } } + /// 下载并落盘:授权已在 [grant] 完成,播放地址缺失时向服务端刷新一次。 + Future startDownload( + String episodeId, { + DownloadLicense? license, + }) async { + if (_downloading.contains(episodeId)) return const DownloadActionResult(ok: true); + final source = license ?? _licenseFor(episodeId); + if (source == null) return const DownloadActionResult(ok: false); + _beginDownload(episodeId); + try { + final resolved = await _resolvePlayUrl(source); + final media = await _storage.download( + episodeId: episodeId, + url: resolved, + onProgress: (received, total) => _onProgress(episodeId, received, total), + ); + _localPaths[episodeId] = media.filePath; + _progress[episodeId] = 1; + unawaited(_report(episodeId, 'COMPLETED', 100, media.bytes, media.bytes)); + return DownloadActionResult(ok: true, license: source); + } catch (_) { + unawaited(_report(episodeId, 'FAILED', 0, null, null, 'downloadFailed')); + actionError = 'downloadFailed'; + return const DownloadActionResult(ok: false); + } finally { + _downloading.remove(episodeId); + _notify(); + } + } + + DownloadLicense? _licenseFor(String episodeId) { + for (final item in licenses) { + if (item.episodeId == episodeId) return item; + } + return null; + } + + void _beginDownload(String episodeId) { + _downloading.add(episodeId); + _progress[episodeId] = 0; + actionError = null; + unawaited(_report(episodeId, 'DOWNLOADING', 0, 0, 0)); + _notify(); + } + + Future _resolvePlayUrl(DownloadLicense license) async { + final current = license.playUrl.isEmpty + ? await _repository.refreshUrl(license.episodeId) + : license; + return AppConfig.mediaUrl(current.playUrl); + } + + void _onProgress(String episodeId, int received, int total) { + final fraction = total > 0 ? received / total : 0.0; + _progress[episodeId] = fraction.clamp(0.0, 1.0); + final now = DateTime.now(); + final last = _lastReport[episodeId]; + if (last == null || now.difference(last) >= const Duration(seconds: 2)) { + _lastReport[episodeId] = now; + final percent = (_progress[episodeId]! * 100).round(); + unawaited(_report(episodeId, 'DOWNLOADING', percent, received, total)); + } + _notify(); + } + + Future _report( + String episodeId, + String status, + int percent, + int? received, + int? total, [ + String? error, + ]) async { + try { + await _repository.reportProgress( + episodeId, + status: status, + progressPercent: percent, + downloadedBytes: received, + totalBytes: total, + errorMsg: error, + ); + } catch (_) { + // 进度上报失败不阻断本地播放,下次打开下载页会重新同步。 + } + } + /// 更新设置,先本地展示,失败恢复服务端旧值。 Future updateSettings({bool? wifiOnly, String? maxQuality}) async { final previous = policy; @@ -109,6 +235,9 @@ class DownloadController extends ChangeNotifier { _notify(); try { await _repository.remove(episodeId); + await _storage.delete(episodeId); + _localPaths.remove(episodeId); + _progress.remove(episodeId); return true; } catch (_) { final restored = [...licenses]; @@ -134,6 +263,9 @@ class DownloadController extends ChangeNotifier { _notify(); try { await _repository.clear(); + await _storage.clear(); + _localPaths.clear(); + _progress.clear(); return true; } catch (_) { licenses = previous; @@ -153,6 +285,10 @@ class DownloadController extends ChangeNotifier { clearing = false; error = null; actionError = null; + _localPaths.clear(); + _downloading.clear(); + _progress.clear(); + _lastReport.clear(); _notify(); } diff --git a/lib/core/data/services/download_storage.dart b/lib/core/data/services/download_storage.dart new file mode 100644 index 0000000..05eb4c8 --- /dev/null +++ b/lib/core/data/services/download_storage.dart @@ -0,0 +1,182 @@ +// Author: fruit.com.cn + +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +/// 下载进度回调:[received] 与 [total] 均为字节数,未知总长时 [total] 为 0。 +typedef DownloadProgress = void Function(int received, int total); + +/// 已落盘的媒体文件。 +class DownloadedMedia { + const DownloadedMedia({ + required this.episodeId, + required this.filePath, + required this.bytes, + }); + + final String episodeId; + final String filePath; + final int bytes; +} + +/// 本地媒体存储边界;页面与控制器不直接操作文件系统。 +abstract interface class DownloadStorage { + Future> restore(); + + Future localPathFor(String episodeId); + + Future download({ + required String episodeId, + required String url, + DownloadProgress? onProgress, + }); + + Future delete(String episodeId); + + Future clear(); +} + +/// 将分集落盘到应用文档目录;临时文件完成后原子改名,避免半文件被播放。 +class FileDownloadStorage implements DownloadStorage { + FileDownloadStorage({ + Future Function()? directoryProvider, + HttpClient Function()? clientFactory, + }) : _directoryProvider = directoryProvider ?? getApplicationDocumentsDirectory, + _clientFactory = clientFactory ?? HttpClient.new; + + final Future Function() _directoryProvider; + final HttpClient Function() _clientFactory; + + static const String _folder = 'downloads'; + static const String _partSuffix = '.part'; + + @override + Future> restore() async { + try { + final directory = await _downloadDirectory(); + final files = {}; + await for (final entity in directory.list()) { + if (entity is! File) continue; + final name = entity.uri.pathSegments.last; + if (name.endsWith(_partSuffix)) continue; + final dot = name.lastIndexOf('.'); + final id = dot > 0 ? name.substring(0, dot) : name; + files[id] = entity.path; + } + return files; + } catch (_) { + return const {}; + } + } + + @override + Future localPathFor(String episodeId) async { + try { + final file = await _findFile(episodeId, includePartial: false); + return file?.path; + } catch (_) { + // 本地目录不可用时视为未下载;真正下载会单独返回错误。 + return null; + } + } + + @override + Future download({ + required String episodeId, + required String url, + DownloadProgress? onProgress, + }) async { + final existing = await _findFile(episodeId, includePartial: false); + if (existing != null) { + final length = await existing.length(); + onProgress?.call(length, length); + return DownloadedMedia( + episodeId: episodeId, + filePath: existing.path, + bytes: length, + ); + } + final directory = await _downloadDirectory(); + final target = File('${directory.path}/${_safe(episodeId)}${_extension(url)}'); + final partial = File('${target.path}$_partSuffix'); + final client = _clientFactory(); + var received = 0; + IOSink? sink; + try { + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url)); + } + sink = partial.openWrite(); + await for (final chunk in response) { + sink.add(chunk); + received += chunk.length; + final total = response.contentLength > 0 ? response.contentLength : 0; + onProgress?.call(received, total); + } + await sink.close(); + sink = null; + if (await target.exists()) await target.delete(); + final file = await partial.rename(target.path); + final bytes = await file.length(); + onProgress?.call(bytes, bytes); + return DownloadedMedia( + episodeId: episodeId, + filePath: file.path, + bytes: bytes, + ); + } catch (_) { + await sink?.close(); + if (await partial.exists()) await partial.delete(); + rethrow; + } finally { + client.close(force: true); + } + } + + @override + Future delete(String episodeId) async { + final file = await _findFile(episodeId, includePartial: true); + if (file != null && await file.exists()) await file.delete(); + } + + @override + Future clear() async { + final directory = await _downloadDirectory(); + await for (final entity in directory.list()) { + if (entity is File) await entity.delete(); + } + } + + Future _findFile( + String episodeId, { + required bool includePartial, + }) async { + final directory = await _downloadDirectory(); + final prefix = _safe(episodeId); + await for (final entity in directory.list()) { + if (entity is! File) continue; + final name = entity.uri.pathSegments.last; + if (!name.startsWith(prefix)) continue; + final isPartial = name.endsWith(_partSuffix); + if (includePartial || !isPartial) return entity; + } + return null; + } + + Future _downloadDirectory() async { + final base = await _directoryProvider(); + return Directory('${base.path}/$_folder').create(recursive: true); + } + + String _safe(String episodeId) => + episodeId.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_'); + + String _extension(String url) { + final path = Uri.tryParse(url)?.path.toLowerCase() ?? ''; + if (path.endsWith('.m3u8')) return '.m3u8'; + return '.mp4'; + } +} diff --git a/lib/core/data/services/video_player_port.dart b/lib/core/data/services/video_player_port.dart index a5f573d..d5d4f40 100644 --- a/lib/core/data/services/video_player_port.dart +++ b/lib/core/data/services/video_player_port.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:io'; + import 'package:video_player/video_player.dart'; import '../../config/app_config.dart'; @@ -21,10 +23,18 @@ class VideoPlayerPort implements PlayerPort { Episode episode, Future? closedCaptionFile, ) { - final options = VideoPlayerOptions(mixWithOthers: false); // 片源统一由后端下发(详情里的可播放地址 / 播放票据) + final uri = resolveUri(episode); + final options = VideoPlayerOptions(mixWithOthers: false); + if (uri.scheme == 'file') { + return VideoPlayerController.file( + File.fromUri(uri), + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: options, + ); + } return VideoPlayerController.networkUrl( - resolveUri(episode), + uri, closedCaptionFile: closedCaptionFile, videoPlayerOptions: options, ); diff --git a/lib/core/data/services/wallet_controller.dart b/lib/core/data/services/wallet_controller.dart new file mode 100644 index 0000000..31d695d --- /dev/null +++ b/lib/core/data/services/wallet_controller.dart @@ -0,0 +1,96 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/wallet.dart'; +import '../remote/api_exception.dart'; +import '../repositories/commerce_repository.dart'; + +/// 钱包状态:余额、最近流水与金币解锁。 +/// +/// 余额是解锁后 UI 的唯一来源;接口失败保留后端 message,页面原样展示。 +class WalletController extends ChangeNotifier { + WalletController(this._repository); + + final CommerceRepository _repository; + + WalletSnapshot? snapshot; + bool loading = false; + bool unlocking = false; + String? error; + String? unlockError; + String? unlockingEpisodeId; + bool _disposed = false; + + int get balance => snapshot?.wallet.coinBalance ?? 0; + List get ledger => snapshot?.ledger ?? const []; + + Future load() async { + loading = true; + error = null; + _notify(); + try { + snapshot = await _repository.fetchWallet(); + } on ApiException catch (exception) { + error = exception.message; + } catch (_) { + error = 'walletLoadFailed'; + } finally { + loading = false; + _notify(); + } + } + + /// 金币解锁;成功后就地更新余额并返回后端结果。 + Future unlock(String episodeId) async { + if (unlocking) return null; + unlocking = true; + unlockingEpisodeId = episodeId; + unlockError = null; + _notify(); + try { + final result = await _repository.unlock(episodeId); + final current = snapshot; + snapshot = WalletSnapshot( + wallet: CoinWallet( + coinBalance: result.coinBalance, + totalRechargedCoin: current?.wallet.totalRechargedCoin ?? 0, + totalConsumedCoin: current?.wallet.totalConsumedCoin ?? 0, + totalRefundedCoin: current?.wallet.totalRefundedCoin ?? 0, + ), + ledger: current?.ledger ?? const [], + ); + return result; + } on ApiException catch (exception) { + unlockError = exception.message; + return null; + } catch (_) { + unlockError = 'unlockFailed'; + return null; + } finally { + unlocking = false; + unlockingEpisodeId = null; + _notify(); + } + } + + void reset() { + snapshot = null; + loading = false; + unlocking = false; + error = null; + unlockError = null; + unlockingEpisodeId = null; + _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/core/widgets/download_action.dart b/lib/core/widgets/download_action.dart index 6da727b..4fd5bab 100644 --- a/lib/core/widgets/download_action.dart +++ b/lib/core/widgets/download_action.dart @@ -20,10 +20,16 @@ Future requestEpisodeDownload( if (!await ensureSignedIn(context)) return; if (!context.mounted) return; unawaited(context.read().lightImpact()); - final result = await context.read().grant(episodeId); + final downloads = context.read(); + final result = await downloads.grant(episodeId); if (!context.mounted) return; final l10n = AppLocalizations.of(context)!; final hasBackendMessage = result.message?.trim().isNotEmpty ?? false; + if (result.ok) { + unawaited( + downloads.startDownload(episodeId, license: result.license), + ); + } final message = result.ok ? l10n.downloadGranted : hasBackendMessage diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b710ee7..eb5a4d0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -476,5 +476,36 @@ "accountCancelDeactivation": "Cancel deletion request", "accountCancelSucceeded": "Deletion request cancelled.", "accountDeactivateFailed": "Could not request account deletion.", - "accountCancelFailed": "Could not cancel the deletion request." + "accountCancelFailed": "Could not cancel the deletion request.", + "authPhoneTitle": "Continue with phone", + "authPhoneSignUp": "Create an account with phone", + "authPhoneLabel": "Phone number", + "authPhoneHint": "0812 3456 7890", + "authPhoneInvalid": "Enter a valid phone number", + "authCodeLabel": "Verification code", + "authCodeHint": "6-digit code", + "authCodeRequired": "Enter the verification code", + "authSendCode": "Send code", + "authResendCode": "Resend in {seconds}s", + "@authResendCode": { "placeholders": { "seconds": { "type": "int" } } }, + "authCodeSent": "Verification code sent.", + "authPhoneSignInAction": "Sign in", + "authPhoneSignUpAction": "Create account", + "unlockWithCoins": "Unlock with coins", + "unlockFailed": "Could not unlock this episode.", + "wallet": "Wallet", + "coinBalance": "Coin balance", + "coinLedger": "Coin activity", + "noCoinLedger": "No coin activity yet.", + "walletLoadFailed": "Could not load your wallet.", + "walletBalanceShort": "{coins} coins", + "@walletBalanceShort": { "placeholders": { "coins": { "type": "int" } } }, + "insufficientCoins": "Not enough coins.", + "rechargeUnavailable": "Top-up is not available yet.", + "totalRecharged": "Total recharged", + "totalConsumed": "Total spent", + "ledgerUnlock": "Episode unlock", + "ledgerRecharge": "Coin top-up", + "ledgerRefund": "Refund", + "ledgerAdjustment": "Adjustment" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 6071753..4efa7a5 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -476,5 +476,34 @@ "accountCancelDeactivation": "Batalkan permintaan penghapusan", "accountCancelSucceeded": "Permintaan penghapusan dibatalkan.", "accountDeactivateFailed": "Permintaan penghapusan gagal dikirim.", - "accountCancelFailed": "Permintaan penghapusan gagal dibatalkan." + "accountCancelFailed": "Permintaan penghapusan gagal dibatalkan.", + "authPhoneTitle": "Lanjutkan dengan nomor HP", + "authPhoneSignUp": "Buat akun dengan nomor HP", + "authPhoneLabel": "Nomor HP", + "authPhoneHint": "0812 3456 7890", + "authPhoneInvalid": "Masukkan nomor HP yang valid", + "authCodeLabel": "Kode verifikasi", + "authCodeHint": "Kode 6 digit", + "authCodeRequired": "Masukkan kode verifikasi", + "authSendCode": "Kirim kode", + "authResendCode": "Kirim ulang dalam {seconds} detik", + "authCodeSent": "Kode verifikasi telah dikirim.", + "authPhoneSignInAction": "Masuk", + "authPhoneSignUpAction": "Buat akun", + "unlockWithCoins": "Buka dengan koin", + "unlockFailed": "Gagal membuka episode ini.", + "wallet": "Dompet", + "coinBalance": "Saldo koin", + "coinLedger": "Riwayat koin", + "noCoinLedger": "Belum ada aktivitas koin.", + "walletLoadFailed": "Gagal memuat dompet.", + "walletBalanceShort": "{coins} koin", + "insufficientCoins": "Koin tidak cukup.", + "rechargeUnavailable": "Isi ulang belum tersedia.", + "totalRecharged": "Total isi ulang", + "totalConsumed": "Total pemakaian", + "ledgerUnlock": "Buka episode", + "ledgerRecharge": "Isi ulang koin", + "ledgerRefund": "Pengembalian", + "ledgerAdjustment": "Penyesuaian" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 63446d1..b0f0c4f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2121,6 +2121,180 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Could not cancel the deletion request.'** String get accountCancelFailed; + + /// No description provided for @authPhoneTitle. + /// + /// In en, this message translates to: + /// **'Continue with phone'** + String get authPhoneTitle; + + /// No description provided for @authPhoneSignUp. + /// + /// In en, this message translates to: + /// **'Create an account with phone'** + String get authPhoneSignUp; + + /// No description provided for @authPhoneLabel. + /// + /// In en, this message translates to: + /// **'Phone number'** + String get authPhoneLabel; + + /// No description provided for @authPhoneHint. + /// + /// In en, this message translates to: + /// **'0812 3456 7890'** + String get authPhoneHint; + + /// No description provided for @authPhoneInvalid. + /// + /// In en, this message translates to: + /// **'Enter a valid phone number'** + String get authPhoneInvalid; + + /// No description provided for @authCodeLabel. + /// + /// In en, this message translates to: + /// **'Verification code'** + String get authCodeLabel; + + /// No description provided for @authCodeHint. + /// + /// In en, this message translates to: + /// **'6-digit code'** + String get authCodeHint; + + /// No description provided for @authCodeRequired. + /// + /// In en, this message translates to: + /// **'Enter the verification code'** + String get authCodeRequired; + + /// No description provided for @authSendCode. + /// + /// In en, this message translates to: + /// **'Send code'** + String get authSendCode; + + /// No description provided for @authResendCode. + /// + /// In en, this message translates to: + /// **'Resend in {seconds}s'** + String authResendCode(int seconds); + + /// No description provided for @authCodeSent. + /// + /// In en, this message translates to: + /// **'Verification code sent.'** + String get authCodeSent; + + /// No description provided for @authPhoneSignInAction. + /// + /// In en, this message translates to: + /// **'Sign in'** + String get authPhoneSignInAction; + + /// No description provided for @authPhoneSignUpAction. + /// + /// In en, this message translates to: + /// **'Create account'** + String get authPhoneSignUpAction; + + /// No description provided for @unlockWithCoins. + /// + /// In en, this message translates to: + /// **'Unlock with coins'** + String get unlockWithCoins; + + /// No description provided for @unlockFailed. + /// + /// In en, this message translates to: + /// **'Could not unlock this episode.'** + String get unlockFailed; + + /// No description provided for @wallet. + /// + /// In en, this message translates to: + /// **'Wallet'** + String get wallet; + + /// No description provided for @coinBalance. + /// + /// In en, this message translates to: + /// **'Coin balance'** + String get coinBalance; + + /// No description provided for @coinLedger. + /// + /// In en, this message translates to: + /// **'Coin activity'** + String get coinLedger; + + /// No description provided for @noCoinLedger. + /// + /// In en, this message translates to: + /// **'No coin activity yet.'** + String get noCoinLedger; + + /// No description provided for @walletLoadFailed. + /// + /// In en, this message translates to: + /// **'Could not load your wallet.'** + String get walletLoadFailed; + + /// No description provided for @walletBalanceShort. + /// + /// In en, this message translates to: + /// **'{coins} coins'** + String walletBalanceShort(int coins); + + /// No description provided for @insufficientCoins. + /// + /// In en, this message translates to: + /// **'Not enough coins.'** + String get insufficientCoins; + + /// No description provided for @rechargeUnavailable. + /// + /// In en, this message translates to: + /// **'Top-up is not available yet.'** + String get rechargeUnavailable; + + /// No description provided for @totalRecharged. + /// + /// In en, this message translates to: + /// **'Total recharged'** + String get totalRecharged; + + /// No description provided for @totalConsumed. + /// + /// In en, this message translates to: + /// **'Total spent'** + String get totalConsumed; + + /// No description provided for @ledgerUnlock. + /// + /// In en, this message translates to: + /// **'Episode unlock'** + String get ledgerUnlock; + + /// No description provided for @ledgerRecharge. + /// + /// In en, this message translates to: + /// **'Coin top-up'** + String get ledgerRecharge; + + /// No description provided for @ledgerRefund. + /// + /// In en, this message translates to: + /// **'Refund'** + String get ledgerRefund; + + /// No description provided for @ledgerAdjustment. + /// + /// In en, this message translates to: + /// **'Adjustment'** + String get ledgerAdjustment; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f777db2..2682cea 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1083,4 +1083,95 @@ class AppLocalizationsEn extends AppLocalizations { @override String get accountCancelFailed => 'Could not cancel the deletion request.'; + + @override + String get authPhoneTitle => 'Continue with phone'; + + @override + String get authPhoneSignUp => 'Create an account with phone'; + + @override + String get authPhoneLabel => 'Phone number'; + + @override + String get authPhoneHint => '0812 3456 7890'; + + @override + String get authPhoneInvalid => 'Enter a valid phone number'; + + @override + String get authCodeLabel => 'Verification code'; + + @override + String get authCodeHint => '6-digit code'; + + @override + String get authCodeRequired => 'Enter the verification code'; + + @override + String get authSendCode => 'Send code'; + + @override + String authResendCode(int seconds) { + return 'Resend in ${seconds}s'; + } + + @override + String get authCodeSent => 'Verification code sent.'; + + @override + String get authPhoneSignInAction => 'Sign in'; + + @override + String get authPhoneSignUpAction => 'Create account'; + + @override + String get unlockWithCoins => 'Unlock with coins'; + + @override + String get unlockFailed => 'Could not unlock this episode.'; + + @override + String get wallet => 'Wallet'; + + @override + String get coinBalance => 'Coin balance'; + + @override + String get coinLedger => 'Coin activity'; + + @override + String get noCoinLedger => 'No coin activity yet.'; + + @override + String get walletLoadFailed => 'Could not load your wallet.'; + + @override + String walletBalanceShort(int coins) { + return '$coins coins'; + } + + @override + String get insufficientCoins => 'Not enough coins.'; + + @override + String get rechargeUnavailable => 'Top-up is not available yet.'; + + @override + String get totalRecharged => 'Total recharged'; + + @override + String get totalConsumed => 'Total spent'; + + @override + String get ledgerUnlock => 'Episode unlock'; + + @override + String get ledgerRecharge => 'Coin top-up'; + + @override + String get ledgerRefund => 'Refund'; + + @override + String get ledgerAdjustment => 'Adjustment'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a417c57..66cc4bc 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1082,4 +1082,95 @@ class AppLocalizationsId extends AppLocalizations { @override String get accountCancelFailed => 'Permintaan penghapusan gagal dibatalkan.'; + + @override + String get authPhoneTitle => 'Lanjutkan dengan nomor HP'; + + @override + String get authPhoneSignUp => 'Buat akun dengan nomor HP'; + + @override + String get authPhoneLabel => 'Nomor HP'; + + @override + String get authPhoneHint => '0812 3456 7890'; + + @override + String get authPhoneInvalid => 'Masukkan nomor HP yang valid'; + + @override + String get authCodeLabel => 'Kode verifikasi'; + + @override + String get authCodeHint => 'Kode 6 digit'; + + @override + String get authCodeRequired => 'Masukkan kode verifikasi'; + + @override + String get authSendCode => 'Kirim kode'; + + @override + String authResendCode(int seconds) { + return 'Kirim ulang dalam $seconds detik'; + } + + @override + String get authCodeSent => 'Kode verifikasi telah dikirim.'; + + @override + String get authPhoneSignInAction => 'Masuk'; + + @override + String get authPhoneSignUpAction => 'Buat akun'; + + @override + String get unlockWithCoins => 'Buka dengan koin'; + + @override + String get unlockFailed => 'Gagal membuka episode ini.'; + + @override + String get wallet => 'Dompet'; + + @override + String get coinBalance => 'Saldo koin'; + + @override + String get coinLedger => 'Riwayat koin'; + + @override + String get noCoinLedger => 'Belum ada aktivitas koin.'; + + @override + String get walletLoadFailed => 'Gagal memuat dompet.'; + + @override + String walletBalanceShort(int coins) { + return '$coins koin'; + } + + @override + String get insufficientCoins => 'Koin tidak cukup.'; + + @override + String get rechargeUnavailable => 'Isi ulang belum tersedia.'; + + @override + String get totalRecharged => 'Total isi ulang'; + + @override + String get totalConsumed => 'Total pemakaian'; + + @override + String get ledgerUnlock => 'Buka episode'; + + @override + String get ledgerRecharge => 'Isi ulang koin'; + + @override + String get ledgerRefund => 'Pengembalian'; + + @override + String get ledgerAdjustment => 'Penyesuaian'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ea78f10..db49ae5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1061,4 +1061,95 @@ class AppLocalizationsZh extends AppLocalizations { @override String get accountCancelFailed => '注销申请撤销失败。'; + + @override + String get authPhoneTitle => '使用手机号继续'; + + @override + String get authPhoneSignUp => '使用手机号注册'; + + @override + String get authPhoneLabel => '手机号'; + + @override + String get authPhoneHint => '0812 3456 7890'; + + @override + String get authPhoneInvalid => '请输入有效手机号'; + + @override + String get authCodeLabel => '验证码'; + + @override + String get authCodeHint => '6 位验证码'; + + @override + String get authCodeRequired => '请输入验证码'; + + @override + String get authSendCode => '发送验证码'; + + @override + String authResendCode(int seconds) { + return '$seconds 秒后重发'; + } + + @override + String get authCodeSent => '验证码已发送。'; + + @override + String get authPhoneSignInAction => '登录'; + + @override + String get authPhoneSignUpAction => '注册'; + + @override + String get unlockWithCoins => '金币解锁'; + + @override + String get unlockFailed => '解锁失败,请稍后重试。'; + + @override + String get wallet => '钱包'; + + @override + String get coinBalance => '金币余额'; + + @override + String get coinLedger => '金币流水'; + + @override + String get noCoinLedger => '暂无金币流水。'; + + @override + String get walletLoadFailed => '钱包加载失败。'; + + @override + String walletBalanceShort(int coins) { + return '$coins 金币'; + } + + @override + String get insufficientCoins => '金币余额不足。'; + + @override + String get rechargeUnavailable => '充值暂未开放。'; + + @override + String get totalRecharged => '累计充值'; + + @override + String get totalConsumed => '累计消费'; + + @override + String get ledgerUnlock => '剧集解锁'; + + @override + String get ledgerRecharge => '金币充值'; + + @override + String get ledgerRefund => '退款'; + + @override + String get ledgerAdjustment => '余额调整'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5267de7..5b45d6c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -438,5 +438,34 @@ "accountCancelDeactivation": "撤销注销申请", "accountCancelSucceeded": "注销申请已撤销。", "accountDeactivateFailed": "注销申请提交失败。", - "accountCancelFailed": "注销申请撤销失败。" + "accountCancelFailed": "注销申请撤销失败。", + "authPhoneTitle": "使用手机号继续", + "authPhoneSignUp": "使用手机号注册", + "authPhoneLabel": "手机号", + "authPhoneHint": "0812 3456 7890", + "authPhoneInvalid": "请输入有效手机号", + "authCodeLabel": "验证码", + "authCodeHint": "6 位验证码", + "authCodeRequired": "请输入验证码", + "authSendCode": "发送验证码", + "authResendCode": "{seconds} 秒后重发", + "authCodeSent": "验证码已发送。", + "authPhoneSignInAction": "登录", + "authPhoneSignUpAction": "注册", + "unlockWithCoins": "金币解锁", + "unlockFailed": "解锁失败,请稍后重试。", + "wallet": "钱包", + "coinBalance": "金币余额", + "coinLedger": "金币流水", + "noCoinLedger": "暂无金币流水。", + "walletLoadFailed": "钱包加载失败。", + "walletBalanceShort": "{coins} 金币", + "insufficientCoins": "金币余额不足。", + "rechargeUnavailable": "充值暂未开放。", + "totalRecharged": "累计充值", + "totalConsumed": "累计消费", + "ledgerUnlock": "剧集解锁", + "ledgerRecharge": "金币充值", + "ledgerRefund": "退款", + "ledgerAdjustment": "余额调整" } diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 5ddbde0..0edfe59 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -13,6 +13,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; import 'auth_logic.dart'; +import 'phone_auth_sheet.dart'; part 'auth_widgets.dart'; part 'auth_form_widgets.dart'; @@ -334,22 +335,13 @@ class _AuthPageState extends State { style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11), ), const SizedBox(height: 22), - _AuthEntryButton( - key: const Key('auth-entry-google'), - label: 'G ${l10n.authContinueGoogle}', - background: Colors.white, - foreground: const Color(0xFF1A1A1A), - leadingColor: const Color(0xFF4285F4), - onTap: () => _comingSoon(l10n), - ), - const SizedBox(height: 11), _AuthEntryButton( key: const Key('auth-entry-phone'), label: l10n.authPhoneSignIn, icon: Icons.phone_android_rounded, background: AppColors.input, foreground: AppColors.onSurface, - onTap: () => _comingSoon(l10n), + onTap: _openPhoneSheet, ), const SizedBox(height: 11), _AuthEntryButton( @@ -374,10 +366,12 @@ class _AuthPageState extends State { ], ); - void _comingSoon(AppLocalizations l10n) { - ScaffoldMessenger.of( + Future _openPhoneSheet() async { + final signedIn = await showPhoneAuthSheet( context, - ).showSnackBar(SnackBar(content: Text(l10n.comingSoon))); + signUp: _logic.signUpMode, + ); + if (signedIn == true && mounted) Navigator.of(context).maybePop(); } Widget _authCard(AppLocalizations l10n) => _AuthCard( @@ -427,7 +421,6 @@ class _AuthEntryButton extends StatelessWidget { required this.foreground, required this.onTap, this.icon, - this.leadingColor, }); final String label; @@ -435,7 +428,6 @@ class _AuthEntryButton extends StatelessWidget { final Color foreground; final VoidCallback onTap; final IconData? icon; - final Color? leadingColor; @override Widget build(BuildContext context) { @@ -451,7 +443,7 @@ class _AuthEntryButton extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ if (icon != null) - Icon(icon, size: 16, color: leadingColor ?? foreground), + Icon(icon, size: 16, color: foreground), if (icon != null) const SizedBox(width: 7), Flexible( child: Text( diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart new file mode 100644 index 0000000..541e830 --- /dev/null +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -0,0 +1,358 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/remote/api_client.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/platform/haptics.dart'; +import '../../core/theme/app_colors.dart'; +import '../../l10n/app_localizations.dart'; + +const double _fieldHeight = 52; +const double _fieldRadius = 14; +const double _sheetRadius = 28; +const double _buttonHeight = 52; +const int _resendSeconds = 60; +const int _phoneMinLength = 8; +const int _codeMinLength = 4; + +/// 打开手机号 OTP 登录/注册表单;成功登录后返回 true。 +Future showPhoneAuthSheet(BuildContext context, {bool signUp = false}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.black.withValues(alpha: 0.62), + builder: (_) => PhoneAuthSheet(initialSignUp: signUp), + ); +} + +class PhoneAuthSheet extends StatefulWidget { + const PhoneAuthSheet({super.key, this.initialSignUp = false}); + + final bool initialSignUp; + + @override + State createState() => _PhoneAuthSheetState(); +} + +class _PhoneAuthSheetState extends State { + late final TextEditingController _phone; + late final TextEditingController _code; + late final FocusNode _phoneFocus; + late final FocusNode _codeFocus; + Timer? _countdown; + bool _signUp = false; + bool _sending = false; + bool _submitting = false; + int _remaining = 0; + String? _error; + + @override + void initState() { + super.initState(); + _signUp = widget.initialSignUp; + _phone = TextEditingController(); + _code = TextEditingController(); + _phoneFocus = FocusNode(debugLabel: 'phone-auth-number'); + _codeFocus = FocusNode(debugLabel: 'phone-auth-code'); + } + + @override + void dispose() { + _countdown?.cancel(); + _phone.dispose(); + _code.dispose(); + _phoneFocus.dispose(); + _codeFocus.dispose(); + super.dispose(); + } + + String get _target => _phone.text.replaceAll(RegExp(r'[\s-]'), ''); + + bool get _phoneValid => _target.length >= _phoneMinLength; + + Future _sendCode() async { + if (_sending || _remaining > 0) return; + final l10n = AppLocalizations.of(context)!; + if (!_phoneValid) { + setState(() => _error = l10n.authPhoneInvalid); + return; + } + setState(() { + _sending = true; + _error = null; + }); + final haptics = context.read(); + final auth = context.read(); + await haptics.lightImpact(); + try { + await auth.sendOtp( + target: _target, + scene: _signUp ? 'REGISTER' : 'LOGIN', + ); + if (!mounted) return; + _startCountdown(); + setState(() => _error = null); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); + _codeFocus.requestFocus(); + } on ApiException catch (error) { + if (mounted) setState(() => _error = error.message); + } catch (_) { + if (mounted) setState(() => _error = l10n.authNetworkError); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + void _startCountdown() { + _countdown?.cancel(); + _remaining = _resendSeconds; + _countdown = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return timer.cancel(); + setState(() => _remaining--); + if (_remaining <= 0) timer.cancel(); + }); + } + + Future _submit() async { + if (_submitting) return; + final l10n = AppLocalizations.of(context)!; + if (!_phoneValid) { + setState(() => _error = l10n.authPhoneInvalid); + return; + } + if (_code.text.trim().length < _codeMinLength) { + setState(() => _error = l10n.authCodeRequired); + return; + } + setState(() { + _submitting = true; + _error = null; + }); + final haptics = context.read(); + final auth = context.read(); + await haptics.lightImpact(); + try { + if (_signUp) { + await auth.registerWithPhone(target: _target, code: _code.text.trim()); + } else { + await auth.signInWithPhone(target: _target, code: _code.text.trim()); + } + if (mounted) Navigator.of(context).pop(true); + } on ApiException catch (error) { + if (mounted) setState(() => _error = error.message); + } catch (_) { + if (mounted) setState(() => _error = l10n.authNetworkError); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _switchMode() { + setState(() { + _signUp = !_signUp; + _error = null; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final bottom = MediaQuery.viewInsetsOf(context).bottom; + return Padding( + padding: EdgeInsets.only(bottom: bottom), + child: Container( + key: const Key('phone-auth-sheet'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(22, 10, 22, 26), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center(child: _grabber()), + const SizedBox(height: 16), + Text( + _signUp ? l10n.authPhoneSignUp : l10n.authPhoneTitle, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 22, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + l10n.authSubtitle, + style: const TextStyle(color: AppColors.subtle, fontSize: 12), + ), + const SizedBox(height: 20), + _phoneField(l10n), + const SizedBox(height: 12), + _codeField(l10n), + if (_error != null) ...[ + const SizedBox(height: 10), + Text( + _error!, + key: const Key('auth-phone-error'), + style: const TextStyle(color: AppColors.accent, fontSize: 12), + ), + ], + const SizedBox(height: 18), + _submitButton(l10n), + const SizedBox(height: 8), + Center( + child: TextButton( + key: const Key('auth-phone-switch'), + onPressed: _submitting ? null : _switchMode, + style: TextButton.styleFrom( + foregroundColor: AppColors.blueSoft, + minimumSize: const Size(48, 48), + ), + child: Text( + _signUp ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _grabber() => Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.subtle.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ); + + Widget _phoneField(AppLocalizations l10n) => SizedBox( + height: _fieldHeight, + child: TextField( + key: const Key('auth-phone-number'), + controller: _phone, + focusNode: _phoneFocus, + enabled: !_submitting, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.telephoneNumber], + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9+\s-]')), + LengthLimitingTextInputFormatter(20), + ], + onSubmitted: (_) => _codeFocus.requestFocus(), + onChanged: (_) => setState(() => _error = null), + style: const TextStyle(color: AppColors.onSurface, fontSize: 15), + decoration: _decoration(l10n.authPhoneLabel, l10n.authPhoneHint), + ), + ); + + Widget _codeField(AppLocalizations l10n) => SizedBox( + height: _fieldHeight, + child: TextField( + key: const Key('auth-phone-code'), + controller: _code, + focusNode: _codeFocus, + enabled: !_submitting, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.oneTimeCode], + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + onSubmitted: (_) => _submit(), + onChanged: (_) => setState(() => _error = null), + style: const TextStyle(color: AppColors.onSurface, fontSize: 15), + decoration: _decoration(l10n.authCodeLabel, l10n.authCodeHint).copyWith( + suffixIcon: _sendButton(l10n), + ), + ), + ); + + Widget _sendButton(AppLocalizations l10n) => Padding( + padding: const EdgeInsets.only(right: 6), + child: TextButton( + key: const Key('auth-send-code'), + onPressed: _sending || _remaining > 0 ? null : _sendCode, + style: TextButton.styleFrom( + foregroundColor: AppColors.blueSoft, + minimumSize: const Size(48, 48), + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + child: _sending + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + _remaining > 0 + ? l10n.authResendCode(_remaining) + : l10n.authSendCode, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + ), + ); + + Widget _submitButton(AppLocalizations l10n) => SizedBox( + width: double.infinity, + height: _buttonHeight, + child: FilledButton( + key: const Key('auth-phone-submit'), + onPressed: _submitting ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_buttonHeight / 2), + ), + ), + child: _submitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.onPrimary, + ), + ) + : Text( + _signUp ? l10n.authPhoneSignUpAction : l10n.authPhoneSignInAction, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w800), + ), + ), + ); + + InputDecoration _decoration(String label, String hint) => InputDecoration( + labelText: label, + hintText: hint, + labelStyle: const TextStyle(color: AppColors.subtle), + hintStyle: const TextStyle(color: AppColors.subtle), + filled: true, + fillColor: AppColors.input, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(_fieldRadius), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(_fieldRadius), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + ); +} diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index ba7aff8..ea53383 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../../core/data/models/download_license.dart'; import '../../core/data/models/download_policy.dart'; +import '../../core/data/models/drama.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/formatting/error_text.dart'; @@ -18,6 +19,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; +import '../player/player_view.dart'; import 'downloads_binding.dart'; /// 下载授权与状态管理页;本批不落盘,只管理服务端授权。 @@ -135,7 +137,12 @@ class _DownloadsBody extends StatelessWidget { for (final license in downloads.licenses) ...[ _LicenseTile( license: license, + downloads: downloads, onRemove: () => _remove(context, license.episodeId), + onPlay: () => _playOffline(context, license), + onDownload: () => unawaited( + downloads.startDownload(license.episodeId, license: license), + ), ), const SizedBox(height: 10), ], @@ -144,6 +151,32 @@ class _DownloadsBody extends StatelessWidget { ); } + void _playOffline(BuildContext context, DownloadLicense license) { + final drama = Drama( + id: license.dramaId, + title: license.dramaTitle, + synopsis: '', + posterUrl: '', + episodes: [ + Episode( + id: license.episodeId, + number: license.episodeNo, + title: 'Episode ${license.episodeNo}', + isFree: true, + ), + ], + ); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage( + drama: drama, + initialIndex: 0, + downloads: downloads, + ), + ), + ); + } + Future _remove(BuildContext context, String episodeId) async { unawaited(context.read().mediumImpact()); final ok = await downloads.remove(episodeId); @@ -302,14 +335,35 @@ class _PolicyPanel extends StatelessWidget { } class _LicenseTile extends StatelessWidget { - const _LicenseTile({required this.license, required this.onRemove}); + const _LicenseTile({ + required this.license, + required this.downloads, + required this.onRemove, + required this.onPlay, + required this.onDownload, + }); final DownloadLicense license; + final DownloadController downloads; final VoidCallback onRemove; + final VoidCallback onPlay; + final VoidCallback onDownload; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final downloaded = downloads.isDownloaded(license.episodeId); + final downloading = downloads.isDownloading(license.episodeId); + final progress = downloaded + ? 1.0 + : downloading + ? downloads.progressFor(license.episodeId) + : license.progressPercent / 100; + final taskStatus = downloaded + ? 'COMPLETED' + : downloading + ? 'DOWNLOADING' + : license.taskStatus; return Container( key: ValueKey('download-license-${license.episodeId}'), padding: const EdgeInsets.all(12), @@ -335,6 +389,20 @@ class _LicenseTile extends StatelessWidget { ), ), Text(l10n.episodeLabel(license.episodeNo)), + if (downloaded) + IconButton( + key: ValueKey('download-play-${license.episodeId}'), + tooltip: l10n.play, + onPressed: onPlay, + icon: const Icon(Icons.play_circle_outline_rounded), + ) + else if (!downloading) + IconButton( + key: ValueKey('download-start-${license.episodeId}'), + tooltip: l10n.downloads, + onPressed: onDownload, + icon: const Icon(Icons.download_rounded), + ), IconButton( key: ValueKey('download-remove-${license.episodeId}'), tooltip: l10n.remove, @@ -352,7 +420,7 @@ class _LicenseTile extends StatelessWidget { style: const TextStyle(color: AppColors.blueSoft, fontSize: 12), ), Text( - l10n.downloadTaskStatus(license.taskStatus), + l10n.downloadTaskStatus(taskStatus), style: const TextStyle(color: AppColors.muted, fontSize: 12), ), Text( @@ -371,7 +439,7 @@ class _LicenseTile extends StatelessWidget { borderRadius: BorderRadius.circular(AppDimens.progressRadius), child: LinearProgressIndicator( minHeight: AppDimens.progressHeight, - value: (license.progressPercent / 100).clamp(0.0, 1.0), + value: progress.clamp(0.0, 1.0), backgroundColor: Colors.white24, valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), ), diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index 128dc7f..9dc74df 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/watch_progress.dart'; @@ -9,6 +10,8 @@ import '../../core/data/remote/api_client.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../player/player_view.dart'; class DramaDetailLogic extends ChangeNotifier { @@ -104,7 +107,12 @@ class DramaDetailLogic extends ChangeNotifier { Future openPlayer(BuildContext context, int index) async { await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => PlayerPage(drama: drama, initialIndex: index), + builder: (_) => PlayerPage( + drama: drama, + initialIndex: index, + wallet: context.read(), + downloads: context.read(), + ), ), ); await loadContinue(); diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index 307b42c..f2e5fec 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -5,6 +5,8 @@ import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../discover/discover_logic.dart'; import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_logic.dart'; @@ -94,7 +96,12 @@ class HomeLogic extends ChangeNotifier { if (!context.mounted) return; Navigator.of(context).push( MaterialPageRoute( - builder: (_) => PlayerPage(drama: playable, initialIndex: 0), + builder: (_) => PlayerPage( + drama: playable, + initialIndex: 0, + wallet: context.read(), + downloads: context.read(), + ), ), ); } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 2c9377c..9a45785 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -10,6 +10,8 @@ import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; @@ -200,6 +202,8 @@ class _HomeTabsState extends State<_HomeTabs> { onSearch: () => logic.select(1, search: true), onMenu: () => _openMenu(context), onImmersiveChanged: logic.onImmersiveChanged, + wallet: context.read(), + downloads: context.read(), ), DiscoverPage( drama: logic.currentDrama, @@ -229,6 +233,7 @@ class _HomeTabsState extends State<_HomeTabs> { signedIn: signedIn, onSignIn: () => openAuthPage(context), onSignOut: () => context.read().signOut(), + wallet: context.read(), ), ], ), diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index bc1e7a5..174397f 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -13,6 +13,7 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/interaction_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/theme/app_colors.dart'; @@ -25,6 +26,7 @@ import '../account/account_entry_card.dart'; import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; import '../notices/notices_page.dart'; +import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; @@ -46,6 +48,7 @@ class MyPage extends StatefulWidget { this.onSignOut, this.dramas, this.onDramaSelected, + this.wallet, this.debugShowSetting = kDebugMode, }); @@ -63,6 +66,7 @@ class MyPage extends StatefulWidget { final VoidCallback? onSignOut; final List? dramas; final ValueChanged? onDramaSelected; + final WalletController? wallet; final bool debugShowSetting; @override @@ -308,9 +312,21 @@ class _MyPageState extends State { } Widget _wallet(AppLocalizations l10n) { + final wallet = widget.wallet; + if (wallet == null) return _walletCard(l10n, null); + return ListenableBuilder( + listenable: wallet, + builder: (context, _) => _walletCard(l10n, wallet), + ); + } + + Widget _walletCard(AppLocalizations l10n, WalletController? wallet) { + final value = wallet == null + ? l10n.coins + : l10n.walletBalanceShort(wallet.balance); return InkWell( key: const Key('mine-wallet'), - onTap: () => _mockSnack(l10n), + onTap: () => _openWallet(context), borderRadius: BorderRadius.circular(13), child: Container( padding: const EdgeInsets.all(12), @@ -340,7 +356,7 @@ class _MyPageState extends State { ), const SizedBox(height: 2), Text( - '0 ${l10n.coins} · ${l10n.topUpWallet}', + widget.signedIn ? value : l10n.logInHint, style: const TextStyle( color: AppColors.accent, fontSize: 9, @@ -360,6 +376,16 @@ class _MyPageState extends State { ); } + void _openWallet(BuildContext context) { + if (!widget.signedIn) { + widget.onSignIn?.call(); + return; + } + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const WalletPage())); + } + WatchProgress? get _continueRecord { for (final record in logic.recent) { if (!record.finished && record.hasPosition) return record; @@ -934,12 +960,6 @@ class _MyPageState extends State { } } - void _mockSnack(AppLocalizations l10n) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.mockNotice))); - } - Widget _poster( Drama drama, { required double width, diff --git a/lib/pages/player/player_binding.dart b/lib/pages/player/player_binding.dart index 873901d..088245a 100644 --- a/lib/pages/player/player_binding.dart +++ b/lib/pages/player/player_binding.dart @@ -3,15 +3,21 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; import 'player_logic.dart'; class PlayerBinding { PlayerLogic create({ required WatchHistoryRepository history, required PlaybackRepository playback, + WalletController? wallet, + DownloadController? downloads, }) => PlayerLogic( history: history, playback: playback, pictureInPicture: AndroidPictureInPicture(), + wallet: wallet, + downloads: downloads, ); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 321a3b6..910aff0 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -14,10 +14,12 @@ import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/download_controller.dart'; import '../../core/data/services/platform_share_service.dart'; import '../../core/data/services/playback_queue.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; @@ -37,11 +39,15 @@ class PlayerLogic extends ChangeNotifier { required this.history, required this.playback, required this.pictureInPicture, + this.wallet, + this.downloads, }); final WatchHistoryRepository history; final PlaybackRepository playback; final AndroidPictureInPicture pictureInPicture; + final WalletController? wallet; + final DownloadController? downloads; late PageController pages; late PlaybackQueue queue; @@ -75,6 +81,10 @@ class PlayerLogic extends ChangeNotifier { bool pipPreparing = false; final Map pickedEpisode = {}; + /// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。 + String? unlockingEpisodeId; + String? unlockError; + bool Function() isMounted = () => false; bool homeMode = false; bool embedded = false; @@ -194,7 +204,7 @@ class PlayerLogic extends ChangeNotifier { /// 票据门:起播前先拿票(命中未过期缓存就不会重复请求), /// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。 - Future ensureTicket(int index) async { + Future ensureTicket(int index, {bool forceRefresh = false}) async { if (index < 0 || index >= sessionEpisodes.length) return false; final episode = sessionEpisodes[index]; final request = pageRequest; @@ -202,7 +212,18 @@ class PlayerLogic extends ChangeNotifier { ticketError = null; notify(); try { - final ticket = await playback.ticketFor(episode.id); + final localPath = await downloads?.findLocalPath(episode.id); + if (request != pageRequest) return false; + if (localPath != null) { + blockedTicket = null; + blockedEpisodeId = null; + sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath)); + return true; + } + final ticket = await playback.ticketFor( + episode.id, + forceRefresh: forceRefresh, + ); if (request != pageRequest) return false; blockedTicket = null; blockedEpisodeId = null; @@ -231,6 +252,9 @@ class PlayerLogic extends ChangeNotifier { } } + String _fileUrl(String path) => + path.startsWith('file:') ? path : Uri.file(path).toString(); + /// 重试当前集的票据(失败态的"重试"按钮)。 Future retryTicket() async { ticketError = null; @@ -238,6 +262,36 @@ class PlayerLogic extends ChangeNotifier { unawaited(selectEpisode(session.currentIndex)); } + /// 金币解锁当前集;成功后强制刷新票据并直接起播。 + Future unlockEpisode(int index) async { + final service = wallet; + if (service == null || index < 0 || index >= sessionEpisodes.length) { + return false; + } + final episode = sessionEpisodes[index]; + unlockingEpisodeId = episode.id; + unlockError = null; + notify(); + try { + final result = await service.unlock(episode.id); + if (result == null || !result.unlocked) { + unlockError = service.unlockError; + return false; + } + final ready = await ensureTicket(index, forceRefresh: true); + if (ready) { + await session.select( + index, + preloadIndex: nextVisibleIndex(index), + ); + } + return ready; + } finally { + unlockingEpisodeId = null; + if (isMounted()) notify(); + } + } + void onPageUpdated(PlayerPage previous) { if (previous.active != active) { session.setRouteVisible(active && visibleIndices.isNotEmpty); @@ -562,7 +616,12 @@ class PlayerLogic extends ChangeNotifier { if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => PlayerPage(drama: drama, initialIndex: index), + builder: (_) => PlayerPage( + drama: drama, + initialIndex: index, + wallet: wallet, + downloads: downloads, + ), ), ); if (isMounted()) session.setRouteVisible(active); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 20ac336..5e9a109 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -10,9 +10,11 @@ import 'package:video_player/video_player.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/play_ticket.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/download_controller.dart'; import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/platform/haptics.dart'; @@ -48,6 +50,8 @@ class PlayerPage extends StatefulWidget { this.feedDramas, this.onDramaChanged, this.onImmersiveChanged, + this.wallet, + this.downloads, }); final Drama drama; @@ -64,6 +68,8 @@ class PlayerPage extends StatefulWidget { final List? feedDramas; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; + final WalletController? wallet; + final DownloadController? downloads; @override State createState() => _PlayerPageState(); @@ -80,6 +86,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { logic = PlayerBinding().create( history: context.read(), playback: context.read(), + wallet: widget.wallet, + downloads: widget.downloads, ); logic.attach(widget, isMounted: () => mounted); WidgetsBinding.instance.addObserver(this); @@ -285,6 +293,11 @@ class _PlayerPageState extends State with WidgetsBindingObserver { episode: episode, accessType: logic.blockedTicket?.accessType, unlockCoin: logic.blockedTicket?.unlockCoin ?? 0, + unlocking: logic.unlockingEpisodeId == episode.id, + error: logic.unlockError, + onUnlock: logic.wallet == null + ? null + : () => _unlockEpisode(context, index), ), // 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程) if (selected && @@ -484,6 +497,21 @@ class _PlayerPageState extends State with WidgetsBindingObserver { setState(() => _playbackSpeed = selected); await logic.setPlaybackSpeed(selected); } + + Future _unlockEpisode(BuildContext context, int index) async { + if (!await ensureSignedIn(context)) return; + if (!context.mounted) return; + final ok = await logic.unlockEpisode(index); + if (!context.mounted || ok) return; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + errorText(l10n, logic.unlockError, l10n.unlockFailed), + ), + ), + ); + } } /// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。 @@ -493,12 +521,18 @@ class _BlockedNotice extends StatelessWidget { required this.episode, required this.accessType, required this.unlockCoin, + required this.unlocking, + required this.error, + required this.onUnlock, }); final AppLocalizations l10n; final Episode episode; final String? accessType; final int unlockCoin; + final bool unlocking; + final String? error; + final VoidCallback? onUnlock; @override Widget build(BuildContext context) { @@ -521,7 +555,12 @@ class _BlockedNotice extends StatelessWidget { coinLabel: needsUnlock && unlockCoin > 0 ? '$unlockCoin ${l10n.coins}' : null, - footnote: needsUnlock ? l10n.unlockComingSoon : null, + footnote: needsUnlock && error != null + ? errorText(l10n, error, l10n.unlockFailed) + : null, + actionLabel: needsUnlock ? l10n.unlockWithCoins : null, + busy: unlocking, + onAction: needsUnlock ? onUnlock : null, ), ), ); @@ -535,12 +574,18 @@ class _UnlockCard extends StatelessWidget { required this.title, required this.coinLabel, required this.footnote, + this.actionLabel, + this.busy = false, + this.onAction, }); final bool locked; final String title; final String? coinLabel; final String? footnote; + final String? actionLabel; + final bool busy; + final VoidCallback? onAction; @override Widget build(BuildContext context) { @@ -601,6 +646,34 @@ class _UnlockCard extends StatelessWidget { style: const TextStyle(color: AppColors.muted, fontSize: 12), ), ], + if (actionLabel != null) ...[ + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + height: 40, + child: FilledButton( + key: const Key('unlock-with-coins'), + onPressed: busy ? null : onAction, + style: FilledButton.styleFrom( + backgroundColor: AppColors.gold, + foregroundColor: AppColors.onGold, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + child: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + actionLabel!, + style: const TextStyle(fontWeight: FontWeight.w800), + ), + ), + ), + ], ], ), ), diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart new file mode 100644 index 0000000..fcb12de --- /dev/null +++ b/lib/pages/wallet/wallet_page.dart @@ -0,0 +1,400 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/wallet.dart'; +import '../../core/data/services/wallet_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/formatting/localized_date_time.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 WalletPage extends StatefulWidget { + const WalletPage({super.key}); + + @override + State createState() => _WalletPageState(); +} + +class _WalletPageState extends State { + bool _requested = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requested) return; + _requested = true; + final wallet = context.read(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(wallet.load()); + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final wallet = context.watch(); + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('wallet-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onSurface, + title: Text(l10n.wallet), + ), + body: SafeArea(top: false, child: _body(l10n, wallet)), + ), + ); + } + + Widget _body(AppLocalizations l10n, WalletController wallet) { + if (wallet.loading && wallet.snapshot == null) { + return const Center(child: CircularProgressIndicator()); + } + final snapshot = wallet.snapshot; + if (snapshot == null) { + return EmptyState( + key: const Key('wallet-error'), + icon: Icons.account_balance_wallet_outlined, + title: errorText(l10n, wallet.error, l10n.walletLoadFailed), + actionLabel: l10n.retry, + actionKey: const Key('wallet-retry'), + onAction: () => unawaited(wallet.load()), + ); + } + return RefreshIndicator( + onRefresh: wallet.load, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 28), + children: [ + _BalanceHero(wallet: snapshot.wallet), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _SummaryTile( + icon: Icons.add_card_rounded, + label: l10n.totalRecharged, + value: snapshot.wallet.totalRechargedCoin, + color: AppColors.success, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _SummaryTile( + icon: Icons.local_fire_department_rounded, + label: l10n.totalConsumed, + value: snapshot.wallet.totalConsumedCoin, + color: AppColors.accent, + ), + ), + ], + ), + const SizedBox(height: 22), + Text( + l10n.coinLedger, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 10), + if (snapshot.ledger.isEmpty) + _LedgerEmpty(message: l10n.noCoinLedger) + else + for (final entry in snapshot.ledger) ...[ + _LedgerTile(entry: entry), + const SizedBox(height: 8), + ], + ], + ), + ); + } +} + +class _BalanceHero extends StatelessWidget { + const _BalanceHero({required this.wallet}); + + final CoinWallet wallet; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('wallet-balance-card'), + padding: const EdgeInsets.fromLTRB(20, 22, 20, 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2B2116), Color(0xFF17171D)], + ), + border: Border.all(color: AppColors.gold.withValues(alpha: 0.35)), + boxShadow: [ + BoxShadow( + color: AppColors.gold.withValues(alpha: 0.08), + blurRadius: 28, + offset: const Offset(0, 14), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppColors.gold.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.monetization_on_rounded, + color: AppColors.gold, + size: 21, + ), + ), + const SizedBox(width: 10), + Text( + l10n.coinBalance, + style: const TextStyle( + color: AppColors.muted, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 18), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${wallet.coinBalance}', + key: const Key('wallet-balance'), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 42, + height: 1, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Text( + l10n.coins, + style: const TextStyle( + color: AppColors.gold, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _SummaryTile extends StatelessWidget { + const _SummaryTile({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + final IconData icon; + final String label; + final int value; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(height: 10), + Text( + '$value', + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.subtle, fontSize: 11), + ), + ], + ), + ); + } +} + +class _LedgerTile extends StatelessWidget { + const _LedgerTile({required this.entry}); + + final CoinLedgerEntry entry; + + @override + Widget build(BuildContext context) { + final positive = entry.changeAmount >= 0; + final color = positive ? AppColors.success : AppColors.accent; + final amount = positive ? '+${entry.changeAmount}' : '${entry.changeAmount}'; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + positive ? Icons.south_west_rounded : Icons.north_east_rounded, + color: color, + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _ledgerLabel(context, entry.bizType), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + if (entry.remark.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + entry.remark, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 11, + ), + ), + ], + if (entry.createdAt != null) ...[ + const SizedBox(height: 3), + Text( + formatLocalizedDateTime(context, entry.createdAt!), + style: const TextStyle( + color: AppColors.subtle, + fontSize: 10, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + amount, + key: Key('wallet-ledger-${entry.id}'), + style: TextStyle( + color: color, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + '${entry.balanceAfter}', + style: const TextStyle(color: AppColors.subtle, fontSize: 10), + ), + ], + ), + ], + ), + ); + } + + String _ledgerLabel(BuildContext context, String bizType) { + final l10n = AppLocalizations.of(context)!; + return switch (bizType) { + 'UNLOCK' => l10n.ledgerUnlock, + 'RECHARGE' || 'ORDER' => l10n.ledgerRecharge, + 'REFUND' => l10n.ledgerRefund, + _ => l10n.ledgerAdjustment, + }; + } +} + +class _LedgerEmpty extends StatelessWidget { + const _LedgerEmpty({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 22), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + const Icon(Icons.receipt_long_outlined, color: AppColors.subtle), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.subtle, fontSize: 12), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 7dc0e15..f53f05f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -73,6 +81,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" csslib: dependency: transitive description: @@ -175,6 +191,14 @@ packages: description: flutter source: sdk version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" html: dependency: transitive description: @@ -212,6 +236,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" json_annotation: dependency: transitive description: @@ -252,6 +300,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -292,6 +348,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -300,6 +372,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -364,6 +460,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" shared_preferences: dependency: "direct main" description: @@ -397,7 +509,7 @@ packages: source: hosted version: "2.4.1" shared_preferences_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: shared_preferences_platform_interface sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" diff --git a/pubspec.yaml b/pubspec.yaml index 092293d..104344a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: shared_preferences_android: 2.4.28 flutter_localizations: sdk: flutter + path_provider: 2.1.5 dev_dependencies: flutter_test: @@ -61,6 +62,7 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.4 + shared_preferences_platform_interface: 2.4.2 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/auth/auth_controller_test.dart b/test/auth/auth_controller_test.dart index cbefd61..630b2fa 100644 --- a/test/auth/auth_controller_test.dart +++ b/test/auth/auth_controller_test.dart @@ -165,4 +165,56 @@ void main() { expect(auth.isSignedIn, isFalse); expect(await AuthSessionStore().read(), isNull); }); + + test('should_store_session_when_phone_code_login_succeeds', () async { + late AuthController auth; + final adapter = FakeHttpAdapter((options) async { + if (options.uri.path == '/app/auth/otp') { + final body = (options.data as Map).cast(); + expect(body['targetType'], 'PHONE'); + expect(body['target'], '08123456789'); + expect(body['scene'], 'LOGIN'); + expect(body['deviceId'], isNotEmpty); + return FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ); + } + expect(options.uri.path, '/app/auth/login'); + final body = (options.data as Map).cast(); + expect(body['targetType'], 'PHONE'); + expect(body['code'], '123456'); + return FakeHttpResponse(tokenBody(accessToken: 'p1', refreshToken: 'pr1')); + }); + final store = AuthSessionStore(); + auth = AuthController( + AuthApi(clientWith(() => auth, adapter: adapter)), + store, + ); + + await auth.sendOtp(target: '08123456789', scene: 'LOGIN'); + await auth.signInWithPhone(target: '08123456789', code: '123456'); + + expect(auth.isSignedIn, isTrue); + expect(auth.accessToken, 'p1'); + }); + + test('should_store_session_when_phone_registration_succeeds', () async { + late AuthController auth; + final adapter = FakeHttpAdapter((options) async { + expect(options.uri.path, '/app/auth/register'); + final body = (options.data as Map).cast(); + expect(body['targetType'], 'PHONE'); + expect(body['code'], '654321'); + return FakeHttpResponse(tokenBody(accessToken: 'p2', refreshToken: 'pr2')); + }); + auth = AuthController( + AuthApi(clientWith(() => auth, adapter: adapter)), + AuthSessionStore(), + ); + + await auth.registerWithPhone(target: '08123456789', code: '654321'); + + expect(auth.isSignedIn, isTrue); + expect(auth.user?.uid, 'U1'); + }); } diff --git a/test/auth/auth_landing_test.dart b/test/auth/auth_landing_test.dart index af8b6d7..63b66d8 100644 --- a/test/auth/auth_landing_test.dart +++ b/test/auth/auth_landing_test.dart @@ -18,7 +18,7 @@ import '../support/fake_haptics.dart'; import '../support/fake_http_adapter.dart'; void main() { - testWidgets('should_show_three_login_entries_and_open_email_form', ( + testWidgets('should_show_real_login_entries_and_open_phone_sheet', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -48,14 +48,17 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-google')), findsNothing); expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); expect(find.byKey(const Key('auth-legal')), findsOneWidget); - await tester.tap(find.byKey(const Key('auth-entry-google'))); + await tester.tap(find.byKey(const Key('auth-entry-phone'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('phone-auth-sheet')), findsOneWidget); + + await tester.tapAt(const Offset(8, 8)); await tester.pumpAndSettle(); - expect(find.text('Coming soon'), findsOneWidget); await tester.tap(find.byKey(const Key('auth-entry-email'))); await tester.pumpAndSettle(); diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index 10c94f4..ae17115 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -80,7 +80,7 @@ void main() { tester.getSize(find.byKey(const Key('auth-brand-logo'))), const Size(64, 64), ); - expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-google')), findsNothing); expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); expect(find.byKey(const Key('auth-legal')), findsOneWidget); diff --git a/test/auth/phone_auth_sheet_test.dart b/test/auth/phone_auth_sheet_test.dart new file mode 100644 index 0000000..9656a31 --- /dev/null +++ b/test/auth/phone_auth_sheet_test.dart @@ -0,0 +1,127 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/phone_auth_sheet.dart'; +import 'package:provider/provider.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 '../support/fake_haptics.dart'; +import '../support/fake_http_adapter.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_send_otp_and_sign_in_when_phone_form_is_submitted', ( + tester, + ) async { + final adapter = FakeHttpAdapter((options) async { + if (options.uri.path == '/app/auth/otp') { + return FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ); + } + expect(options.uri.path, '/app/auth/login'); + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'accessToken': 'phone-token', + 'refreshToken': 'phone-refresh', + 'expiresIn': 7200, + 'user': {'uid': 'U9', 'nickname': 'Phone User'}, + }, + }), + ); + }); + final auth = AuthController( + AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter)), + AuthSessionStore(), + ); + await _pumpHost(tester, auth); + + await tester.tap(find.byKey(const Key('open-phone-sheet'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('auth-phone-number')), + '08123456789', + ); + await tester.tap(find.byKey(const Key('auth-send-code'))); + await tester.pumpAndSettle(); + expect( + adapter.requests.where((item) => item.uri.path == '/app/auth/otp'), + hasLength(1), + ); + + await tester.enterText(find.byKey(const Key('auth-phone-code')), '123456'); + await tester.tap(find.byKey(const Key('auth-phone-submit'))); + await tester.pumpAndSettle(); + + expect(auth.isSignedIn, isTrue); + expect(auth.accessToken, 'phone-token'); + }); + + testWidgets('should_keep_sheet_open_when_phone_number_is_invalid', ( + tester, + ) async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ), + ); + final auth = AuthController( + AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter)), + AuthSessionStore(), + ); + await _pumpHost(tester, auth); + await tester.tap(find.byKey(const Key('open-phone-sheet'))); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('auth-phone-number')), '12'); + await tester.tap(find.byKey(const Key('auth-send-code'))); + await tester.pumpAndSettle(); + + expect(find.text('Enter a valid phone number'), findsOneWidget); + expect(adapter.requests, isEmpty); + }); +} + +Future _pumpHost(WidgetTester tester, AuthController auth) { + return tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + key: const Key('open-phone-sheet'), + onPressed: () => showPhoneAuthSheet(context), + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); +} diff --git a/test/commerce/commerce_api_test.dart b/test/commerce/commerce_api_test.dart new file mode 100644 index 0000000..02f4a02 --- /dev/null +++ b/test/commerce/commerce_api_test.dart @@ -0,0 +1,81 @@ +// 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_parse_wallet_and_ledger_when_server_returns_string_ids', () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'wallet': { + 'id': '2111012753262120962', + 'coinBalance': '320', + 'totalRechargedCoin': '500', + 'totalConsumedCoin': '180', + 'totalRefundedCoin': '0', + }, + 'ledger': [ + { + 'id': '2111012753262120963', + 'changeAmount': '-50', + 'balanceAfter': '320', + 'bizType': 'UNLOCK', + 'remark': '解锁第 3 集', + 'createdAt': '2026-10-19 10:00:00', + }, + ], + }, + }), + ), + ); + final api = CommerceApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final snapshot = await api.wallet(); + + expect(adapter.requests.single.uri.path, '/app/wallet'); + expect(snapshot.wallet.coinBalance, 320); + expect(snapshot.ledger.single.id, '2111012753262120963'); + expect(snapshot.ledger.single.changeAmount, -50); + }); + + test('should_post_unlock_for_episode_when_requested', () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'episodeId': '12', + 'dramaId': '5', + 'unlocked': true, + 'accessType': 'UNLOCKED', + 'coinCost': '50', + 'coinBalance': '270', + }, + }), + ), + ); + final api = CommerceApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final result = await api.unlock('12'); + + expect(adapter.requests.single.method, 'POST'); + expect(adapter.requests.single.uri.path, '/app/episodes/12/unlock'); + expect(result.unlocked, isTrue); + expect(result.coinCost, 50); + expect(result.coinBalance, 270); + }); +} diff --git a/test/commerce/wallet_controller_test.dart b/test/commerce/wallet_controller_test.dart new file mode 100644 index 0000000..f1939dd --- /dev/null +++ b/test/commerce/wallet_controller_test.dart @@ -0,0 +1,100 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; +import 'package:fruitshorts/core/data/remote/api_exception.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; +import 'package:fruitshorts/core/data/services/wallet_controller.dart'; + +void main() { + test('should_expose_server_balance_when_wallet_loads', () async { + final controller = WalletController( + FakeCommerceRepository(snapshot: _snapshot(balance: 320)), + ); + + await controller.load(); + + expect(controller.balance, 320); + expect(controller.ledger.single.changeAmount, -50); + expect(controller.error, isNull); + }); + + test('should_update_balance_and_clear_error_when_unlock_succeeds', () async { + final repository = FakeCommerceRepository( + snapshot: _snapshot(balance: 320), + unlockResult: const UnlockResult( + unlocked: true, + accessType: 'UNLOCKED', + coinCost: 50, + coinBalance: 270, + ), + ); + final controller = WalletController(repository); + await controller.load(); + + final result = await controller.unlock('episode-9'); + + expect(result?.unlocked, isTrue); + expect(controller.balance, 270); + expect(controller.unlocking, isFalse); + expect(controller.unlockError, isNull); + }); + + test('should_keep_balance_and_expose_message_when_unlock_is_rejected', () async { + final controller = WalletController( + FakeCommerceRepository( + snapshot: _snapshot(balance: 20), + unlockError: const ApiException(100002, '金币余额不足'), + ), + ); + await controller.load(); + + final result = await controller.unlock('episode-9'); + + expect(result, isNull); + expect(controller.balance, 20); + expect(controller.unlockError, '金币余额不足'); + }); +} + +WalletSnapshot _snapshot({required int balance}) => WalletSnapshot( + wallet: CoinWallet( + coinBalance: balance, + totalRechargedCoin: 500, + totalConsumedCoin: 180, + totalRefundedCoin: 0, + ), + ledger: [ + CoinLedgerEntry( + id: '1', + changeAmount: -50, + balanceAfter: balance, + bizType: 'UNLOCK', + remark: '解锁第 3 集', + createdAt: DateTime(2026, 10, 19), + ), + ], +); + +class FakeCommerceRepository implements CommerceRepository { + FakeCommerceRepository({ + required this.snapshot, + this.unlockResult, + this.unlockError, + }); + + final WalletSnapshot snapshot; + final UnlockResult? unlockResult; + final ApiException? unlockError; + final List unlockedEpisodes = []; + + @override + Future fetchWallet() async => snapshot; + + @override + Future unlock(String episodeId) async { + unlockedEpisodes.add(episodeId); + if (unlockError != null) throw unlockError!; + return unlockResult!; + } +} diff --git a/test/config/locale_controller_test.dart b/test/config/locale_controller_test.dart index 077b54c..925dc07 100644 --- a/test/config/locale_controller_test.dart +++ b/test/config/locale_controller_test.dart @@ -4,6 +4,7 @@ import 'package:fruitshorts/core/config/locale_controller.dart'; import 'package:shared_preferences/shared_preferences.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:shared_preferences_platform_interface/types.dart'; void main() { setUp(() { diff --git a/test/core/haptics_scan_test.dart b/test/core/haptics_scan_test.dart index f8a448c..d216b17 100644 --- a/test/core/haptics_scan_test.dart +++ b/test/core/haptics_scan_test.dart @@ -9,7 +9,8 @@ void main() { final violations = []; for (final entity in Directory('lib').listSync(recursive: true)) { if (entity is! File || !entity.path.endsWith('.dart')) continue; - if (entity.path.endsWith('lib/core/platform/haptics.dart')) continue; + final normalized = entity.path.replaceAll('\\', '/'); + if (normalized.endsWith('lib/core/platform/haptics.dart')) continue; final lines = entity.readAsLinesSync(); for (var index = 0; index < lines.length; index++) { if (lines[index].contains('HapticFeedback.')) { diff --git a/test/download/download_controller_local_test.dart b/test/download/download_controller_local_test.dart new file mode 100644 index 0000000..369e808 --- /dev/null +++ b/test/download/download_controller_local_test.dart @@ -0,0 +1,149 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/download_license.dart'; +import 'package:fruitshorts/core/data/models/download_policy.dart'; +import 'package:fruitshorts/core/data/repositories/download_repository.dart'; +import 'package:fruitshorts/core/data/services/download_controller.dart'; +import 'package:fruitshorts/core/data/services/download_storage.dart'; + +void main() { + test('should_mark_episode_downloaded_and_report_progress_when_download_finishes', + () async { + final repository = _RecordingRepository(_license('ep-1')); + final storage = _FakeStorage(); + final controller = DownloadController(repository, storage: storage); + await controller.load(); + + final result = await controller.startDownload('ep-1'); + + expect(result.ok, isTrue); + expect(controller.isDownloaded('ep-1'), isTrue); + expect(controller.localPathFor('ep-1'), 'file:///tmp/ep-1.mp4'); + expect(repository.statuses, containsAll(['DOWNLOADING', 'COMPLETED'])); + }); + + test('should_keep_local_file_when_backend_delete_fails', () async { + final repository = _RecordingRepository(_license('ep-1')); + final storage = _FakeStorage(); + final controller = DownloadController(repository, storage: storage); + await controller.load(); + await controller.startDownload('ep-1'); + repository.failRemove = true; + + final ok = await controller.remove('ep-1'); + + expect(ok, isFalse); + expect(controller.isDownloaded('ep-1'), isTrue); + }); +} + +DownloadLicense _license(String episodeId) => DownloadLicense( + licenseId: 'license-$episodeId', + dramaId: 'drama-1', + dramaTitle: 'The Signal', + episodeId: episodeId, + episodeNo: 1, + deviceId: 'device-1', + quality: 'AUTO', + playUrl: '/files/episode/ep-1.mp4?e=1&s=abc', + licenseKey: 'key', + status: 'ACTIVE', + grantedAt: DateTime(2026, 10, 19), + expireAt: DateTime(2026, 11, 18), + taskStatus: 'QUEUED', + progressPercent: 0, + downloadedBytes: 0, + totalBytes: 0, + errorMsg: '', +); + +class _FakeStorage implements DownloadStorage { + final Set files = {}; + + @override + Future> restore() async => { + for (final id in files) id: 'file:///tmp/$id.mp4', + }; + + @override + Future localPathFor(String episodeId) async => + files.contains(episodeId) ? 'file:///tmp/$episodeId.mp4' : null; + + @override + Future download({ + required String episodeId, + required String url, + DownloadProgress? onProgress, + }) async { + onProgress?.call(50, 100); + onProgress?.call(100, 100); + files.add(episodeId); + return DownloadedMedia( + episodeId: episodeId, + filePath: 'file:///tmp/$episodeId.mp4', + bytes: 100, + ); + } + + @override + Future delete(String episodeId) async => files.remove(episodeId); + + @override + Future clear() async => files.clear(); +} + +class _RecordingRepository implements DownloadRepository { + _RecordingRepository(this.license); + + DownloadLicense license; + bool failRemove = false; + final List statuses = []; + + @override + Future fetchPolicy() async => const DownloadPolicy( + maxPerDevice: 20, + licenseDays: 30, + wifiOnly: false, + maxQuality: 'AUTO', + maxStorageBytes: 0, + used: 1, + remaining: 19, + expirePolicyText: '30 days', + ); + + @override + Future> list() async => [license]; + + @override + Future grant(String episodeId, {String? quality}) async => + license; + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) async {} + + @override + Future refreshUrl(String episodeId) async => license; + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async { + if (status != null) statuses.add(status); + return license; + } + + @override + Future remove(String episodeId) async { + if (failRemove) throw StateError('remove failed'); + } + + @override + Future clear() async => 0; +} diff --git a/test/download/download_storage_test.dart b/test/download/download_storage_test.dart new file mode 100644 index 0000000..f965ac0 --- /dev/null +++ b/test/download/download_storage_test.dart @@ -0,0 +1,42 @@ +// Author: fruit.com.cn + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/download_storage.dart'; + +void main() { + test('should_write_media_file_and_report_progress_when_download_completes', + () async { + final directory = await Directory.systemTemp.createTemp('fruit-download-'); + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final payload = Uint8List.fromList(List.generate(4096, (i) => i % 251)); + server.listen((request) async { + request.response.headers.contentLength = payload.length; + request.response.add(payload); + await request.response.close(); + }); + addTearDown(() async { + await server.close(force: true); + await directory.delete(recursive: true); + }); + final storage = FileDownloadStorage(directoryProvider: () async => directory); + final progress = []; + + final media = await storage.download( + episodeId: 'ep-1', + url: 'http://127.0.0.1:${server.port}/episode.mp4', + onProgress: (received, total) => progress.add(total == 0 ? 0 : received / total), + ); + + expect(File(media.filePath).existsSync(), isTrue); + expect(media.bytes, payload.length); + expect(progress.last, 1.0); + final localPath = await storage.localPathFor('ep-1'); + expect(localPath, isNotNull); + expect(File(localPath!).existsSync(), isTrue); + await storage.delete('ep-1'); + expect(await storage.localPathFor('ep-1'), isNull); + }); +} diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index ed939b3..a57e9e5 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -5,7 +5,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/download_license.dart'; +import 'package:fruitshorts/core/data/models/download_policy.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; +import 'package:fruitshorts/core/data/repositories/download_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; @@ -14,6 +19,8 @@ import 'package:fruitshorts/core/data/services/catalog_controller.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; import 'package:fruitshorts/core/data/services/interaction_controller.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/core/data/services/download_controller.dart'; +import 'package:fruitshorts/core/data/services/wallet_controller.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; @@ -191,6 +198,12 @@ Widget _host(Drama drama) { ChangeNotifierProvider( create: (_) => InteractionController(interactions), ), + ChangeNotifierProvider( + create: (_) => WalletController(_FakeCommerceRepository()), + ), + ChangeNotifierProvider( + create: (_) => DownloadController(_FakeDownloadRepository()), + ), ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()), ], child: MaterialApp( @@ -203,6 +216,71 @@ Widget _host(Drama drama) { ); } +class _FakeCommerceRepository implements CommerceRepository { + @override + Future fetchWallet() async => const WalletSnapshot( + wallet: CoinWallet( + coinBalance: 100, + totalRechargedCoin: 0, + totalConsumedCoin: 0, + totalRefundedCoin: 0, + ), + ); + + @override + Future unlock(String episodeId) async => const UnlockResult( + unlocked: true, + accessType: 'UNLOCKED', + coinCost: 0, + coinBalance: 100, + ); +} + +class _FakeDownloadRepository implements DownloadRepository { + @override + Future fetchPolicy() async => const DownloadPolicy( + maxPerDevice: 20, + licenseDays: 30, + wifiOnly: false, + maxQuality: 'AUTO', + maxStorageBytes: 0, + used: 0, + remaining: 20, + expirePolicyText: '', + ); + + @override + Future> list() async => const []; + + @override + Future grant(String episodeId, {String? quality}) async => + throw UnimplementedError(); + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) async {} + + @override + Future refreshUrl(String episodeId) async => + throw UnimplementedError(); + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async => throw UnimplementedError(); + + @override + Future remove(String episodeId) async {} + + @override + Future clear() async => 0; +} + Drama _detailDrama({int count = 8, bool descending = false}) { final numbers = List.generate( count, diff --git a/test/integration/live_catalog_test.dart b/test/integration/live_catalog_test.dart index 274cf60..d3b03ac 100644 --- a/test/integration/live_catalog_test.dart +++ b/test/integration/live_catalog_test.dart @@ -29,7 +29,12 @@ void main() { final feed = await api.homeFeed(); expect(feed.sections, isNotEmpty); expect(feed.dramas, isNotEmpty); - final card = feed.dramas.first; + final withCover = feed.dramas.where((item) => item.posterUrl.isNotEmpty); + if (withCover.isEmpty) { + markTestSkipped('当前目录没有带封面的剧目,已跳过联调断言'); + return; + } + final card = withCover.first; expect(card.id, isNotEmpty); expect(card.title, isNotEmpty); expect(card.posterUrl, startsWith('/files/')); @@ -41,7 +46,12 @@ void main() { return; } final feed = await api.homeFeed(); - final card = feed.dramas.first; + final cards = feed.dramas.where((item) => item.posterUrl.isNotEmpty); + if (cards.isEmpty) { + markTestSkipped('当前目录没有带封面的剧目,已跳过联调断言'); + return; + } + final card = cards.first; final detail = await api.dramaDetail(card.id); expect(detail.id, card.id); diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index ff0b5c9..44084d9 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -4,7 +4,12 @@ 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/models/player_port.dart'; +import 'package:fruitshorts/core/data/models/download_license.dart'; +import 'package:fruitshorts/core/data/models/download_policy.dart'; +import 'package:fruitshorts/core/data/repositories/download_repository.dart'; import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; +import 'package:fruitshorts/core/data/services/download_controller.dart'; +import 'package:fruitshorts/core/data/services/download_storage.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; @@ -116,6 +121,34 @@ void main() { }); }); + test('should_play_local_file_without_ticket_when_episode_is_downloaded', + () async { + final playback = FakePlaybackRepository(); + final downloads = DownloadController( + _EmptyDownloadRepository(), + storage: _LocalStorage(), + ); + await downloads.restore(); + final drama = _drama('signal'); + final logic = PlayerLogic( + history: FakeWatchHistory(), + playback: playback, + pictureInPicture: _pip(), + downloads: downloads, + ); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + + logic.start(factory: _RecordingFactory().create, coordinator: PlaybackCoordinator()); + await pumpEventQueue(); + await logic.session.playbackSettled; + + expect(playback.requests, isEmpty); + expect(logic.sessionEpisodes.first.videoUrl, 'file:///local/signal-1.mp4'); + }); + group('surface flags', () { test('should_set_immersive_when_picture_in_picture_callback_fires', () async { final factory = _RecordingFactory(); @@ -430,6 +463,75 @@ void main() { }); } +class _LocalStorage implements DownloadStorage { + @override + Future> restore() async => { + 'signal-1': 'file:///local/signal-1.mp4', + }; + + @override + Future localPathFor(String episodeId) async => + episodeId == 'signal-1' ? 'file:///local/signal-1.mp4' : null; + + @override + Future download({ + required String episodeId, + required String url, + DownloadProgress? onProgress, + }) async => throw UnimplementedError(); + + @override + Future delete(String episodeId) async {} + + @override + Future clear() async {} +} + +class _EmptyDownloadRepository implements DownloadRepository { + @override + Future fetchPolicy() async => const DownloadPolicy( + maxPerDevice: 20, + licenseDays: 30, + wifiOnly: false, + maxQuality: 'AUTO', + maxStorageBytes: 0, + used: 0, + remaining: 20, + expirePolicyText: '', + ); + + @override + Future> list() async => const []; + + @override + Future grant(String episodeId, {String? quality}) async => + throw UnimplementedError(); + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) async {} + + @override + Future refreshUrl(String episodeId) async => + throw UnimplementedError(); + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async => throw UnimplementedError(); + + @override + Future remove(String episodeId) async {} + + @override + Future clear() async => 0; +} + /// Counts native-style allocations so leaks are visible to a test. class _RecordingFactory { final List<_RecordingPlayer> created = []; diff --git a/test/wallet/wallet_page_test.dart b/test/wallet/wallet_page_test.dart new file mode 100644 index 0000000..f06ee78 --- /dev/null +++ b/test/wallet/wallet_page_test.dart @@ -0,0 +1,101 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fruitshorts/core/data/models/wallet.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; +import 'package:fruitshorts/core/data/services/wallet_controller.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/wallet/wallet_page.dart'; + +void main() { + testWidgets('should_show_server_balance_and_ledger_when_wallet_opens', ( + tester, + ) async { + final controller = WalletController(_FakeCommerceRepository()); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: controller, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const WalletPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('wallet-balance')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('wallet-balance'))).data, + '320', + ); + expect(find.text('Episode unlock'), findsOneWidget); + expect(find.text('-50'), findsOneWidget); + expect(find.byKey(const Key('wallet-ledger-11')), findsOneWidget); + }); + + testWidgets('should_show_empty_ledger_copy_when_wallet_has_no_entries', ( + tester, + ) async { + final controller = WalletController( + _FakeCommerceRepository(includeLedger: false), + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: controller, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const WalletPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No coin activity yet.'), findsOneWidget); + }); +} + +class _FakeCommerceRepository implements CommerceRepository { + _FakeCommerceRepository({this.includeLedger = true}); + + final bool includeLedger; + + @override + Future fetchWallet() async => WalletSnapshot( + wallet: const CoinWallet( + coinBalance: 320, + totalRechargedCoin: 500, + totalConsumedCoin: 180, + totalRefundedCoin: 0, + ), + ledger: includeLedger + ? [ + CoinLedgerEntry( + id: '11', + changeAmount: -50, + balanceAfter: 320, + bizType: 'UNLOCK', + remark: '', + createdAt: DateTime(2026, 10, 19, 10), + ), + ] + : const [], + ); + + @override + Future unlock(String episodeId) async => + const UnlockResult( + unlocked: true, + accessType: 'UNLOCKED', + coinCost: 50, + coinBalance: 270, + ); +} From 2b95c79931a583bc88efd3883359285cae15920a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 23:50:03 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(app):=20=E5=A2=9E=E5=8A=A0=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E6=A6=9C=E5=8D=95=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 10 +- lib/l10n/app_id.arb | 10 +- lib/l10n/app_localizations.dart | 48 +++ lib/l10n/app_localizations_en.dart | 25 ++ lib/l10n/app_localizations_id.dart | 24 ++ lib/l10n/app_localizations_zh.dart | 24 ++ lib/l10n/app_zh.arb | 10 +- lib/pages/home/home_view.dart | 21 +- lib/pages/ranking/ranking_logic.dart | 76 +++++ lib/pages/ranking/ranking_view.dart | 443 +++++++++++++++++++++++++++ test/home/home_nav_ui_test.dart | 12 +- test/ranking/ranking_logic_test.dart | 74 +++++ test/ranking/ranking_view_test.dart | 88 ++++++ 13 files changed, 859 insertions(+), 6 deletions(-) create mode 100644 lib/pages/ranking/ranking_logic.dart create mode 100644 lib/pages/ranking/ranking_view.dart create mode 100644 test/ranking/ranking_logic_test.dart create mode 100644 test/ranking/ranking_view_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index eb5a4d0..e63895a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -507,5 +507,13 @@ "ledgerUnlock": "Episode unlock", "ledgerRecharge": "Coin top-up", "ledgerRefund": "Refund", - "ledgerAdjustment": "Adjustment" + "ledgerAdjustment": "Adjustment", + "rankingTab": "Charts", + "rankingTitle": "Top Charts", + "rankingSubtitle": "Ranked from today's live catalog feed.", + "rankingHot": "Trending", + "rankingNew": "New releases", + "rankingEmptyTitle": "No ranked stories yet", + "rankingEmptyBody": "Titles appear here as soon as they are published.", + "rankingTopPick": "TOP PICK" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4efa7a5..8a84486 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -505,5 +505,13 @@ "ledgerUnlock": "Buka episode", "ledgerRecharge": "Isi ulang koin", "ledgerRefund": "Pengembalian", - "ledgerAdjustment": "Penyesuaian" + "ledgerAdjustment": "Penyesuaian", + "rankingTab": "Peringkat", + "rankingTitle": "Peringkat Teratas", + "rankingSubtitle": "Diurutkan dari feed katalog terkini.", + "rankingHot": "Populer", + "rankingNew": "Rilis baru", + "rankingEmptyTitle": "Belum ada peringkat", + "rankingEmptyBody": "Judul muncul di sini setelah diterbitkan.", + "rankingTopPick": "PILIHAN UTAMA" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b0f0c4f..18215cf 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2295,6 +2295,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Adjustment'** String get ledgerAdjustment; + + /// No description provided for @rankingTab. + /// + /// In en, this message translates to: + /// **'Charts'** + String get rankingTab; + + /// No description provided for @rankingTitle. + /// + /// In en, this message translates to: + /// **'Top Charts'** + String get rankingTitle; + + /// No description provided for @rankingSubtitle. + /// + /// In en, this message translates to: + /// **'Ranked from today\'s live catalog feed.'** + String get rankingSubtitle; + + /// No description provided for @rankingHot. + /// + /// In en, this message translates to: + /// **'Trending'** + String get rankingHot; + + /// No description provided for @rankingNew. + /// + /// In en, this message translates to: + /// **'New releases'** + String get rankingNew; + + /// No description provided for @rankingEmptyTitle. + /// + /// In en, this message translates to: + /// **'No ranked stories yet'** + String get rankingEmptyTitle; + + /// No description provided for @rankingEmptyBody. + /// + /// In en, this message translates to: + /// **'Titles appear here as soon as they are published.'** + String get rankingEmptyBody; + + /// No description provided for @rankingTopPick. + /// + /// In en, this message translates to: + /// **'TOP PICK'** + String get rankingTopPick; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2682cea..24f9aa8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1174,4 +1174,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get ledgerAdjustment => 'Adjustment'; + + @override + String get rankingTab => 'Charts'; + + @override + String get rankingTitle => 'Top Charts'; + + @override + String get rankingSubtitle => 'Ranked from today\'s live catalog feed.'; + + @override + String get rankingHot => 'Trending'; + + @override + String get rankingNew => 'New releases'; + + @override + String get rankingEmptyTitle => 'No ranked stories yet'; + + @override + String get rankingEmptyBody => + 'Titles appear here as soon as they are published.'; + + @override + String get rankingTopPick => 'TOP PICK'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 66cc4bc..d45a22e 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1173,4 +1173,28 @@ class AppLocalizationsId extends AppLocalizations { @override String get ledgerAdjustment => 'Penyesuaian'; + + @override + String get rankingTab => 'Peringkat'; + + @override + String get rankingTitle => 'Peringkat Teratas'; + + @override + String get rankingSubtitle => 'Diurutkan dari feed katalog terkini.'; + + @override + String get rankingHot => 'Populer'; + + @override + String get rankingNew => 'Rilis baru'; + + @override + String get rankingEmptyTitle => 'Belum ada peringkat'; + + @override + String get rankingEmptyBody => 'Judul muncul di sini setelah diterbitkan.'; + + @override + String get rankingTopPick => 'PILIHAN UTAMA'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index db49ae5..d384d2b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1152,4 +1152,28 @@ class AppLocalizationsZh extends AppLocalizations { @override String get ledgerAdjustment => '余额调整'; + + @override + String get rankingTab => '榜单'; + + @override + String get rankingTitle => '热门榜单'; + + @override + String get rankingSubtitle => '依据目录实时分区排序。'; + + @override + String get rankingHot => '热播'; + + @override + String get rankingNew => '新剧'; + + @override + String get rankingEmptyTitle => '暂无榜单内容'; + + @override + String get rankingEmptyBody => '新剧上线后会自动进入榜单。'; + + @override + String get rankingTopPick => '今日榜首'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5b45d6c..60b8761 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -467,5 +467,13 @@ "ledgerUnlock": "剧集解锁", "ledgerRecharge": "金币充值", "ledgerRefund": "退款", - "ledgerAdjustment": "余额调整" + "ledgerAdjustment": "余额调整", + "rankingTab": "榜单", + "rankingTitle": "热门榜单", + "rankingSubtitle": "依据目录实时分区排序。", + "rankingHot": "热播", + "rankingNew": "新剧", + "rankingEmptyTitle": "暂无榜单内容", + "rankingEmptyBody": "新剧上线后会自动进入榜单。", + "rankingTopPick": "今日榜首" } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 9a45785..9dddeb5 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/home_feed.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/download_controller.dart'; @@ -24,6 +25,7 @@ import '../discover/discover_view.dart'; import '../splash/splash_page.dart'; import '../mine/mine_view.dart'; import '../player/player_view.dart'; +import '../ranking/ranking_view.dart'; import 'home_binding.dart'; import 'home_logic.dart'; import 'widgets/home_menu_sheet.dart'; @@ -79,6 +81,7 @@ class HomePage extends StatelessWidget { return _HomeTabs( key: ValueKey(catalog.dramas.first.id), dramas: catalog.dramas, + sections: catalog.sections, ); } } @@ -136,9 +139,10 @@ class HomeFailureView extends StatelessWidget { /// The three primary destinations share a single route and keep their state. class _HomeTabs extends StatefulWidget { - const _HomeTabs({super.key, required this.dramas}); + const _HomeTabs({super.key, required this.dramas, required this.sections}); final List dramas; + final List sections; @override State<_HomeTabs> createState() => _HomeTabsState(); @@ -235,6 +239,13 @@ class _HomeTabsState extends State<_HomeTabs> { onSignOut: () => context.read().signOut(), wallet: context.read(), ), + RankingPage( + sections: widget.sections, + fallback: widget.dramas, + onOpen: (drama) => logic.openDrama(context, drama), + embedded: true, + active: logic.tab == 3, + ), ], ), bottomNavigationBar: logic.videoImmersive @@ -258,6 +269,14 @@ class _HomeTabsState extends State<_HomeTabs> { selected: logic.tab == 1, onTap: () => _selectTab(context, 1), ), + AppBottomNavItem( + tabKey: const Key('tab-ranking'), + icon: Icons.leaderboard_outlined, + selectedIcon: Icons.leaderboard_rounded, + label: l10n.rankingTab, + selected: logic.tab == 3, + onTap: () => _selectTab(context, 3), + ), AppBottomNavItem( tabKey: const Key('tab-my'), icon: Icons.video_library_outlined, diff --git a/lib/pages/ranking/ranking_logic.dart b/lib/pages/ranking/ranking_logic.dart new file mode 100644 index 0000000..5312e30 --- /dev/null +++ b/lib/pages/ranking/ranking_logic.dart @@ -0,0 +1,76 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/models/home_feed.dart'; + +/// Ranking boards backed by the feed sections the server already returns. +enum RankingBoard { hot, newReleases } + +/// One ranked title; rank is one-based and mirrors server section order. +class RankingEntry { + const RankingEntry({required this.rank, required this.drama}); + + final int rank; + final Drama drama; +} + +/// Keeps the active board and turns real feed sections into ranked entries. +/// +/// The backend does not expose a dedicated ranking endpoint, so this class +/// never invents scores: it preserves the order of the `hot` and `new` +/// sections and only falls back to the catalog when a section is absent. +class RankingLogic extends ChangeNotifier { + RankingLogic({required this.sections, required this.fallback}); + + static const hotSectionCode = 'hot'; + static const newSectionCode = 'new'; + + List sections; + List fallback; + RankingBoard _board = RankingBoard.hot; + + RankingBoard get board => _board; + + List get entries { + final sectionCode = _board == RankingBoard.hot + ? hotSectionCode + : newSectionCode; + final source = _sectionDramas(sectionCode); + final dramas = source.isEmpty ? fallback : source; + final seen = {}; + final unique = [ + for (final drama in dramas) + if (seen.add(drama.id)) drama, + ]; + return [ + for (var index = 0; index < unique.length; index++) + RankingEntry(rank: index + 1, drama: unique[index]), + ]; + } + + /// Switches boards and notifies only when the visible source changes. + void select(RankingBoard next) { + if (_board == next) return; + _board = next; + notifyListeners(); + } + + /// Applies refreshed feed data without rebuilding the page state. + void updateSources({ + required List sections, + required List fallback, + }) { + this.sections = sections; + this.fallback = fallback; + notifyListeners(); + } + + List _sectionDramas(String code) { + for (final section in sections) { + if (section.code == code) return section.dramas; + } + return const []; + } +} diff --git a/lib/pages/ranking/ranking_view.dart b/lib/pages/ranking/ranking_view.dart new file mode 100644 index 0000000..25fcf0d --- /dev/null +++ b/lib/pages/ranking/ranking_view.dart @@ -0,0 +1,443 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/models/home_feed.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 '../../core/widgets/remote_image.dart'; +import '../../l10n/app_localizations.dart'; +import 'ranking_logic.dart'; + +const double _pageHeaderHeight = 62; +const double _selectorHeight = 48; +const double _championHeight = 214; +const double _posterWidth = 62; +const double _posterHeight = 84; +const double _rankWidth = 34; +const int _topThreeLimit = 3; + +/// Full-screen charts page driven by the real `hot` and `new` feed sections. +class RankingPage extends StatefulWidget { + const RankingPage({ + super.key, + required this.sections, + required this.fallback, + required this.onOpen, + this.embedded = false, + this.active = true, + }); + + final List sections; + final List fallback; + final ValueChanged onOpen; + final bool embedded; + final bool active; + + @override + State createState() => _RankingPageState(); +} + +class _RankingPageState extends State { + late final RankingLogic logic = RankingLogic( + sections: widget.sections, + fallback: widget.fallback, + ); + + @override + void didUpdateWidget(covariant RankingPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.sections, widget.sections) || + !identical(oldWidget.fallback, widget.fallback)) { + logic.updateSources(sections: widget.sections, fallback: widget.fallback); + } + } + + @override + void dispose() { + logic.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: logic, + builder: (context, _) => _page(context), + ); + + Widget _page(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final content = SafeArea( + bottom: !widget.embedded, + child: Container( + key: const Key('ranking-page'), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF17131A), AppColors.ink], + stops: [0, 0.42], + ), + ), + child: Column( + children: [ + _header(l10n), + _boardSelector(l10n), + const SizedBox(height: AppDimens.spaceMd), + Expanded(child: _entries(l10n)), + ], + ), + ), + ); + return Theme( + data: AppTheme.dark(), + child: widget.embedded + ? content + : Scaffold(backgroundColor: AppColors.ink, body: content), + ); + } + + Widget _header(AppLocalizations l10n) => SizedBox( + height: _pageHeaderHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.blue.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.blue.withValues(alpha: 0.3)), + ), + child: const Icon( + Icons.leaderboard_rounded, + color: AppColors.blue, + size: 20, + ), + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.rankingTitle, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, + letterSpacing: -0.4, + ), + ), + Text( + l10n.rankingSubtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), + ], + ), + ), + ); + + Widget _boardSelector(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), + child: Container( + height: _selectorHeight, + padding: const EdgeInsets.all(AppDimens.spaceXs), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + _boardButton( + key: const Key('ranking-tab-hot'), + label: l10n.rankingHot, + board: RankingBoard.hot, + ), + _boardButton( + key: const Key('ranking-tab-new'), + label: l10n.rankingNew, + board: RankingBoard.newReleases, + ), + ], + ), + ), + ); + } + + Widget _boardButton({ + required Key key, + required String label, + required RankingBoard board, + }) { + final selected = logic.board == board; + return Expanded( + child: Material( + color: Colors.transparent, + child: InkWell( + key: key, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + onTap: () => logic.select(board), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? AppColors.blue : Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + label, + style: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: AppDimens.body, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ), + ); + } + + Widget _entries(AppLocalizations l10n) { + final entries = logic.entries; + if (entries.isEmpty) { + return EmptyState( + key: const Key('ranking-empty'), + icon: Icons.leaderboard_outlined, + title: l10n.rankingEmptyTitle, + subtitle: l10n.rankingEmptyBody, + ); + } + return ListView.separated( + key: const Key('ranking-list'), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + physics: const AlwaysScrollableScrollPhysics(), + itemCount: entries.length, + separatorBuilder: (_, _) => const SizedBox(height: AppDimens.spaceSm), + itemBuilder: (context, index) { + final entry = entries[index]; + if (index == 0) return _champion(entry, l10n); + return _rankRow(entry, l10n); + }, + ); + } + + Widget _champion(RankingEntry entry, AppLocalizations l10n) { + final drama = entry.drama; + return SizedBox( + key: Key('ranking-entry-${drama.id}'), + height: _championHeight, + child: Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.authCardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => widget.onOpen(drama), + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage(drama.posterUrl), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x22000000), Color(0xF20B0C10)], + stops: [0.15, 1], + ), + ), + ), + Positioned( + top: AppDimens.spaceMd, + left: AppDimens.spaceMd, + child: _rankBadge(entry.rank, large: true), + ), + Positioned( + top: AppDimens.spaceMd, + right: AppDimens.spaceMd, + child: _topPickPill(l10n.rankingTopPick), + ), + Positioned( + left: AppDimens.spaceLg, + right: AppDimens.spaceLg, + bottom: AppDimens.spaceLg, + child: _championCopy(drama, l10n), + ), + ], + ), + ), + ), + ); + } + + Widget _championCopy(Drama drama, AppLocalizations l10n) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + drama.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 22, + fontWeight: FontWeight.w800, + height: 1.15, + letterSpacing: -0.4, + ), + ), + const SizedBox(height: AppDimens.spaceSm), + Row( + children: [ + Expanded( + child: Text( + _subtitle(drama, l10n), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ), + const Icon(Icons.play_circle_fill_rounded, color: AppColors.blue), + ], + ), + ], + ); + + Widget _rankRow(RankingEntry entry, AppLocalizations l10n) { + final drama = entry.drama; + return Material( + key: Key('ranking-entry-${drama.id}'), + color: AppColors.card.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(AppDimens.glassRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => widget.onOpen(drama), + child: SizedBox( + height: 104, + child: Padding( + padding: const EdgeInsets.all(AppDimens.spaceMd), + child: Row( + children: [ + SizedBox( + width: _rankWidth, + child: Center(child: _rankBadge(entry.rank)), + ), + const SizedBox(width: AppDimens.spaceSm), + ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.coverRadius), + child: SizedBox( + width: _posterWidth, + height: _posterHeight, + child: RemoteImage(drama.posterUrl), + ), + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + height: 1.25, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + _subtitle(drama, l10n), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 20, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _rankBadge(int rank, {bool large = false}) { + final top = rank <= _topThreeLimit; + return Container( + width: large ? 44 : 30, + height: large ? 34 : 30, + alignment: Alignment.center, + decoration: BoxDecoration( + color: top + ? AppColors.gold.withValues(alpha: large ? 0.95 : 0.12) + : AppColors.raised, + borderRadius: BorderRadius.circular(large ? 11 : 9), + border: top && !large + ? Border.all(color: AppColors.gold.withValues(alpha: 0.35)) + : null, + ), + child: Text( + '$rank', + style: TextStyle( + color: top && large ? AppColors.onGold : AppColors.gold, + fontSize: large ? 17 : 13, + fontWeight: FontWeight.w900, + ), + ), + ); + } + + Widget _topPickPill(String label) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.blue.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + label, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + ), + ), + ); + + String _subtitle(Drama drama, AppLocalizations l10n) { + final genre = drama.genre.isNotEmpty ? drama.genre : drama.category; + final count = l10n.episodeCount(drama.episodeCount); + return genre.isEmpty ? count : '$genre · $count'; + } +} diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 0c9d15a..9ae1b1c 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -20,6 +20,7 @@ const _keys = [ Key('home-tabs-bar'), Key('tab-home'), Key('tab-discover'), + Key('tab-ranking'), Key('tab-my'), ]; @@ -33,7 +34,7 @@ void main() { }); }); - testWidgets('should_theme_three_tabs_and_switch_selected_state_when_tapped', ( + testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', ( tester, ) async { final haptics = FakeHaptics(); @@ -95,11 +96,14 @@ void main() { ); }); - testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async { + testWidgets('should_map_four_tabs_to_home_discover_ranking_and_me', ( + tester, + ) async { await _pumpApp(tester); expect(find.byKey(const Key('tab-home')), findsOneWidget); expect(find.byKey(const Key('tab-discover')), findsOneWidget); + expect(find.byKey(const Key('tab-ranking')), findsOneWidget); expect(find.byKey(const Key('tab-my')), findsOneWidget); expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); @@ -107,6 +111,10 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + await tester.tap(find.byKey(const Key('tab-ranking'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); expect(find.text('Viewer'), findsOneWidget); diff --git a/test/ranking/ranking_logic_test.dart b/test/ranking/ranking_logic_test.dart new file mode 100644 index 0000000..8d1f935 --- /dev/null +++ b/test/ranking/ranking_logic_test.dart @@ -0,0 +1,74 @@ +// 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/home_feed.dart'; +import 'package:fruitshorts/pages/ranking/ranking_logic.dart'; + +void main() { + test('should_use_backend_hot_order_when_hot_board_is_selected', () { + final logic = RankingLogic( + sections: [ + FeedSection( + code: 'hot', + title: 'Hot', + dramas: [_drama('first'), _drama('second')], + ), + FeedSection(code: 'new', title: 'New', dramas: [_drama('fresh')]), + ], + fallback: [_drama('fallback')], + ); + + expect(logic.board, RankingBoard.hot); + expect(logic.entries.map((entry) => entry.drama.id), ['first', 'second']); + expect(logic.entries.map((entry) => entry.rank), [1, 2]); + logic.dispose(); + }); + + test('should_switch_to_new_releases_when_new_board_is_selected', () { + final logic = RankingLogic( + sections: [ + FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot')]), + FeedSection(code: 'new', title: 'New', dramas: [_drama('fresh')]), + ], + fallback: const [], + ); + + logic.select(RankingBoard.newReleases); + + expect(logic.board, RankingBoard.newReleases); + expect(logic.entries.single.drama.id, 'fresh'); + logic.dispose(); + }); + + test( + 'should_deduplicate_and_fall_back_when_requested_section_is_missing', + () { + final logic = RankingLogic( + sections: [ + FeedSection( + code: 'hot', + title: 'Hot', + dramas: [_drama('a'), _drama('a')], + ), + ], + fallback: [_drama('b'), _drama('a')], + ); + + expect(logic.entries.map((entry) => entry.drama.id), ['a']); + + logic.select(RankingBoard.newReleases); + + expect(logic.entries.map((entry) => entry.drama.id), ['b', 'a']); + expect(logic.entries.last.rank, 2); + logic.dispose(); + }, + ); +} + +Drama _drama(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: 'Synopsis $id', + posterUrl: '/files/$id.jpg', +); diff --git a/test/ranking/ranking_view_test.dart b/test/ranking/ranking_view_test.dart new file mode 100644 index 0000000..7c226a8 --- /dev/null +++ b/test/ranking/ranking_view_test.dart @@ -0,0 +1,88 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.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 'package:fruitshorts/pages/drama_detail/drama_detail_view.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 '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({}); + }); + + testWidgets('should_render_real_section_order_when_ranking_tab_is_opened', ( + tester, + ) async { + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('tab-ranking'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + expect(find.byKey(const Key('ranking-tab-hot')), findsOneWidget); + expect(find.byKey(const Key('ranking-tab-new')), findsOneWidget); + expect( + find.byKey(const Key('ranking-entry-the-signal-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('ranking-entry-city-lights-demo')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('ranking-tab-new'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('ranking-entry-the-signal-demo')), + findsOneWidget, + ); + }); + + testWidgets('should_open_the_selected_drama_when_ranking_entry_is_tapped', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-ranking'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('ranking-entry-city-lights-demo'))); + await tester.pumpAndSettle(); + + expect(find.byType(DramaDetailPage), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); + }); +} + +Future _pumpApp(WidgetTester tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} From 061aac6577fcf9cddfb5b7f5b1e97609b88cfb49 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 23:51:15 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(app):=20=E7=A7=BB=E9=99=A4=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E8=B0=83=E8=AF=95=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/server_endpoint_dialog.dart | 128 ------------------- lib/pages/home/home_view.dart | 55 ++------ lib/pages/mine/mine_logic.dart | 24 +--- lib/pages/mine/mine_view.dart | 13 +- test/home/home_server_setting_test.dart | 32 +---- test/mine/server_endpoint_setting_test.dart | 46 ++----- 6 files changed, 25 insertions(+), 273 deletions(-) delete mode 100644 lib/core/widgets/server_endpoint_dialog.dart diff --git a/lib/core/widgets/server_endpoint_dialog.dart b/lib/core/widgets/server_endpoint_dialog.dart deleted file mode 100644 index 66d6e9d..0000000 --- a/lib/core/widgets/server_endpoint_dialog.dart +++ /dev/null @@ -1,128 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -import '../../l10n/app_localizations.dart'; -import '../config/app_config.dart'; -import '../config/server_endpoint.dart'; -import '../theme/app_colors.dart'; - -/// Opens the debug-only runtime server address editor. -Future showServerEndpointDialog( - BuildContext context, - AppLocalizations l10n, -) async { - final changed = await showDialog( - context: context, - builder: (_) => _ServerEndpointDialog(l10n: l10n), - ); - if (changed == true && context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.restartToApply))); - return true; - } - return false; -} - -class _ServerEndpointDialog extends StatefulWidget { - const _ServerEndpointDialog({required this.l10n}); - - final AppLocalizations l10n; - - @override - State<_ServerEndpointDialog> createState() => _ServerEndpointDialogState(); -} - -class _ServerEndpointDialogState extends State<_ServerEndpointDialog> { - late final TextEditingController _controller = TextEditingController( - text: AppConfig.apiBaseUrl, - ); - String? _error; - bool _saving = false; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - Future _save() async { - final normalized = ServerEndpoint.normalize(_controller.text); - if (normalized == null) { - setState(() => _error = widget.l10n.invalidServerAddress); - return; - } - setState(() { - _saving = true; - _error = null; - }); - final saved = await ServerEndpoint.save(normalized); - if (!mounted) return; - if (!saved) { - setState(() { - _saving = false; - _error = widget.l10n.invalidServerAddress; - }); - return; - } - Navigator.pop(context, true); - } - - Future _restoreDefault() async { - setState(() => _saving = true); - await ServerEndpoint.clear(); - if (mounted) Navigator.pop(context, true); - } - - @override - Widget build(BuildContext context) { - final l10n = widget.l10n; - return AlertDialog( - title: Text(l10n.serverAddress), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - key: const Key('server-endpoint-input'), - controller: _controller, - enabled: !_saving, - keyboardType: TextInputType.url, - autocorrect: false, - decoration: InputDecoration( - hintText: l10n.serverAddressHint, - errorText: _error, - errorMaxLines: 2, - ), - ), - const SizedBox(height: 10), - Text( - l10n.restartToApply, - key: const Key('server-endpoint-restart-hint'), - style: const TextStyle(color: AppColors.muted, fontSize: 12), - ), - const SizedBox(height: 4), - TextButton( - key: const Key('server-endpoint-restore'), - onPressed: _saving ? null : _restoreDefault, - child: Text(l10n.restoreDefault), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: _saving ? null : () => Navigator.pop(context, false), - child: Text(l10n.cancel), - ), - FilledButton( - key: const Key('server-endpoint-save'), - onPressed: _saving ? null : _save, - child: Text(l10n.saveAddress), - ), - ], - ); - } -} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 9dddeb5..92c96b5 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -2,11 +2,9 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/home_feed.dart'; import '../../core/data/services/auth_controller.dart'; @@ -19,7 +17,6 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/server_endpoint_dialog.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_view.dart'; import '../splash/splash_page.dart'; @@ -58,10 +55,7 @@ class HomePage extends StatelessWidget { if (catalog.error != null) { return Scaffold( backgroundColor: AppColors.ink, - body: HomeFailureView( - onRetry: catalog.load, - showDebugTools: kDebugMode, - ), + body: HomeFailureView(onRetry: catalog.load), ); } if (catalog.dramas.isEmpty) { @@ -86,53 +80,20 @@ class HomePage extends StatelessWidget { } } -/// Home catalog failure with optional debug-only endpoint controls. +/// Home catalog failure with a single recovery action. class HomeFailureView extends StatelessWidget { - const HomeFailureView({ - super.key, - required this.onRetry, - this.showDebugTools = kDebugMode, - }); + const HomeFailureView({super.key, required this.onRetry}); final VoidCallback onRetry; - final bool showDebugTools; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return Column( - children: [ - Expanded( - child: EmptyState( - icon: Icons.cloud_off_rounded, - title: l10n.catalogError, - actionLabel: l10n.retry, - onAction: onRetry, - ), - ), - if (showDebugTools) _debugTools(context, l10n), - ], - ); - } - - Widget _debugTools(BuildContext context, AppLocalizations l10n) { - return Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), - child: Column( - children: [ - Text( - AppConfig.apiBaseUrl, - style: const TextStyle(fontSize: 12, color: AppColors.muted), - ), - const SizedBox(height: 8), - OutlinedButton.icon( - key: const Key('home-server-settings'), - onPressed: () => showServerEndpointDialog(context, l10n), - icon: const Icon(Icons.dns_outlined), - label: Text(l10n.serverSettings), - ), - ], - ), + return EmptyState( + icon: Icons.cloud_off_rounded, + title: l10n.catalogError, + actionLabel: l10n.retry, + onAction: onRetry, ); } } diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index 3c56aaf..f6e17fd 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -1,16 +1,13 @@ // Author: fruit.com.cn -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../core/config/app_config.dart'; import '../../core/config/locale_controller.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/widgets/server_endpoint_dialog.dart'; import '../../l10n/app_localizations.dart'; enum MyPageAction { home, discover, resume } @@ -114,10 +111,7 @@ class MineLogic extends ChangeNotifier { } } - Future showLanguage( - BuildContext context, { - bool showServerSetting = kDebugMode, - }) async { + Future showLanguage(BuildContext context) async { final l10n = AppLocalizations.of(context)!; await showModalBottomSheet( context: context, @@ -146,17 +140,6 @@ class MineLogic extends ChangeNotifier { Navigator.pop(sheetContext); }, ), - if (showServerSetting) ...[ - const Divider(height: 1), - ListTile( - key: const Key('server-endpoint-setting'), - leading: const Icon(Icons.dns_outlined), - title: Text(l10n.serverAddress), - subtitle: Text(AppConfig.apiBaseUrl), - trailing: const Icon(Icons.edit_outlined), - onTap: () => _editServerEndpoint(context, l10n), - ), - ], const SizedBox(height: 8), ], ), @@ -166,11 +149,6 @@ class MineLogic extends ChangeNotifier { ); } - Future _editServerEndpoint( - BuildContext context, - AppLocalizations l10n, - ) => showServerEndpointDialog(context, l10n); - @override void dispose() { _disposed = true; diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 174397f..4174e76 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -2,7 +2,6 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -49,7 +48,6 @@ class MyPage extends StatefulWidget { this.dramas, this.onDramaSelected, this.wallet, - this.debugShowSetting = kDebugMode, }); final Drama drama; @@ -67,7 +65,6 @@ class MyPage extends StatefulWidget { final List? dramas; final ValueChanged? onDramaSelected; final WalletController? wallet; - final bool debugShowSetting; @override State createState() => _MyPageState(); @@ -759,10 +756,7 @@ class _MyPageState extends State { }; return InkWell( key: const Key('mine-language'), - onTap: () => logic.showLanguage( - context, - showServerSetting: widget.debugShowSetting, - ), + onTap: () => logic.showLanguage(context), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(12), @@ -844,10 +838,7 @@ class _MyPageState extends State { _menuTile( icon: Icons.settings_rounded, title: l10n.settings, - onTap: () => logic.showLanguage( - context, - showServerSetting: widget.debugShowSetting, - ), + onTap: () => logic.showLanguage(context), ), ], ), diff --git a/test/home/home_server_setting_test.dart b/test/home/home_server_setting_test.dart index 239da4f..3278287 100644 --- a/test/home/home_server_setting_test.dart +++ b/test/home/home_server_setting_test.dart @@ -12,45 +12,23 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets( - 'should_show_server_settings_and_open_dialog_when_debug_tools_are_enabled', - (tester) async { - await _pumpFailureView(tester, showDebugTools: true); - - final button = find.byKey(const Key('home-server-settings')); - expect(button, findsOneWidget); - expect(find.text('Server settings'), findsOneWidget); - - await tester.tap(button); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('server-endpoint-input')), findsOneWidget); - expect(find.text('Restart the app to apply.'), findsOneWidget); - }, - ); - - testWidgets('should_hide_server_settings_when_debug_tools_are_disabled', ( + testWidgets('should_not_expose_server_settings_on_catalog_failure', ( tester, ) async { - await _pumpFailureView(tester, showDebugTools: false); - + await _pumpFailureView(tester); expect(find.byKey(const Key('home-server-settings')), findsNothing); expect(find.text('Server settings'), findsNothing); + expect(find.text('Retry'), findsOneWidget); }); } -Future _pumpFailureView( - WidgetTester tester, { - required bool showDebugTools, -}) async { +Future _pumpFailureView(WidgetTester tester) async { await tester.pumpWidget( MaterialApp( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: HomeFailureView(onRetry: () {}, showDebugTools: showDebugTools), - ), + home: Scaffold(body: HomeFailureView(onRetry: () {})), ), ); await tester.pumpAndSettle(); diff --git a/test/mine/server_endpoint_setting_test.dart b/test/mine/server_endpoint_setting_test.dart index 7eb9c49..2d468fb 100644 --- a/test/mine/server_endpoint_setting_test.dart +++ b/test/mine/server_endpoint_setting_test.dart @@ -1,12 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.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/core/config/locale_controller.dart'; -import 'package:fruitshorts/core/config/server_endpoint.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; @@ -25,49 +23,26 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_show_server_address_when_debug_entry_is_enabled', ( + testWidgets('should_not_show_server_address_in_settings_sheet', ( tester, ) async { - await _pumpSettings(tester, showServerSetting: true); - expect(find.byKey(const Key('server-endpoint-setting')), findsOneWidget); - expect(find.text('Server address'), findsOneWidget); - }); - - testWidgets('should_hide_server_address_when_debug_entry_is_disabled', ( - tester, - ) async { - await _pumpSettings(tester, showServerSetting: false); + await _pumpSettings(tester); expect(find.byKey(const Key('server-endpoint-setting')), findsNothing); expect(find.text('Server address'), findsNothing); }); - testWidgets('should_reject_invalid_server_address_without_saving', ( + testWidgets('should_keep_all_three_languages_in_settings_sheet', ( tester, ) async { - await _pumpSettings(tester, showServerSetting: true); - await tester.tap(find.byKey(const Key('server-endpoint-setting'))); - await tester.pumpAndSettle(); + await _pumpSettings(tester); - expect(find.text('Restart the app to apply.'), findsOneWidget); - await tester.enterText( - find.byKey(const Key('server-endpoint-input')), - 'ftp://invalid', - ); - await tester.tap(find.byKey(const Key('server-endpoint-save'))); - await tester.pumpAndSettle(); - - expect(find.text('Enter a valid http(s) server address.'), findsOneWidget); - expect( - await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), - isNull, - ); + expect(find.text('English'), findsOneWidget); + expect(find.text('简体中文'), findsOneWidget); + expect(find.text('Bahasa Indonesia'), findsOneWidget); }); } -Future _pumpSettings( - WidgetTester tester, { - required bool showServerSetting, -}) async { +Future _pumpSettings(WidgetTester tester) async { final logic = MineLogic( history: InMemoryWatchHistoryRepository(), drama: _drama, @@ -85,10 +60,7 @@ Future _pumpSettings( builder: (context) => Scaffold( body: TextButton( key: const Key('open-settings'), - onPressed: () => logic.showLanguage( - context, - showServerSetting: showServerSetting, - ), + onPressed: () => logic.showLanguage(context), child: const Text('Open settings'), ), ), From 7b927c794e45e098b03a1ef7613d5bfbefff8c22 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 23:53:03 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(app):=20=E6=8E=A5=E5=85=A5=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E8=BD=AE=E6=92=AD=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 17 ++ .../widgets/feed_banner_carousel.dart | 234 ++++++++++++++++++ lib/pages/home/home_logic.dart | 16 ++ lib/pages/home/home_view.dart | 16 +- test/discover/discover_banner_test.dart | 74 ++++++ test/home/feed_banner_carousel_test.dart | 88 +++++++ test/support/fake_catalog.dart | 61 ++--- 7 files changed, 469 insertions(+), 37 deletions(-) create mode 100644 lib/pages/discover/widgets/feed_banner_carousel.dart create mode 100644 test/discover/discover_banner_test.dart create mode 100644 test/home/feed_banner_carousel_test.dart diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 656adcb..059cd19 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/home_feed.dart'; import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/category_controller.dart'; @@ -18,6 +19,7 @@ import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; +import 'widgets/feed_banner_carousel.dart'; part 'widgets/home_drama_grid.dart'; @@ -38,6 +40,8 @@ class DiscoverPage extends StatefulWidget { this.onAction, this.dramas, this.onDramaSelected, + this.banners = const [], + this.onBannerSelected, }); final Drama drama; @@ -54,6 +58,8 @@ class DiscoverPage extends StatefulWidget { final ValueChanged? onAction; final List? dramas; final ValueChanged? onDramaSelected; + final List banners; + final ValueChanged? onBannerSelected; @override State createState() => _DiscoverPageState(); @@ -147,6 +153,17 @@ class _DiscoverPageState extends State { controller: logic.scroll, physics: const AlwaysScrollableScrollPhysics(), slivers: [ + if (widget.banners.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: FeedBannerCarousel( + banners: widget.banners, + active: widget.active, + onOpen: (banner) => widget.onBannerSelected?.call(banner), + ), + ), + ), SliverToBoxAdapter(child: _search()), SliverToBoxAdapter( child: Padding( diff --git a/lib/pages/discover/widgets/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart new file mode 100644 index 0000000..9d9c944 --- /dev/null +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -0,0 +1,234 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/home_feed.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _carouselHeight = 154; +const double _indicatorHeight = 24; +const double _dotSize = 6; +const double _activeDotWidth = 18; +const double _viewportFraction = 0.94; +const Duration _autoAdvanceInterval = Duration(milliseconds: 4500); +const Duration _pageTransition = Duration(milliseconds: 520); + +/// Auto-advancing, touch-pausable carousel for real `/app/home/feed` banners. +class FeedBannerCarousel extends StatefulWidget { + const FeedBannerCarousel({ + super.key, + required this.banners, + required this.onOpen, + this.active = true, + }); + + final List banners; + final ValueChanged onOpen; + final bool active; + + @override + State createState() => _FeedBannerCarouselState(); +} + +class _FeedBannerCarouselState extends State { + final PageController _controller = PageController( + viewportFraction: _viewportFraction, + ); + Timer? _timer; + int _index = 0; + bool _pressed = false; + bool _tickerEnabled = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final enabled = TickerMode.valuesOf(context).enabled; + if (enabled != _tickerEnabled) { + _tickerEnabled = enabled; + _syncTimer(); + } + } + + @override + void didUpdateWidget(covariant FeedBannerCarousel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.banners.length != widget.banners.length || + oldWidget.active != widget.active) { + if (_index >= widget.banners.length) _index = 0; + _syncTimer(); + } + } + + @override + void dispose() { + _timer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + void _syncTimer() { + _timer?.cancel(); + _timer = null; + final canAdvance = + mounted && + widget.active && + _tickerEnabled && + !_pressed && + widget.banners.length > 1; + if (!canAdvance) return; + _timer = Timer.periodic(_autoAdvanceInterval, (_) => _advance()); + } + + void _advance() { + if (!_controller.hasClients || widget.banners.length < 2) return; + final next = (_index + 1) % widget.banners.length; + _controller.animateToPage( + next, + duration: _pageTransition, + curve: Curves.easeOutCubic, + ); + } + + void _pause() { + _pressed = true; + _timer?.cancel(); + } + + void _resume() { + _pressed = false; + _syncTimer(); + } + + @override + Widget build(BuildContext context) { + if (widget.banners.isEmpty) return const SizedBox.shrink(); + return Listener( + key: const Key('feed-banner-carousel'), + onPointerDown: (_) => _pause(), + onPointerUp: (_) => _resume(), + onPointerCancel: (_) => _resume(), + child: Column( + children: [ + SizedBox( + height: _carouselHeight, + child: PageView.builder( + controller: _controller, + itemCount: widget.banners.length, + onPageChanged: (value) => setState(() => _index = value), + itemBuilder: (context, index) => _bannerCard(index), + ), + ), + SizedBox(height: _indicatorHeight, child: _indicator(context)), + ], + ), + ); + } + + Widget _bannerCard(int index) { + final banner = widget.banners[index]; + final dramaId = banner.dramaId; + final card = ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.authCardRadius), + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage(banner.imageUrl, showFallbackIcon: true), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00000000), Color(0x8A0B0C10)], + ), + ), + ), + if (dramaId != null) + const Positioned(right: 14, bottom: 14, child: _PlayMark()), + ], + ), + ); + final page = Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: dramaId == null + ? card + : Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.authCardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: Key('feed-banner-$index'), + onTap: () => widget.onOpen(banner), + child: card, + ), + ), + ); + return Semantics(container: true, button: dramaId != null, child: page); + } + + Widget _indicator(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return ExcludeSemantics( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var index = 0; index < widget.banners.length; index++) + Semantics( + label: l10n.carouselSemantics(index + 1, widget.banners.length), + selected: index == _index, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + key: Key('feed-banner-dot-$index'), + width: index == _index ? _activeDotWidth : _dotSize, + height: _dotSize, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: index == _index + ? AppColors.onPrimary + : AppColors.muted, + borderRadius: BorderRadius.circular(_dotSize), + ), + ), + ), + ], + ), + ); + } +} + +class _PlayMark extends StatelessWidget { + const _PlayMark(); + + @override + Widget build(BuildContext context) => Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: AppColors.blue.withValues(alpha: 0.35), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 22, + ), + ); +} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index f2e5fec..b3b5781 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -7,6 +7,8 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/wallet_controller.dart'; +import '../../core/data/repositories/catalog_repository.dart'; +import '../../l10n/app_localizations.dart'; import '../discover/discover_logic.dart'; import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_logic.dart'; @@ -85,6 +87,20 @@ class HomeLogic extends ChangeNotifier { ); } + /// Resolves a banner link that is not in the loaded feed, then opens detail. + Future openDramaById(BuildContext context, String dramaId) async { + try { + final drama = await context.read().fetchDrama(dramaId); + if (context.mounted) openDrama(context, drama); + } catch (_) { + if (!context.mounted) return; + final message = AppLocalizations.of(context)!.detailLoadFailed; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + } + Future playDrama(BuildContext context, Drama drama) async { // 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。 var playable = drama; diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 92c96b5..e522637 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -76,6 +76,7 @@ class HomePage extends StatelessWidget { key: ValueKey(catalog.dramas.first.id), dramas: catalog.dramas, sections: catalog.sections, + banners: catalog.banners, ); } } @@ -100,10 +101,16 @@ class HomeFailureView extends StatelessWidget { /// The three primary destinations share a single route and keep their state. class _HomeTabs extends StatefulWidget { - const _HomeTabs({super.key, required this.dramas, required this.sections}); + const _HomeTabs({ + super.key, + required this.dramas, + required this.sections, + required this.banners, + }); final List dramas; final List sections; + final List banners; @override State<_HomeTabs> createState() => _HomeTabsState(); @@ -182,6 +189,13 @@ class _HomeTabsState extends State<_HomeTabs> { onSignIn: () => openAuthPage(context), onCoins: logic.openProfile, onRewards: logic.openProfile, + banners: widget.banners, + onBannerSelected: (banner) { + final dramaId = banner.dramaId; + if (dramaId != null) { + unawaited(logic.openDramaById(context, dramaId)); + } + }, onAction: (action) => logic.discoverAction(context, action), ), MyPage( diff --git a/test/discover/discover_banner_test.dart b/test/discover/discover_banner_test.dart new file mode 100644 index 0000000..991e7c7 --- /dev/null +++ b/test/discover/discover_banner_test.dart @@ -0,0 +1,74 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/home_feed.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 'package:fruitshorts/pages/drama_detail/drama_detail_view.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 '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({}); + }); + + testWidgets('should_open_linked_drama_when_discover_banner_is_tapped', ( + tester, + ) async { + final catalog = FakeCatalogRepository( + banners: const [ + FeedBanner( + id: 'banner-1', + imageUrl: '/files/banner.jpg', + linkType: 'DRAMA', + linkValue: 'the-signal-demo', + ), + ], + ); + await _pumpApp(tester, catalog); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('feed-banner-carousel')), findsOneWidget); + await tester.tap(find.byKey(const Key('feed-banner-0'))); + await tester.pumpAndSettle(); + + expect(find.byType(DramaDetailPage), findsOneWidget); + expect(find.text('The Signal'), findsWidgets); + }); +} + +Future _pumpApp( + WidgetTester tester, + FakeCatalogRepository catalog, +) 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: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/home/feed_banner_carousel_test.dart b/test/home/feed_banner_carousel_test.dart new file mode 100644 index 0000000..7fac22b --- /dev/null +++ b/test/home/feed_banner_carousel_test.dart @@ -0,0 +1,88 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/discover/widgets/feed_banner_carousel.dart'; + +void main() { + testWidgets('should_render_nothing_when_banner_list_is_empty', ( + tester, + ) async { + await _pump(tester, const []); + + expect(find.byKey(const Key('feed-banner-carousel')), findsNothing); + }); + + testWidgets('should_open_linked_drama_when_banner_is_tapped', (tester) async { + FeedBanner? opened; + await _pump(tester, const [ + FeedBanner( + id: 'banner-1', + imageUrl: '/files/banner.jpg', + linkType: 'DRAMA', + linkValue: 'drama-1', + ), + ], onOpen: (banner) => opened = banner); + + expect(find.byKey(const Key('feed-banner-carousel')), findsOneWidget); + expect(find.byKey(const Key('feed-banner-0')), findsOneWidget); + await tester.tap(find.byKey(const Key('feed-banner-0'))); + await tester.pump(); + + expect(opened?.dramaId, 'drama-1'); + }); + + testWidgets('should_advance_to_next_banner_when_interval_elapses', ( + tester, + ) async { + await _pump(tester, _banners); + + await tester.pump(const Duration(milliseconds: 4600)); + await tester.pumpAndSettle(); + + expect(_page(tester), 1); + }); + + testWidgets('should_pause_auto_advance_when_pointer_is_held', (tester) async { + await _pump(tester, _banners); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('feed-banner-carousel'))), + ); + + await tester.pump(const Duration(seconds: 5)); + expect(_page(tester), 0); + + await gesture.up(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(_page(tester), 1); + }); +} + +const _banners = [ + FeedBanner(id: 'banner-1', imageUrl: '/files/one.jpg'), + FeedBanner(id: 'banner-2', imageUrl: '/files/two.jpg'), +]; + +double _page(WidgetTester tester) => + tester.widget(find.byType(PageView)).controller!.page!; + +Future _pump( + WidgetTester tester, + List banners, { + ValueChanged? onOpen, +}) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: FeedBannerCarousel(banners: banners, onOpen: onOpen ?? (_) {}), + ), + ), + ); + await tester.pump(); +} diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index fc6d54a..7c41eed 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -4,9 +4,11 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; /// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。 class FakeCatalogRepository implements CatalogRepository { - FakeCatalogRepository({List? dramas}) : dramas = dramas ?? demoDramas; + FakeCatalogRepository({List? dramas, this.banners = const []}) + : dramas = dramas ?? demoDramas; final List dramas; + final List banners; @override Future fetchHomeFeed() async => _feed(); @@ -20,51 +22,38 @@ class FakeCatalogRepository implements CatalogRepository { } HomeFeed _feed() => HomeFeed( + banners: banners, sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)], ); } final List demoDramas = [ - _drama( - 'the-signal-demo', - 'The Signal', - 'Suspense · Mystery', - format: 1, - ), + _drama('the-signal-demo', 'The Signal', 'Suspense · Mystery', format: 1), _drama( 'city-lights-demo', 'City Lights & Velvet Nights', 'Urban Romance', format: 8, ), - _drama( - 'vengeance-demo', - 'Vengeance Rising', - 'Action · Thriller', - format: 15, - ), + _drama('vengeance-demo', 'Vengeance Rising', 'Action · Thriller', format: 15), ]; -Drama _drama( - String id, - String title, - String genre, { - required int format, -}) => Drama( - id: id, - title: title, - synopsis: '$title synopsis', - posterUrl: '/files/$id.png', - genre: genre, - detailLoaded: true, - episodes: [ - for (var index = 0; index < 7; index++) - Episode( - id: '$id-${index + 1}', - number: index + 1, - title: 'Episode ${index + 1}', - isFree: true, - videoUrl: 'https://example.test/$format-$index.mp4', - ), - ], -); +Drama _drama(String id, String title, String genre, {required int format}) => + Drama( + id: id, + title: title, + synopsis: '$title synopsis', + posterUrl: '/files/$id.png', + genre: genre, + detailLoaded: true, + episodes: [ + for (var index = 0; index < 7; index++) + Episode( + id: '$id-${index + 1}', + number: index + 1, + title: 'Episode ${index + 1}', + isFree: true, + videoUrl: 'https://example.test/$format-$index.mp4', + ), + ], + ); From dfc650456ada48abda9c2c3964b419a48e317cf4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 23:56:12 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat(app):=20=E5=B1=95=E7=A4=BA=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=82=B9=E8=B5=9E=E4=B8=8E=E8=AF=84=E8=AE=BA=E8=AE=A1?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/comment_controller.dart | 6 +++ .../data/services/interaction_controller.dart | 16 +++++-- lib/pages/player/player_view.dart | 14 +++++-- .../player/widgets/drama_comments_sheet.dart | 4 +- .../player/widgets/home_feed_overlay.dart | 8 +++- test/comment/comment_controller_test.dart | 21 ++++++++++ test/comment/drama_comments_sheet_test.dart | 2 + test/home/home_preview_layout_test.dart | 42 +++++++++++++++++++ .../interaction_controller_test.dart | 18 ++++++++ 9 files changed, 123 insertions(+), 8 deletions(-) diff --git a/lib/core/data/services/comment_controller.dart b/lib/core/data/services/comment_controller.dart index 55f6785..a81b066 100644 --- a/lib/core/data/services/comment_controller.dart +++ b/lib/core/data/services/comment_controller.dart @@ -36,12 +36,17 @@ class CommentController extends ChangeNotifier { bool loadingMore = false; bool submitting = false; bool hasMore = false; + bool _loaded = false; String? nextCursor; String? error; /// 当前已加载的顶层评论。 List get comments => List.unmodifiable(_comments); + /// 全部分页加载完后返回精确的顶层评论数,否则返回 null。 + int? get exactCount => + _loaded && !loading && !loadingMore && !hasMore ? _comments.length : null; + /// 某一楼层已加载的回复。 List repliesFor(String rootId) => List.unmodifiable(_replies[rootId] ?? const []); @@ -77,6 +82,7 @@ class CommentController extends ChangeNotifier { ..addAll(page.comments); nextCursor = page.nextCursor; hasMore = page.hasMore; + _loaded = true; } catch (failure) { error = _errorMessage(failure) ?? 'commentsLoadFailed'; } finally { diff --git a/lib/core/data/services/interaction_controller.dart b/lib/core/data/services/interaction_controller.dart index 9dd39c9..539ee5d 100644 --- a/lib/core/data/services/interaction_controller.dart +++ b/lib/core/data/services/interaction_controller.dart @@ -8,14 +8,15 @@ import '../repositories/interaction_repository.dart'; /// 剧集点赞与举报的会话状态控制器。 /// -/// 剧集契约暂未下发初始点赞状态与计数,本批只维护当前会话内的 liked 状态, -/// 不展示数字,也不伪造初始值。 +/// 剧集契约暂未下发初始点赞状态与计数,因此仅在点赞接口成功返回后展示 +/// 服务端最新计数,不伪造初始值。 class InteractionController extends ChangeNotifier { InteractionController(this._repository); final InteractionRepository _repository; final Set _likedDramaIds = {}; final Set _busyDramaIds = {}; + final Map _likeCounts = {}; bool _disposed = false; /// 当前会话内该剧是否已点赞。 @@ -24,6 +25,9 @@ class InteractionController extends ChangeNotifier { /// 点赞请求是否进行中,用于防连点。 bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId); + /// 服务端最新点赞数,仅在点赞接口成功返回后可用。 + int? likeCount(String dramaId) => _likeCounts[dramaId]; + /// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。 Future> toggleDramaLike( String dramaId, @@ -49,6 +53,7 @@ class InteractionController extends ChangeNotifier { } else { _likedDramaIds.remove(dramaId); } + _likeCounts[dramaId] = result.count; return OperationResult.success(result); } catch (failure) { if (wasLiked) { @@ -86,9 +91,14 @@ class InteractionController extends ChangeNotifier { /// 登录账号变化时清理会话内状态,避免跨账号串联。 void clear() { - if (_likedDramaIds.isEmpty && _busyDramaIds.isEmpty) return; + if (_likedDramaIds.isEmpty && + _busyDramaIds.isEmpty && + _likeCounts.isEmpty) { + return; + } _likedDramaIds.clear(); _busyDramaIds.clear(); + _likeCounts.clear(); _notify(); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 5e9a109..0528611 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -337,6 +337,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { drama: drama, episode: episode, liked: interactions.isLiked(drama.id), + likeCount: interactions.likeCount(drama.id), saved: context.watch().isSaved(drama.id), onLike: () async { if (await ensureSignedIn(context)) { @@ -388,6 +389,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { episode: episode, controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), + likeCount: interactions.likeCount(drama.id), // 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载) saved: context.watch().isSaved(drama.id), onBack: widget.embedded @@ -506,9 +508,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { final l10n = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - errorText(l10n, logic.unlockError, l10n.unlockFailed), - ), + content: Text(errorText(l10n, logic.unlockError, l10n.unlockFailed)), ), ); } @@ -688,6 +688,7 @@ class _PlaybackOverlay extends StatefulWidget { required this.episode, required this.controller, required this.liked, + this.likeCount, required this.saved, required this.playing, required this.playbackSpeed, @@ -714,6 +715,7 @@ class _PlaybackOverlay extends StatefulWidget { final Episode episode; final VideoPlayerController? controller; final bool liked; + final int? likeCount; final bool saved; final bool playing; final double playbackSpeed; @@ -985,6 +987,12 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { color: widget.liked ? AppColors.blue : AppColors.muted, ), title: Text(widget.l10n.like), + trailing: widget.likeCount == null + ? null + : Text( + '${widget.likeCount}', + key: const Key('player-more-like-count'), + ), onTap: () { Navigator.pop(sheetContext); widget.onLike(); diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index e95d820..d183d1b 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -178,11 +178,13 @@ class _DramaCommentsSheetState extends State { Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final count = _controller.exactCount; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l10n.comments, + count == null ? l10n.comments : '${l10n.comments} · $count', + key: count == null ? null : const Key('comment-count-label'), style: const TextStyle( color: AppColors.cream, fontSize: 18, diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 3fc5c00..81dc343 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -19,6 +19,7 @@ class HomeFeedOverlay extends StatelessWidget { required this.drama, required this.episode, required this.liked, + this.likeCount, required this.saved, required this.onLike, required this.onSave, @@ -35,6 +36,7 @@ class HomeFeedOverlay extends StatelessWidget { final Drama drama; final Episode episode; final bool liked; + final int? likeCount; final bool saved; final VoidCallback onLike; final VoidCallback onSave; @@ -123,7 +125,8 @@ class HomeFeedOverlay extends StatelessWidget { _RailItem( key: const Key('rail-like'), icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - label: l10n.like, + label: likeCount?.toString() ?? l10n.like, + countKey: likeCount == null ? null : const Key('rail-like-count'), selected: liked, onTap: onLike, ), @@ -375,12 +378,14 @@ class _RailItem extends StatelessWidget { required this.label, required this.onTap, this.selected = false, + this.countKey, }); final IconData icon; final String label; final VoidCallback onTap; final bool selected; + final Key? countKey; @override Widget build(BuildContext context) { @@ -404,6 +409,7 @@ class _RailItem extends StatelessWidget { const SizedBox(height: 2), Text( label, + key: countKey, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( diff --git a/test/comment/comment_controller_test.dart b/test/comment/comment_controller_test.dart index b47f97f..30b0868 100644 --- a/test/comment/comment_controller_test.dart +++ b/test/comment/comment_controller_test.dart @@ -42,6 +42,27 @@ void main() { model.dispose(); }); + test('should_expose_exact_count_only_when_last_page_is_loaded', () async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [commentFixture(id: '2')], + nextCursor: '2', + hasMore: true, + ), + CommentPage(comments: [commentFixture(id: '1')]), + ], + ); + final model = controller(repository); + + await model.load(); + expect(model.exactCount, isNull); + + await model.loadMore(); + expect(model.exactCount, 2); + model.dispose(); + }); + test('should_use_server_content_and_pending_status_when_review_is_returned', () async { final repository = FakeCommentRepository( publishResult: commentFixture( diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 4c5d436..413d19e 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -62,11 +62,13 @@ void main() { await _pumpSheet(tester, repository: repository); expect(find.text('Great episode'), findsOneWidget); expect(find.byKey(const Key('comment-list-load-more')), findsOneWidget); + expect(find.byKey(const Key('comment-count-label')), findsNothing); await tester.tap(find.byKey(const Key('comment-list-load-more'))); await tester.pumpAndSettle(); expect(find.text('Second page'), findsOneWidget); + expect(find.text('Comments · 2'), findsOneWidget); expect(repository.pageCalls.last.cursor, '10'); }); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 753de68..82badc6 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -147,6 +147,48 @@ void main() { 'Fallback Drama', ); }); + + testWidgets('should_show_server_like_count_when_count_is_available', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) => Scaffold( + body: HomeFeedOverlay( + l10n: AppLocalizations.of(context)!, + drama: _drama, + episode: const Episode( + id: 'count-episode', + number: 1, + title: 'Count Episode', + isFree: true, + ), + liked: true, + likeCount: 128, + saved: false, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onEpisodes: () {}, + onDownload: () {}, + onClear: () {}, + onSearch: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('rail-like-count')), findsOneWidget); + expect(find.text('128'), findsOneWidget); + }); } const _drama = Drama( diff --git a/test/interaction/interaction_controller_test.dart b/test/interaction/interaction_controller_test.dart index b2690b3..e79b8cc 100644 --- a/test/interaction/interaction_controller_test.dart +++ b/test/interaction/interaction_controller_test.dart @@ -26,6 +26,23 @@ void main() { expect(result.isSuccess, isTrue); expect(controller.isLiked('5'), isFalse); + expect(controller.likeCount('5'), 0); + controller.dispose(); + }); + + test('should_expose_latest_server_count_when_two_toggles_succeed', () async { + final repository = FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 12), + ); + final controller = InteractionController(repository); + + await controller.toggleDramaLike('5'); + expect(controller.likeCount('5'), 12); + + repository.nextLikeResult = const LikeToggleResult(liked: false, count: 11); + await controller.toggleDramaLike('5'); + + expect(controller.likeCount('5'), 11); controller.dispose(); }); @@ -55,6 +72,7 @@ void main() { controller.clear(); expect(controller.isLiked('5'), isFalse); + expect(controller.likeCount('5'), isNull); controller.dispose(); }); }