From de941c841cd3ef13ce2f10aaf1957c715e7db1d4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 22:43:04 +0800 Subject: [PATCH 001/124] =?UTF-8?q?fix(app):=20=E5=AF=B9=E9=BD=90=20releas?= =?UTF-8?q?e=20=E4=BF=AE=E5=A4=8D=EF=BC=88=E6=92=AD=E6=94=BE=E6=98=8E?= =?UTF-8?q?=E6=96=87HTTP=E3=80=81shared=5Fpreferences=20=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E9=98=B2=E5=B4=A9=E6=BA=83=E3=80=81=E5=8F=8C=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 3 +- lib/core/config/app_config.dart | 28 ++++++++ lib/core/config/server_endpoint.dart | 12 ++-- .../data/services/auth_session_store.dart | 8 +-- lib/main.dart | 2 + pubspec.lock | 70 ++++++------------- pubspec.yaml | 28 +------- 7 files changed, 67 insertions(+), 84 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 91bbba2..2571b16 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,8 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> probeAndApplyBaseUrl() async { + try { + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 2); + final request = await client.getUrl( + Uri.parse('$lanBaseUrl/app/categories'), + ); + final response = await request.close().timeout(const Duration(seconds: 2)); + await response.drain(); + client.close(force: true); + if (response.statusCode == 200) { + applyRuntimeBaseUrl(lanBaseUrl); + return; + } + } catch (_) { + // 内网不可达,回落到公网。 + } + applyRuntimeBaseUrl( + compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl, + ); + } + /// 后端返回的是相对路径(/files/...),这里补上服务地址。 static String mediaUrl(String path) { if (path.isEmpty) return ''; diff --git a/lib/core/config/server_endpoint.dart b/lib/core/config/server_endpoint.dart index 332eb6b..37355fc 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, - SharedPreferencesAsync? preferences, + SharedPreferences? preferences, }) async { if (!debug) return; try { - final prefs = preferences ?? SharedPreferencesAsync(); + final prefs = preferences ?? await SharedPreferences.getInstance(); 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, { - SharedPreferencesAsync? preferences, + SharedPreferences? preferences, }) async { final normalized = normalize(raw); if (normalized == null) return false; try { - await (preferences ?? SharedPreferencesAsync()).setString( + await (preferences ?? await SharedPreferences.getInstance()).setString( preferenceKey, normalized, ); @@ -76,9 +76,9 @@ abstract final class ServerEndpoint { } /// Removes the persisted runtime endpoint. - static Future clear({SharedPreferencesAsync? preferences}) async { + static Future clear({SharedPreferences? preferences}) async { try { - await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey); + await (preferences ?? await SharedPreferences.getInstance()).remove(preferenceKey); } catch (_) { // Clearing an optional developer override is best effort. } diff --git a/lib/core/data/services/auth_session_store.dart b/lib/core/data/services/auth_session_store.dart index 1586759..e03cc6b 100644 --- a/lib/core/data/services/auth_session_store.dart +++ b/lib/core/data/services/auth_session_store.dart @@ -23,7 +23,7 @@ class AuthSessionStore { Future deviceId() async { if (_cachedDeviceId != null) return _cachedDeviceId!; - final prefs = SharedPreferencesAsync(); + final prefs = await SharedPreferences.getInstance(); var value = await prefs.getString(_kDeviceId) ?? ''; if (value.isEmpty) { final random = Random(); @@ -37,7 +37,7 @@ class AuthSessionStore { } Future read() async { - final prefs = SharedPreferencesAsync(); + final prefs = await SharedPreferences.getInstance(); final accessToken = await prefs.getString(_kAccessToken) ?? ''; final refreshToken = await prefs.getString(_kRefreshToken) ?? ''; if (accessToken.isEmpty || refreshToken.isEmpty) return null; @@ -58,14 +58,14 @@ class AuthSessionStore { } Future save(AuthSession session) async { - final prefs = SharedPreferencesAsync(); + 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())); } Future clear() async { - final prefs = SharedPreferencesAsync(); + final prefs = await SharedPreferences.getInstance(); await prefs.remove(_kAccessToken); await prefs.remove(_kRefreshToken); await prefs.remove(_kUser); diff --git a/lib/main.dart b/lib/main.dart index cfc68ce..bb45154 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'app.dart'; +import 'core/config/app_config.dart'; import 'core/config/server_endpoint.dart'; Future main() async { @@ -12,5 +13,6 @@ Future main() async { SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), ServerEndpoint.load(), ]); + await AppConfig.probeAndApplyBaseUrl(); runApp(const LumaDramaApp()); } diff --git a/pubspec.lock b/pubspec.lock index 2db1ec3..7dc0e15 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: clock - sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.3" + version: "1.1.2" collection: dependency: transitive description: @@ -208,26 +208,10 @@ packages: dependency: transitive description: name: intl - sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.flutter-io.cn" source: hosted - version: "0.20.3" - jni: - dependency: transitive - description: - name: jni - sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.0" - jni_util: - dependency: transitive - description: - name: jni_util - sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.0.0" + version: "0.20.2" json_annotation: dependency: transitive description: @@ -272,10 +256,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -288,10 +272,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.flutter-io.cn" source: hosted - version: "1.19.0" + version: "1.18.0" mime: dependency: transitive description: @@ -308,14 +292,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.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: @@ -392,26 +368,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82" url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.6" + version: "2.3.3" shared_preferences_android: - dependency: transitive + dependency: "direct main" description: name: shared_preferences_android - sha256: b90caa27669d5f3ef9918d55343b44000f088ee9d4685ed822310848004c793e + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.0" + version: "2.4.28" shared_preferences_foundation: - dependency: "direct overridden" + dependency: transitive description: name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.6" + version: "2.5.7" shared_preferences_linux: dependency: transitive description: @@ -421,7 +397,7 @@ packages: source: hosted version: "2.4.1" shared_preferences_platform_interface: - dependency: "direct dev" + dependency: transitive description: name: shared_preferences_platform_interface sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" @@ -461,10 +437,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.flutter-io.cn" source: hosted - version: "1.12.2" + version: "1.12.1" stream_channel: dependency: transitive description: @@ -501,10 +477,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.12" + version: "0.7.11" typed_data: dependency: transitive description: @@ -517,10 +493,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.3" + version: "2.2.0" video_player: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d184d02..092293d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,8 @@ dependencies: dio: ^5.7.0 provider: ^6.1.5+1 video_player: ^2.14.0 - shared_preferences: ^2.5.5 + shared_preferences: 2.3.3 + shared_preferences_android: 2.4.28 flutter_localizations: sdk: flutter @@ -59,13 +60,8 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 - shared_preferences_platform_interface: ^2.4.2 flutter_launcher_icons: ^0.14.4 -# Xcode 16.2 / Swift 6.0.3 cannot parse the trailing comma in foundation 2.6.0. -dependency_overrides: - shared_preferences_foundation: 2.5.6 - # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -82,26 +78,6 @@ flutter: assets: - assets/icon/splash_mark.png - fonts: - - family: Outfit - fonts: - - asset: assets/fonts/Outfit-Regular.ttf - weight: 400 - - asset: assets/fonts/Outfit-SemiBold.ttf - weight: 600 - - asset: assets/fonts/Outfit-Bold.ttf - weight: 700 - - family: PlusJakartaSans - fonts: - - asset: assets/fonts/PlusJakartaSans-Regular.ttf - weight: 400 - - asset: assets/fonts/PlusJakartaSans-Medium.ttf - weight: 500 - - asset: assets/fonts/PlusJakartaSans-SemiBold.ttf - weight: 600 - - asset: assets/fonts/PlusJakartaSans-Bold.ttf - weight: 700 - # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg 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 002/124] =?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 003/124] =?UTF-8?q?feat(app):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=A6=96=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 004/124] =?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 005/124] =?UTF-8?q?feat(app):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E9=A6=96=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 006/124] =?UTF-8?q?feat(app):=20=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E7=82=B9=E8=B5=9E=E4=B8=8E=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E8=AE=A1=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(); }); } From 730e38622f84f56518468be962e38c5779593d27 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 04:32:03 +0800 Subject: [PATCH 007/124] =?UTF-8?q?feat(app):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E9=87=91=E5=B8=81=E5=85=85=E5=80=BC=E4=B8=8E=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=95=86=E5=93=81=E4=B8=8B=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 7 + lib/core/data/models/order.dart | 49 ++++ lib/core/data/models/product.dart | 54 ++++ lib/core/data/remote/commerce_api.dart | 29 +- .../data/repositories/store_repository.dart | 25 ++ lib/core/data/services/store_controller.dart | 75 +++++ lib/l10n/app_en.arb | 24 ++ lib/l10n/app_id.arb | 24 ++ lib/l10n/app_localizations.dart | 120 ++++++++ lib/l10n/app_localizations_en.dart | 69 +++++ lib/l10n/app_localizations_id.dart | 69 +++++ lib/l10n/app_localizations_zh.dart | 68 +++++ lib/l10n/app_zh.arb | 24 ++ lib/pages/mine/mine_view.dart | 22 ++ lib/pages/store/recharge_page.dart | 24 ++ lib/pages/store/store_page.dart | 268 ++++++++++++++++++ lib/pages/store/vip_page.dart | 24 ++ lib/pages/wallet/wallet_page.dart | 51 ++++ test/commerce/store_api_test.dart | 87 ++++++ test/commerce/store_controller_test.dart | 108 +++++++ test/store/store_pages_test.dart | 105 +++++++ 21 files changed, 1325 insertions(+), 1 deletion(-) create mode 100644 lib/core/data/models/order.dart create mode 100644 lib/core/data/models/product.dart create mode 100644 lib/core/data/repositories/store_repository.dart create mode 100644 lib/core/data/services/store_controller.dart create mode 100644 lib/pages/store/recharge_page.dart create mode 100644 lib/pages/store/store_page.dart create mode 100644 lib/pages/store/vip_page.dart create mode 100644 test/commerce/store_api_test.dart create mode 100644 test/commerce/store_controller_test.dart create mode 100644 test/store/store_pages_test.dart diff --git a/lib/app.dart b/lib/app.dart index 970c467..a8c35ea 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -30,6 +30,7 @@ import 'core/data/repositories/favorite_repository.dart'; import 'core/data/repositories/interaction_repository.dart'; import 'core/data/repositories/notice_repository.dart'; import 'core/data/repositories/playback_repository.dart'; +import 'core/data/repositories/store_repository.dart'; import 'core/data/repositories/watch_history_repository.dart'; import 'core/data/services/auth_controller.dart'; import 'core/data/services/auth_session_store.dart'; @@ -62,6 +63,7 @@ class LumaDramaApp extends StatefulWidget { this.noticeRepository, this.accountRepository, this.commerceRepository, + this.storeRepository, this.haptics = const FlutterHaptics(), }); @@ -79,6 +81,7 @@ class LumaDramaApp extends StatefulWidget { final NoticeRepository? noticeRepository; final AccountRepository? accountRepository; final CommerceRepository? commerceRepository; + final StoreRepository? storeRepository; final Haptics haptics; @override @@ -146,6 +149,9 @@ class _LumaDramaAppState extends State { _commerceRepository, ); + late final StoreRepository _storeRepository = + widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient)); + late final CommentRepository _commentRepository = widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient)); @@ -216,6 +222,7 @@ class _LumaDramaAppState extends State { Provider.value(value: _noticeRepository), Provider.value(value: _accountRepository), Provider.value(value: _commerceRepository), + Provider.value(value: _storeRepository), Provider.value(value: widget.haptics), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), diff --git a/lib/core/data/models/order.dart b/lib/core/data/models/order.dart new file mode 100644 index 0000000..27c7501 --- /dev/null +++ b/lib/core/data/models/order.dart @@ -0,0 +1,49 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 充值订单:`POST /app/orders` 创建的 `CREATED` 订单,等待支付。 +class AppOrder { + const AppOrder({ + required this.id, + required this.orderNo, + required this.productName, + required this.coinAmount, + required this.status, + this.productId = '', + this.type = '', + this.amount = '', + this.currency = '', + this.createdAt, + }); + + static const String created = 'CREATED'; + + final String id; + final String orderNo; + final String productId; + final String productName; + final String type; + final int coinAmount; + final String amount; + final String currency; + final String status; + final DateTime? createdAt; + + bool get isCreated => status == created; + + factory AppOrder.fromJson(Map json) { + return AppOrder( + id: (json['id'] ?? '').toString(), + orderNo: json['orderNo'] as String? ?? '', + productId: (json['productId'] ?? '').toString(), + productName: json['productName'] as String? ?? '', + type: json['type'] as String? ?? '', + coinAmount: jsonToInt(json['coinAmount']), + amount: (json['amount'] ?? '').toString(), + currency: json['currency'] as String? ?? '', + status: json['status'] as String? ?? '', + createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''), + ); + } +} diff --git a/lib/core/data/models/product.dart b/lib/core/data/models/product.dart new file mode 100644 index 0000000..e9072a9 --- /dev/null +++ b/lib/core/data/models/product.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 充值商品:`GET /app/products` 的 `COIN_PACK` / `VIP` 条目。 +class AppProduct { + const AppProduct({ + required this.id, + required this.code, + required this.name, + required this.type, + this.coinAmount = 0, + this.bonusCoin = 0, + this.totalCoin = 0, + this.displayPrice = '', + this.currency = '', + this.vipDays = 0, + }); + + static const String coinPackType = 'COIN_PACK'; + static const String vipType = 'VIP'; + + final String id; + final String code; + final String name; + final String type; + final int coinAmount; + final int bonusCoin; + final int totalCoin; + final String displayPrice; + final String currency; + final int vipDays; + + bool get isVip => type == vipType; + + factory AppProduct.fromJson(Map json) { + final coinAmount = jsonToInt(json['coinAmount']); + final bonusCoin = jsonToInt(json['bonusCoin']); + return AppProduct( + id: (json['id'] ?? '').toString(), + code: json['code'] as String? ?? '', + name: json['name'] as String? ?? '', + type: json['type'] as String? ?? '', + coinAmount: coinAmount, + bonusCoin: bonusCoin, + totalCoin: json['totalCoin'] == null + ? coinAmount + bonusCoin + : jsonToInt(json['totalCoin']), + displayPrice: json['displayPrice'] as String? ?? '', + currency: json['currency'] as String? ?? '', + vipDays: jsonToInt(json['vipDays']), + ); + } +} diff --git a/lib/core/data/remote/commerce_api.dart b/lib/core/data/remote/commerce_api.dart index 3dedc68..7364130 100644 --- a/lib/core/data/remote/commerce_api.dart +++ b/lib/core/data/remote/commerce_api.dart @@ -1,5 +1,7 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn +import '../models/order.dart'; +import '../models/product.dart'; import '../models/wallet.dart'; import 'api_client.dart'; @@ -15,6 +17,31 @@ class CommerceApi { throw const ApiException(-1, '返回数据格式异常'); } + /// 上架商品列表;`type` 传 `COIN_PACK` 或 `VIP`。 + Future> products({String? type}) async { + final data = await _client.get( + '/app/products', + query: type == null ? null : {'type': type}, + ); + if (data is List) { + return data + .whereType>() + .map(AppProduct.fromJson) + .toList(); + } + throw const ApiException(-1, '返回数据格式异常'); + } + + /// 创建充值订单,返回 `CREATED` 状态订单。 + Future createOrder(String productId) async { + final data = await _client.post( + '/app/orders', + body: {'productId': productId}, + ); + if (data is Map) return AppOrder.fromJson(data); + throw const ApiException(-1, '返回数据格式异常'); + } + Future unlock(String episodeId) async { final data = await _client.post('/app/episodes/$episodeId/unlock'); if (data is Map) return UnlockResult.fromJson(data); diff --git a/lib/core/data/repositories/store_repository.dart b/lib/core/data/repositories/store_repository.dart new file mode 100644 index 0000000..d2d2e77 --- /dev/null +++ b/lib/core/data/repositories/store_repository.dart @@ -0,0 +1,25 @@ +// Author: fruit.com.cn + +import '../models/order.dart'; +import '../models/product.dart'; +import '../remote/commerce_api.dart'; + +/// 商品与订单来源:充值页、会员页共用。 +abstract interface class StoreRepository { + Future> fetchProducts({String? type}); + + Future createOrder(String productId); +} + +class HttpStoreRepository implements StoreRepository { + const HttpStoreRepository(this._api); + + final CommerceApi _api; + + @override + Future> fetchProducts({String? type}) => + _api.products(type: type); + + @override + Future createOrder(String productId) => _api.createOrder(productId); +} diff --git a/lib/core/data/services/store_controller.dart b/lib/core/data/services/store_controller.dart new file mode 100644 index 0000000..5e8adb2 --- /dev/null +++ b/lib/core/data/services/store_controller.dart @@ -0,0 +1,75 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/order.dart'; +import '../models/product.dart'; +import '../repositories/store_repository.dart'; +import '../remote/api_exception.dart'; + +/// 商品货架状态:按类型拉取商品并创建订单。 +class StoreController extends ChangeNotifier { + StoreController(this._repository, {required this.type}); + + static const String coinPackType = AppProduct.coinPackType; + static const String vipType = AppProduct.vipType; + + final StoreRepository _repository; + final String type; + + List products = const []; + bool loading = false; + String? error; + String? purchasingProductId; + String? purchaseError; + AppOrder? order; + bool _disposed = false; + + Future load() async { + loading = true; + error = null; + _notify(); + try { + products = await _repository.fetchProducts(type: type); + } on ApiException catch (exception) { + error = exception.message; + } catch (_) { + error = 'productsLoadFailed'; + } finally { + loading = false; + _notify(); + } + } + + /// 创建订单;成功后保留 [order] 供页面展示订单号。 + Future buy(AppProduct product) async { + if (purchasingProductId != null) return null; + purchasingProductId = product.id; + purchaseError = null; + _notify(); + try { + final created = await _repository.createOrder(product.id); + order = created; + return created; + } on ApiException catch (exception) { + purchaseError = exception.message; + return null; + } catch (_) { + purchaseError = 'orderCreateFailed'; + return null; + } finally { + purchasingProductId = null; + _notify(); + } + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e63895a..94333ca 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -516,4 +516,28 @@ "rankingEmptyTitle": "No ranked stories yet", "rankingEmptyBody": "Titles appear here as soon as they are published.", "rankingTopPick": "TOP PICK" +, "rechargeTitle": "Top Up Coins", + "vipTitle": "VIP Membership", + "buy": "Buy", + "bonusCoin": "+{bonus} bonus", + "@bonusCoin": { "placeholders": { "bonus": { "type": "int" } } }, + "vipDuration": "{days}-day VIP", + "@vipDuration": { "placeholders": { "days": { "type": "int" } } }, + "noCoinPacks": "No coin packs available right now.", + "noVipPlans": "No VIP plans available right now.", + "productsLoadFailed": "Could not load products.", + "orderCreated": "Order {orderNo} created.", + "@orderCreated": { "placeholders": { "orderNo": { "type": "String" } } }, + "paymentPending": "Waiting for payment.", + "orderCreateFailed": "Could not create the order.", + "playbackQuality": "Quality", + "qualityAuto": "Auto", + "qualityAutoHint": "HLS adjusts the bitrate automatically for your connection.", + "searchFailed": "Search failed. Please try again.", + "languageFilter": "Language", + "languageChinese": "Chinese", + "languageIndonesian": "Indonesian", + "languageEnglish": "English", + "searchResultCount": "{count} results", + "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 8a84486..c8b540d 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -514,4 +514,28 @@ "rankingEmptyTitle": "Belum ada peringkat", "rankingEmptyBody": "Judul muncul di sini setelah diterbitkan.", "rankingTopPick": "PILIHAN UTAMA" +, "rechargeTitle": "Isi Ulang Koin", + "vipTitle": "Keanggotaan VIP", + "buy": "Beli", + "bonusCoin": "+{bonus} bonus", + "@bonusCoin": { "placeholders": { "bonus": { "type": "int" } } }, + "vipDuration": "VIP {days} hari", + "@vipDuration": { "placeholders": { "days": { "type": "int" } } }, + "noCoinPacks": "Belum ada paket koin.", + "noVipPlans": "Belum ada paket VIP.", + "productsLoadFailed": "Gagal memuat produk.", + "orderCreated": "Pesanan {orderNo} dibuat.", + "@orderCreated": { "placeholders": { "orderNo": { "type": "String" } } }, + "paymentPending": "Menunggu pembayaran.", + "orderCreateFailed": "Gagal membuat pesanan.", + "playbackQuality": "Kualitas", + "qualityAuto": "Otomatis", + "qualityAutoHint": "HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.", + "searchFailed": "Pencarian gagal. Coba lagi.", + "languageFilter": "Bahasa", + "languageChinese": "Mandarin", + "languageIndonesian": "Indonesia", + "languageEnglish": "Inggris", + "searchResultCount": "{count} hasil", + "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 18215cf..0a9e9d1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2343,6 +2343,126 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'TOP PICK'** String get rankingTopPick; + + /// No description provided for @rechargeTitle. + /// + /// In en, this message translates to: + /// **'Top Up Coins'** + String get rechargeTitle; + + /// No description provided for @vipTitle. + /// + /// In en, this message translates to: + /// **'VIP Membership'** + String get vipTitle; + + /// No description provided for @buy. + /// + /// In en, this message translates to: + /// **'Buy'** + String get buy; + + /// No description provided for @bonusCoin. + /// + /// In en, this message translates to: + /// **'+{bonus} bonus'** + String bonusCoin(int bonus); + + /// No description provided for @vipDuration. + /// + /// In en, this message translates to: + /// **'{days}-day VIP'** + String vipDuration(int days); + + /// No description provided for @noCoinPacks. + /// + /// In en, this message translates to: + /// **'No coin packs available right now.'** + String get noCoinPacks; + + /// No description provided for @noVipPlans. + /// + /// In en, this message translates to: + /// **'No VIP plans available right now.'** + String get noVipPlans; + + /// No description provided for @productsLoadFailed. + /// + /// In en, this message translates to: + /// **'Could not load products.'** + String get productsLoadFailed; + + /// No description provided for @orderCreated. + /// + /// In en, this message translates to: + /// **'Order {orderNo} created.'** + String orderCreated(String orderNo); + + /// No description provided for @paymentPending. + /// + /// In en, this message translates to: + /// **'Waiting for payment.'** + String get paymentPending; + + /// No description provided for @orderCreateFailed. + /// + /// In en, this message translates to: + /// **'Could not create the order.'** + String get orderCreateFailed; + + /// No description provided for @playbackQuality. + /// + /// In en, this message translates to: + /// **'Quality'** + String get playbackQuality; + + /// No description provided for @qualityAuto. + /// + /// In en, this message translates to: + /// **'Auto'** + String get qualityAuto; + + /// No description provided for @qualityAutoHint. + /// + /// In en, this message translates to: + /// **'HLS adjusts the bitrate automatically for your connection.'** + String get qualityAutoHint; + + /// No description provided for @searchFailed. + /// + /// In en, this message translates to: + /// **'Search failed. Please try again.'** + String get searchFailed; + + /// No description provided for @languageFilter. + /// + /// In en, this message translates to: + /// **'Language'** + String get languageFilter; + + /// No description provided for @languageChinese. + /// + /// In en, this message translates to: + /// **'Chinese'** + String get languageChinese; + + /// No description provided for @languageIndonesian. + /// + /// In en, this message translates to: + /// **'Indonesian'** + String get languageIndonesian; + + /// No description provided for @languageEnglish. + /// + /// In en, this message translates to: + /// **'English'** + String get languageEnglish; + + /// No description provided for @searchResultCount. + /// + /// In en, this message translates to: + /// **'{count} results'** + String searchResultCount(int count); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 24f9aa8..b45cf0c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1199,4 +1199,73 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rankingTopPick => 'TOP PICK'; + + @override + String get rechargeTitle => 'Top Up Coins'; + + @override + String get vipTitle => 'VIP Membership'; + + @override + String get buy => 'Buy'; + + @override + String bonusCoin(int bonus) { + return '+$bonus bonus'; + } + + @override + String vipDuration(int days) { + return '$days-day VIP'; + } + + @override + String get noCoinPacks => 'No coin packs available right now.'; + + @override + String get noVipPlans => 'No VIP plans available right now.'; + + @override + String get productsLoadFailed => 'Could not load products.'; + + @override + String orderCreated(String orderNo) { + return 'Order $orderNo created.'; + } + + @override + String get paymentPending => 'Waiting for payment.'; + + @override + String get orderCreateFailed => 'Could not create the order.'; + + @override + String get playbackQuality => 'Quality'; + + @override + String get qualityAuto => 'Auto'; + + @override + String get qualityAutoHint => + 'HLS adjusts the bitrate automatically for your connection.'; + + @override + String get searchFailed => 'Search failed. Please try again.'; + + @override + String get languageFilter => 'Language'; + + @override + String get languageChinese => 'Chinese'; + + @override + String get languageIndonesian => 'Indonesian'; + + @override + String get languageEnglish => 'English'; + + @override + String searchResultCount(int count) { + return '$count results'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index d45a22e..6004c6a 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1197,4 +1197,73 @@ class AppLocalizationsId extends AppLocalizations { @override String get rankingTopPick => 'PILIHAN UTAMA'; + + @override + String get rechargeTitle => 'Isi Ulang Koin'; + + @override + String get vipTitle => 'Keanggotaan VIP'; + + @override + String get buy => 'Beli'; + + @override + String bonusCoin(int bonus) { + return '+$bonus bonus'; + } + + @override + String vipDuration(int days) { + return 'VIP $days hari'; + } + + @override + String get noCoinPacks => 'Belum ada paket koin.'; + + @override + String get noVipPlans => 'Belum ada paket VIP.'; + + @override + String get productsLoadFailed => 'Gagal memuat produk.'; + + @override + String orderCreated(String orderNo) { + return 'Pesanan $orderNo dibuat.'; + } + + @override + String get paymentPending => 'Menunggu pembayaran.'; + + @override + String get orderCreateFailed => 'Gagal membuat pesanan.'; + + @override + String get playbackQuality => 'Kualitas'; + + @override + String get qualityAuto => 'Otomatis'; + + @override + String get qualityAutoHint => + 'HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.'; + + @override + String get searchFailed => 'Pencarian gagal. Coba lagi.'; + + @override + String get languageFilter => 'Bahasa'; + + @override + String get languageChinese => 'Mandarin'; + + @override + String get languageIndonesian => 'Indonesia'; + + @override + String get languageEnglish => 'Inggris'; + + @override + String searchResultCount(int count) { + return '$count hasil'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d384d2b..53e7a74 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1176,4 +1176,72 @@ class AppLocalizationsZh extends AppLocalizations { @override String get rankingTopPick => '今日榜首'; + + @override + String get rechargeTitle => '充值金币'; + + @override + String get vipTitle => 'VIP 会员'; + + @override + String get buy => '购买'; + + @override + String bonusCoin(int bonus) { + return '赠 $bonus'; + } + + @override + String vipDuration(int days) { + return '$days 天会员'; + } + + @override + String get noCoinPacks => '暂无可购金币包。'; + + @override + String get noVipPlans => '暂无可开通会员。'; + + @override + String get productsLoadFailed => '商品加载失败。'; + + @override + String orderCreated(String orderNo) { + return '订单 $orderNo 已创建。'; + } + + @override + String get paymentPending => '等待支付。'; + + @override + String get orderCreateFailed => '订单创建失败。'; + + @override + String get playbackQuality => '清晰度'; + + @override + String get qualityAuto => '自动'; + + @override + String get qualityAutoHint => 'HLS 会根据当前网络自动切换码率。'; + + @override + String get searchFailed => '搜索失败,请重试。'; + + @override + String get languageFilter => '语言'; + + @override + String get languageChinese => '中文'; + + @override + String get languageIndonesian => '印尼语'; + + @override + String get languageEnglish => '英语'; + + @override + String searchResultCount(int count) { + return '$count 个结果'; + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 60b8761..f637bac 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -476,4 +476,28 @@ "rankingEmptyTitle": "暂无榜单内容", "rankingEmptyBody": "新剧上线后会自动进入榜单。", "rankingTopPick": "今日榜首" +, "rechargeTitle": "充值金币", + "vipTitle": "VIP 会员", + "buy": "购买", + "bonusCoin": "赠 {bonus}", + "@bonusCoin": { "placeholders": { "bonus": { "type": "int" } } }, + "vipDuration": "{days} 天会员", + "@vipDuration": { "placeholders": { "days": { "type": "int" } } }, + "noCoinPacks": "暂无可购金币包。", + "noVipPlans": "暂无可开通会员。", + "productsLoadFailed": "商品加载失败。", + "orderCreated": "订单 {orderNo} 已创建。", + "@orderCreated": { "placeholders": { "orderNo": { "type": "String" } } }, + "paymentPending": "等待支付。", + "orderCreateFailed": "订单创建失败。", + "playbackQuality": "清晰度", + "qualityAuto": "自动", + "qualityAutoHint": "HLS 会根据当前网络自动切换码率。", + "searchFailed": "搜索失败,请重试。", + "languageFilter": "语言", + "languageChinese": "中文", + "languageIndonesian": "印尼语", + "languageEnglish": "英语", + "searchResultCount": "{count} 个结果", + "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 4174e76..0e2d6cf 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -25,6 +25,7 @@ import '../account/account_entry_card.dart'; import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; import '../notices/notices_page.dart'; +import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; import 'mine_binding.dart'; @@ -373,6 +374,21 @@ class _MyPageState extends State { ); } + void _openVip() { + if (!widget.signedIn) { + widget.onSignIn?.call(); + return; + } + final wallet = widget.wallet; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VipPage( + onPurchased: wallet == null ? null : () => unawaited(wallet.load()), + ), + ), + ); + } + void _openWallet(BuildContext context) { if (!widget.signedIn) { widget.onSignIn?.call(); @@ -835,6 +851,12 @@ class _MyPageState extends State { MaterialPageRoute(builder: (_) => const DownloadsPage()), ), ), + _menuTile( + key: const Key('mine-vip'), + icon: Icons.workspace_premium_outlined, + title: l10n.vipMembership, + onTap: _openVip, + ), _menuTile( icon: Icons.settings_rounded, title: l10n.settings, diff --git a/lib/pages/store/recharge_page.dart b/lib/pages/store/recharge_page.dart new file mode 100644 index 0000000..007d971 --- /dev/null +++ b/lib/pages/store/recharge_page.dart @@ -0,0 +1,24 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/data/repositories/store_repository.dart'; +import '../../core/data/services/store_controller.dart'; +import 'store_page.dart'; + +/// 金币充值页:展示上架金币包并在点击购买时创建订单。 +class RechargePage extends StatelessWidget { + const RechargePage({super.key, this.repository, this.onPurchased}); + + final StoreRepository? repository; + final VoidCallback? onPurchased; + + @override + Widget build(BuildContext context) { + return StorePage( + type: StoreController.coinPackType, + repository: repository, + onPurchased: onPurchased, + ); + } +} diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart new file mode 100644 index 0000000..cffb358 --- /dev/null +++ b/lib/pages/store/store_page.dart @@ -0,0 +1,268 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/product.dart'; +import '../../core/data/repositories/store_repository.dart'; +import '../../core/data/services/store_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; + +/// 商品货架:金币包与会员档位共用一套真实接口渲染。 +class StorePage extends StatefulWidget { + const StorePage({ + super.key, + required this.type, + this.repository, + this.onPurchased, + }); + + final String type; + final StoreRepository? repository; + final VoidCallback? onPurchased; + + @override + State createState() => _StorePageState(); +} + +class _StorePageState extends State { + late final StoreController controller; + + @override + void initState() { + super.initState(); + controller = StoreController( + widget.repository ?? context.read(), + type: widget.type, + ); + unawaited(controller.load()); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final isVip = widget.type == StoreController.vipType; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('store-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onSurface, + title: Text(isVip ? l10n.vipTitle : l10n.rechargeTitle), + ), + body: SafeArea( + top: false, + child: ListenableBuilder( + listenable: controller, + builder: (context, _) => _body(l10n, isVip), + ), + ), + ), + ); + } + + Widget _body(AppLocalizations l10n, bool isVip) { + if (controller.loading && controller.products.isEmpty) { + return const Center( + child: CircularProgressIndicator(key: Key('store-loading')), + ); + } + if (controller.error != null && controller.products.isEmpty) { + return EmptyState( + key: const Key('store-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, controller.error, l10n.productsLoadFailed), + actionLabel: l10n.retry, + actionKey: const Key('store-retry'), + onAction: () => unawaited(controller.load()), + ); + } + if (controller.products.isEmpty) { + return EmptyState( + key: const Key('store-empty'), + icon: isVip ? Icons.workspace_premium_outlined : Icons.savings_outlined, + title: isVip ? l10n.noVipPlans : l10n.noCoinPacks, + ); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + itemCount: controller.products.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) => _ProductTile( + product: controller.products[index], + purchasing: + controller.purchasingProductId == controller.products[index].id, + onBuy: () => _buy(controller.products[index]), + ), + ); + } + + Future _buy(AppProduct product) async { + final order = await controller.buy(product); + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + if (order == null) { + final message = errorText( + l10n, + controller.purchaseError, + l10n.orderCreateFailed, + ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + return; + } + widget.onPurchased?.call(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${l10n.orderCreated(order.orderNo)} ${l10n.paymentPending}', + ), + ), + ); + } +} + +class _ProductTile extends StatelessWidget { + const _ProductTile({ + required this.product, + required this.purchasing, + required this.onBuy, + }); + + final AppProduct product; + final bool purchasing; + final VoidCallback onBuy; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: Key('store-product-${product.id}'), + padding: const EdgeInsets.fromLTRB(14, 14, 12, 14), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.gold.withValues(alpha: 0.26)), + ), + child: Row( + children: [ + _ProductBadge(vip: product.isVip), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + key: Key('store-product-name-${product.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + _detail(l10n), + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + product.displayPrice, + key: Key('store-product-price-${product.id}'), + style: const TextStyle( + color: AppColors.gold, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: 88, + height: 32, + child: FilledButton( + key: Key('store-buy-${product.id}'), + onPressed: purchasing ? null : onBuy, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: purchasing + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.onPrimary, + ), + ) + : Text(l10n.buy), + ), + ), + ], + ), + ], + ), + ); + } + + String _detail(AppLocalizations l10n) { + if (product.isVip) return l10n.vipDuration(product.vipDays); + final base = l10n.walletBalanceShort(product.coinAmount); + if (product.bonusCoin <= 0) return base; + return '$base · ${l10n.bonusCoin(product.bonusCoin)}'; + } +} + +class _ProductBadge extends StatelessWidget { + const _ProductBadge({required this.vip}); + + final bool vip; + + @override + Widget build(BuildContext context) { + return Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: AppColors.goldCard, + borderRadius: BorderRadius.circular(13), + ), + child: Icon( + vip ? Icons.workspace_premium_rounded : Icons.monetization_on_rounded, + color: AppColors.gold, + size: AppDimens.sectionTitle, + ), + ); + } +} diff --git a/lib/pages/store/vip_page.dart b/lib/pages/store/vip_page.dart new file mode 100644 index 0000000..f6430b9 --- /dev/null +++ b/lib/pages/store/vip_page.dart @@ -0,0 +1,24 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/data/repositories/store_repository.dart'; +import '../../core/data/services/store_controller.dart'; +import 'store_page.dart'; + +/// 会员页:展示 VIP 档位并在点击开通时创建订单。 +class VipPage extends StatelessWidget { + const VipPage({super.key, this.repository, this.onPurchased}); + + final StoreRepository? repository; + final VoidCallback? onPurchased; + + @override + Widget build(BuildContext context) { + return StorePage( + type: StoreController.vipType, + repository: repository, + onPurchased: onPurchased, + ); + } +} diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart index fcb12de..bc2f055 100644 --- a/lib/pages/wallet/wallet_page.dart +++ b/lib/pages/wallet/wallet_page.dart @@ -13,6 +13,8 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/empty_state.dart'; +import '../store/recharge_page.dart'; +import '../store/vip_page.dart'; import '../../l10n/app_localizations.dart'; /// 金币钱包:余额、累计收支与最近流水。 @@ -56,6 +58,53 @@ class _WalletPageState extends State { ); } + Widget _storeActions(AppLocalizations l10n, WalletController wallet) { + return Row( + children: [ + Expanded( + child: FilledButton.icon( + key: const Key('wallet-top-up'), + onPressed: () => _openRecharge(wallet), + icon: const Icon(Icons.add_card_rounded, size: 16), + label: Text(l10n.topUpWallet), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + key: const Key('wallet-vip'), + onPressed: () => _openVip(wallet), + icon: const Icon(Icons.workspace_premium_outlined, size: 16), + label: Text(l10n.vipMembership), + style: OutlinedButton.styleFrom(foregroundColor: AppColors.gold), + ), + ), + ], + ); + } + + void _openRecharge(WalletController wallet) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RechargePage( + onPurchased: () => unawaited(wallet.load()), + ), + ), + ); + } + + void _openVip(WalletController wallet) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())), + ), + ); + } + Widget _body(AppLocalizations l10n, WalletController wallet) { if (wallet.loading && wallet.snapshot == null) { return const Center(child: CircularProgressIndicator()); @@ -77,6 +126,8 @@ class _WalletPageState extends State { padding: const EdgeInsets.fromLTRB(16, 8, 16, 28), children: [ _BalanceHero(wallet: snapshot.wallet), + const SizedBox(height: 12), + _storeActions(l10n, wallet), const SizedBox(height: 14), Row( children: [ diff --git a/test/commerce/store_api_test.dart b/test/commerce/store_api_test.dart new file mode 100644 index 0000000..89b18fb --- /dev/null +++ b/test/commerce/store_api_test.dart @@ -0,0 +1,87 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/commerce_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test('should_request_coin_packs_when_type_is_given', () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': [ + { + 'id': '2111012753262120962', + 'code': 'coin_550', + 'name': '550 Coins', + 'type': 'COIN_PACK', + 'coinAmount': '500', + 'bonusCoin': '50', + 'totalCoin': '550', + 'displayPrice': 'Rp 15.000', + 'currency': 'IDR', + 'vipDays': 0, + }, + ], + }), + ), + ); + final api = CommerceApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final products = await api.products(type: 'COIN_PACK'); + + final request = adapter.requests.single; + expect(request.method, 'GET'); + expect(request.uri.path, '/app/products'); + expect(request.queryParameters['type'], 'COIN_PACK'); + expect(products.single.id, '2111012753262120962'); + expect(products.single.coinAmount, 500); + expect(products.single.bonusCoin, 50); + expect(products.single.displayPrice, 'Rp 15.000'); + }); + + test('should_post_product_id_when_creating_an_order', () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'id': '2111012753262120999', + 'orderNo': 'ORD20261020001', + 'productId': '2111012753262120962', + 'productName': '550 Coins', + 'type': 'COIN_PACK', + 'coinAmount': '550', + 'status': 'CREATED', + 'createdAt': '2026-10-20 10:00:00', + }, + }), + ), + ); + final api = CommerceApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final order = await api.createOrder('2111012753262120962'); + + final request = adapter.requests.single; + expect(request.method, 'POST'); + expect(request.uri.path, '/app/orders'); + final body = request.data is String + ? jsonDecode(request.data as String) + : request.data; + expect(body, {'productId': '2111012753262120962'}); + expect(order.orderNo, 'ORD20261020001'); + expect(order.coinAmount, 550); + expect(order.status, 'CREATED'); + }); +} diff --git a/test/commerce/store_controller_test.dart b/test/commerce/store_controller_test.dart new file mode 100644 index 0000000..d1b83df --- /dev/null +++ b/test/commerce/store_controller_test.dart @@ -0,0 +1,108 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/order.dart'; +import 'package:fruitshorts/core/data/models/product.dart'; +import 'package:fruitshorts/core/data/remote/api_exception.dart'; +import 'package:fruitshorts/core/data/repositories/store_repository.dart'; +import 'package:fruitshorts/core/data/services/store_controller.dart'; + +void main() { + test('should_expose_coin_packs_when_store_loads', () async { + final repository = _FakeStoreRepository( + products: [_product('550 Coins', coinAmount: 500, bonusCoin: 50)], + ); + final controller = StoreController( + repository, + type: StoreController.coinPackType, + ); + + await controller.load(); + + expect(repository.requestedTypes, [StoreController.coinPackType]); + expect(controller.products.single.name, '550 Coins'); + expect(controller.error, isNull); + }); + + test('should_create_order_and_keep_purchase_when_buying', () async { + final repository = _FakeStoreRepository( + products: [_product('550 Coins')], + order: const AppOrder( + id: '99', + orderNo: 'ORD1', + productName: '550 Coins', + coinAmount: 550, + status: 'CREATED', + ), + ); + final controller = StoreController( + repository, + type: StoreController.coinPackType, + ); + await controller.load(); + + final order = await controller.buy(controller.products.single); + + expect(repository.orderedProductIds, ['2111012753262120962']); + expect(order?.orderNo, 'ORD1'); + expect(controller.purchaseError, isNull); + expect(controller.purchasingProductId, isNull); + }); + + test('should_expose_server_message_when_order_is_rejected', () async { + final repository = _FakeStoreRepository( + products: [_product('550 Coins')], + orderError: const ApiException(400, 'Product is offline'), + ); + final controller = StoreController( + repository, + type: StoreController.coinPackType, + ); + await controller.load(); + + final order = await controller.buy(controller.products.single); + + expect(order, isNull); + expect(controller.purchaseError, 'Product is offline'); + }); +} + +AppProduct _product(String name, {int coinAmount = 550, int bonusCoin = 0}) => + AppProduct( + id: '2111012753262120962', + code: 'coin_550', + name: name, + type: StoreController.coinPackType, + coinAmount: coinAmount, + bonusCoin: bonusCoin, + totalCoin: coinAmount + bonusCoin, + displayPrice: 'Rp 15.000', + vipDays: 0, + ); + +class _FakeStoreRepository implements StoreRepository { + _FakeStoreRepository({ + this.products = const [], + this.order, + this.orderError, + }); + + final List products; + final AppOrder? order; + final ApiException? orderError; + final List requestedTypes = []; + final List orderedProductIds = []; + + @override + Future> fetchProducts({String? type}) async { + requestedTypes.add(type); + return products; + } + + @override + Future createOrder(String productId) async { + orderedProductIds.add(productId); + if (orderError != null) throw orderError!; + return order!; + } +} diff --git a/test/store/store_pages_test.dart b/test/store/store_pages_test.dart new file mode 100644 index 0000000..c851692 --- /dev/null +++ b/test/store/store_pages_test.dart @@ -0,0 +1,105 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/order.dart'; +import 'package:fruitshorts/core/data/models/product.dart'; +import 'package:fruitshorts/core/data/repositories/store_repository.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/store/recharge_page.dart'; +import 'package:fruitshorts/pages/store/vip_page.dart'; + +void main() { + testWidgets('should_show_coin_packs_and_create_order_when_buying', ( + tester, + ) async { + final repository = _FakeStoreRepository(); + + await tester.pumpWidget(_app(RechargePage(repository: repository))); + await tester.pumpAndSettle(); + + expect(find.text('550 Coins'), findsOneWidget); + expect(find.textContaining('500 coins'), findsOneWidget); + expect(find.textContaining('+50 bonus'), findsOneWidget); + expect(find.text('Rp 15.000'), findsOneWidget); + + await tester.tap(find.byKey(const Key('store-buy-pack-1'))); + await tester.pumpAndSettle(); + + expect(repository.requestedTypes, ['COIN_PACK']); + expect(repository.orderedProductIds, ['pack-1']); + expect(find.textContaining('Order ORD1 created.'), findsOneWidget); + }); + + testWidgets('should_show_vip_tiers_and_create_order_when_joining', ( + tester, + ) async { + final repository = _FakeStoreRepository(); + + await tester.pumpWidget(_app(VipPage(repository: repository))); + await tester.pumpAndSettle(); + + expect(find.text('30-day VIP'), findsOneWidget); + expect(find.text('Rp 99.000'), findsOneWidget); + + await tester.tap(find.byKey(const Key('store-buy-vip-1'))); + await tester.pumpAndSettle(); + + expect(repository.requestedTypes, ['VIP']); + expect(repository.orderedProductIds, ['vip-1']); + expect(find.textContaining('Order ORD1 created.'), findsOneWidget); + }); +} + +Widget _app(Widget home) => MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: home, +); + +class _FakeStoreRepository implements StoreRepository { + final List requestedTypes = []; + final List orderedProductIds = []; + + @override + Future> fetchProducts({String? type}) async { + requestedTypes.add(type); + if (type == AppProduct.vipType) { + return const [ + AppProduct( + id: 'vip-1', + code: 'vip_30', + name: 'Monthly VIP', + type: AppProduct.vipType, + displayPrice: 'Rp 99.000', + vipDays: 30, + ), + ]; + } + return const [ + AppProduct( + id: 'pack-1', + code: 'coin_550', + name: '550 Coins', + type: AppProduct.coinPackType, + coinAmount: 500, + bonusCoin: 50, + totalCoin: 550, + displayPrice: 'Rp 15.000', + ), + ]; + } + + @override + Future createOrder(String productId) async { + orderedProductIds.add(productId); + return const AppOrder( + id: 'order-1', + orderNo: 'ORD1', + productName: 'Coin pack', + coinAmount: 550, + status: AppOrder.created, + ); + } +} From ade0cc3566c2a57bcc143e1fdbf346dbab0fc542 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 04:34:12 +0800 Subject: [PATCH 008/124] =?UTF-8?q?feat(app):=20=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E8=B0=83=E7=94=A8=E5=90=8E=E7=AB=AF=E5=B9=B6?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=8D=A1=E7=89=87=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 7 ++ lib/core/data/models/drama.dart | 6 + lib/core/data/models/search_result.dart | 22 ++++ lib/core/data/remote/catalog_api.dart | 18 +++ .../data/repositories/search_repository.dart | 26 ++++ lib/pages/discover/discover_binding.dart | 10 +- lib/pages/discover/discover_logic.dart | 118 +++++++++++++++--- lib/pages/discover/discover_view.dart | 19 ++- .../discover/widgets/home_drama_grid.dart | 29 +++++ test/catalog/search_api_test.dart | 51 ++++++++ test/discover/discover_search_test.dart | 116 +++++++++++++++++ test/discover/home_all_dramas_grid_test.dart | 1 + test/support/fake_catalog.dart | 21 +++- 13 files changed, 424 insertions(+), 20 deletions(-) create mode 100644 lib/core/data/models/search_result.dart create mode 100644 lib/core/data/repositories/search_repository.dart create mode 100644 test/catalog/search_api_test.dart create mode 100644 test/discover/discover_search_test.dart diff --git a/lib/app.dart b/lib/app.dart index a8c35ea..8ddc4ee 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -30,6 +30,7 @@ import 'core/data/repositories/favorite_repository.dart'; import 'core/data/repositories/interaction_repository.dart'; import 'core/data/repositories/notice_repository.dart'; import 'core/data/repositories/playback_repository.dart'; +import 'core/data/repositories/search_repository.dart'; import 'core/data/repositories/store_repository.dart'; import 'core/data/repositories/watch_history_repository.dart'; import 'core/data/services/auth_controller.dart'; @@ -64,6 +65,7 @@ class LumaDramaApp extends StatefulWidget { this.accountRepository, this.commerceRepository, this.storeRepository, + this.searchRepository, this.haptics = const FlutterHaptics(), }); @@ -82,6 +84,7 @@ class LumaDramaApp extends StatefulWidget { final AccountRepository? accountRepository; final CommerceRepository? commerceRepository; final StoreRepository? storeRepository; + final SearchRepository? searchRepository; final Haptics haptics; @override @@ -149,6 +152,9 @@ class _LumaDramaAppState extends State { _commerceRepository, ); + late final SearchRepository _searchRepository = + widget.searchRepository ?? HttpSearchRepository(CatalogApi(_apiClient)); + late final StoreRepository _storeRepository = widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient)); @@ -223,6 +229,7 @@ class _LumaDramaAppState extends State { Provider.value(value: _accountRepository), Provider.value(value: _commerceRepository), Provider.value(value: _storeRepository), + Provider.value(value: _searchRepository), Provider.value(value: widget.haptics), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index fed6cfa..d1b41c3 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -65,6 +65,7 @@ class Drama { this.freeEpisodes = 0, this.isHot = false, this.viewCount = 0, + this.language = '', this.detailLoaded = false, }); @@ -80,6 +81,9 @@ class Drama { final bool isHot; final int viewCount; + /// 卡片语种(如 zh-CN / id),用于剧场语言筛选。 + final String language; + /// 详情接口是否已经取过完整剧集列表。 /// 列表卡片只带一集可播预览,需要完整剧集时再进详情。 final bool detailLoaded; @@ -104,6 +108,7 @@ class Drama { isHot: json['isHot'] == true, // viewCount 后端是 Long,按字符串下发(见 json_numbers.dart) viewCount: jsonToInt(json['viewCount']), + language: json['language'] as String? ?? '', ); } @@ -120,6 +125,7 @@ class Drama { freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0, isHot: json['isHot'] == true, viewCount: jsonToInt(json['viewCount']), + language: json['language'] as String? ?? '', detailLoaded: true, episodes: (json['episodes'] as List? ?? const []) .whereType>() diff --git a/lib/core/data/models/search_result.dart b/lib/core/data/models/search_result.dart new file mode 100644 index 0000000..f33781d --- /dev/null +++ b/lib/core/data/models/search_result.dart @@ -0,0 +1,22 @@ +// Author: fruit.com.cn + +import 'drama.dart'; +import 'json_numbers.dart'; + +/// 搜索分页结果:`GET /app/search` 的 `{total, list}`。 +class SearchResult { + const SearchResult({this.total = 0, this.list = const []}); + + final int total; + final List list; + + factory SearchResult.fromJson(Map json) { + return SearchResult( + total: jsonToInt(json['total']), + list: (json['list'] as List? ?? const []) + .whereType>() + .map(Drama.fromCardJson) + .toList(), + ); + } +} diff --git a/lib/core/data/remote/catalog_api.dart b/lib/core/data/remote/catalog_api.dart index 4346460..666c56f 100644 --- a/lib/core/data/remote/catalog_api.dart +++ b/lib/core/data/remote/catalog_api.dart @@ -1,5 +1,6 @@ import '../models/drama.dart'; import '../models/home_feed.dart'; +import '../models/search_result.dart'; import 'api_client.dart'; /// 客户端目录接口,对应后端 `/app/**`。 @@ -16,6 +17,23 @@ class CatalogApi { return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed'))); } + /// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。 + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) async { + final data = await _client.get( + '/app/search', + query: { + 'keyword': keyword, + 'pageIndex': '$pageIndex', + 'pageSize': '$pageSize', + }, + ); + return SearchResult.fromJson(_asMap(data)); + } + Future dramaDetail(String dramaId) async { return Drama.fromDetailJson( _asMap(await _client.get('/app/dramas/$dramaId')), diff --git a/lib/core/data/repositories/search_repository.dart b/lib/core/data/repositories/search_repository.dart new file mode 100644 index 0000000..9380011 --- /dev/null +++ b/lib/core/data/repositories/search_repository.dart @@ -0,0 +1,26 @@ +// Author: fruit.com.cn + +import '../models/search_result.dart'; +import '../remote/catalog_api.dart'; + +/// 搜索数据来源:只调用后端 `/app/search`,不在客户端做本地过滤。 +abstract interface class SearchRepository { + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }); +} + +class HttpSearchRepository implements SearchRepository { + const HttpSearchRepository(this._api); + + final CatalogApi _api; + + @override + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) => _api.search(keyword, pageIndex: pageIndex, pageSize: pageSize); +} diff --git a/lib/pages/discover/discover_binding.dart b/lib/pages/discover/discover_binding.dart index 89024ce..c85dbbe 100644 --- a/lib/pages/discover/discover_binding.dart +++ b/lib/pages/discover/discover_binding.dart @@ -1,6 +1,7 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import 'discover_logic.dart'; @@ -8,5 +9,10 @@ class DiscoverBinding { DiscoverLogic create({ required Drama drama, required WatchHistoryRepository history, - }) => DiscoverLogic(drama: drama, history: history); + SearchRepository? search, + }) => DiscoverLogic( + drama: drama, + history: history, + searchRepository: search, + ); } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index bff295a..aefb818 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -1,20 +1,29 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/watch_progress.dart'; +import '../../core/data/remote/api_exception.dart'; +import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../l10n/app_localizations.dart'; enum DiscoverPageAction { home, my, watchlist, detail, resume } class DiscoverLogic extends ChangeNotifier { - DiscoverLogic({required this.history, required this.drama}) { + DiscoverLogic({ + required this.history, + required this.drama, + this.searchRepository, + }) { refreshPosition(); } final WatchHistoryRepository history; + final SearchRepository? searchRepository; Drama drama; late Future position; WatchProgress? resumeRecord; @@ -22,6 +31,17 @@ class DiscoverLogic extends ChangeNotifier { final FocusNode searchFocus = FocusNode(); final ScrollController scroll = ScrollController(); bool searchOpen = false; + + /// 搜索防抖与分页口径:与后端 `/app/search` 一致。 + Duration searchDebounce = const Duration(milliseconds: 350); + static const int searchPageSize = 20; + List searchResults = const []; + int searchTotal = 0; + bool searchLoading = false; + String? searchError; + Timer? _searchTimer; + String _lastRequestedQuery = ''; + int _searchRequest = 0; bool _disposed = false; void notify() { @@ -51,20 +71,89 @@ class DiscoverLogic extends ChangeNotifier { notify(); } - void onSearchChanged() => notify(); + bool get hasSearchQuery => search.text.trim().isNotEmpty; - List filtered(List dramas) { - final query = search.text.trim().toLowerCase(); - if (query.isEmpty) return dramas; - return dramas.where((drama) { - final haystack = [ - drama.title, - drama.genre, - drama.category, - drama.synopsis, - ].join(' ').toLowerCase(); - return haystack.contains(query); - }).toList(); + /// 输入变化只触发后端查询;本地不再做标题/简介匹配。 + void onSearchChanged() { + final query = search.text.trim(); + if (query.isEmpty) { + _resetSearch(); + notify(); + return; + } + notify(); + _scheduleSearch(query); + } + + /// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。 + List visibleDramas(List fallback) { + if (!hasSearchQuery) return fallback; + return searchResults; + } + + Future retrySearch() async { + final query = search.text.trim(); + if (query.isEmpty) return; + await _runSearch(query); + } + + void _resetSearch() { + _searchTimer?.cancel(); + _searchRequest++; + searchResults = const []; + searchTotal = 0; + searchLoading = false; + searchError = null; + _lastRequestedQuery = ''; + } + + void _scheduleSearch(String query) { + final alreadyRequested = query == _lastRequestedQuery; + if (alreadyRequested && (searchLoading || searchResults.isNotEmpty || searchError != null)) { + return; + } + _lastRequestedQuery = query; + _searchTimer?.cancel(); + if (searchDebounce == Duration.zero) { + unawaited(_runSearch(query)); + return; + } + _searchTimer = Timer(searchDebounce, () => unawaited(_runSearch(query))); + } + + Future _runSearch(String query) async { + final repository = searchRepository; + if (repository == null) return; + final request = ++_searchRequest; + searchLoading = true; + searchError = null; + notify(); + try { + final result = await repository.search( + query, + pageIndex: 1, + pageSize: searchPageSize, + ); + if (request != _searchRequest) return; + searchResults = result.list; + searchTotal = result.total; + } on ApiException catch (exception) { + _applySearchError(request, exception.message); + } catch (_) { + _applySearchError(request, 'searchFailed'); + } finally { + if (request == _searchRequest) { + searchLoading = false; + notify(); + } + } + } + + void _applySearchError(int request, String message) { + if (request != _searchRequest) return; + searchError = message; + searchResults = const []; + searchTotal = 0; } Drama? resumeDrama(List dramas) { @@ -165,6 +254,7 @@ class DiscoverLogic extends ChangeNotifier { @override void dispose() { _disposed = true; + _searchTimer?.cancel(); search.dispose(); searchFocus.dispose(); scroll.dispose(); diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 059cd19..d33fabc 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -8,8 +8,10 @@ import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/home_feed.dart'; import '../../core/data/models/watch_progress.dart'; +import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/category_controller.dart'; +import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; @@ -30,6 +32,7 @@ class DiscoverPage extends StatefulWidget { required this.duration, this.focusSearch = false, this.history, + this.searchRepository, this.embedded = false, this.active = true, this.searchRequest = 0, @@ -48,6 +51,7 @@ class DiscoverPage extends StatefulWidget { final Duration duration; final bool focusSearch; final WatchHistoryRepository? history; + final SearchRepository? searchRepository; final bool embedded; final bool active; final int searchRequest; @@ -74,6 +78,7 @@ class _DiscoverPageState extends State { logic = DiscoverBinding().create( drama: widget.drama, history: widget.history ?? context.read(), + search: widget.searchRepository ?? context.read(), ); if (widget.focusSearch) { logic.searchOpen = true; @@ -115,7 +120,7 @@ class _DiscoverPageState extends State { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); final allDramas = [...?widget.dramas, ...categories.visibleDramas]; - final visible = logic.filtered(categories.visibleDramas); + final visible = logic.visibleDramas(categories.visibleDramas); final content = SafeArea( bottom: !widget.embedded, child: Column( @@ -235,7 +240,10 @@ class _DiscoverPageState extends State { l10n: l10n, categories: categories, dramas: visible, - searching: logic.search.text.trim().isNotEmpty, + searching: logic.hasSearchQuery, + searchLoading: logic.searchLoading, + searchError: logic.searchError, + onRetrySearch: () => logic.retrySearch(), onOpen: (drama) => logic.openCard( context, drama, @@ -329,6 +337,7 @@ class _DiscoverPageState extends State { controller: logic.search, focusNode: logic.searchFocus, onChanged: (_) => logic.onSearchChanged(), + onTapOutside: (_) => logic.searchFocus.unfocus(), textInputAction: TextInputAction.search, style: const TextStyle(color: AppColors.onSurface, fontSize: 13), @@ -343,6 +352,12 @@ class _DiscoverPageState extends State { color: AppColors.subtle, size: 17, ), + suffixIcon: logic.searchLoading + ? const Padding( + padding: EdgeInsets.all(11), + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(20), diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index d2bd04c..44ec014 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -8,6 +8,9 @@ class _HomeDramaGrid extends StatelessWidget { required this.categories, required this.dramas, required this.searching, + required this.searchLoading, + required this.searchError, + required this.onRetrySearch, required this.onOpen, }); @@ -15,6 +18,9 @@ class _HomeDramaGrid extends StatelessWidget { final CategoryController categories; final List dramas; final bool searching; + final bool searchLoading; + final String? searchError; + final VoidCallback onRetrySearch; final ValueChanged onOpen; @override @@ -37,6 +43,29 @@ class _HomeDramaGrid extends StatelessWidget { ), ); } + if (searching && searchLoading) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: CircularProgressIndicator( + key: Key('discover-search-loading'), + strokeWidth: 2, + ), + ), + ); + } + if (searching && searchError != null) { + return _fill( + EmptyState( + key: const Key('discover-search-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, searchError, l10n.searchFailed), + actionLabel: l10n.retry, + actionKey: const Key('discover-search-retry'), + onAction: onRetrySearch, + ), + ); + } if (dramas.isEmpty) { return _fill( EmptyState( diff --git a/test/catalog/search_api_test.dart b/test/catalog/search_api_test.dart new file mode 100644 index 0000000..7e54257 --- /dev/null +++ b/test/catalog/search_api_test.dart @@ -0,0 +1,51 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/catalog_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test('should_request_search_with_keyword_and_paging_when_searching', () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'total': 1, + 'list': [ + { + 'id': '2111012753262120962', + 'title': 'Velvet Nights', + 'synopsis': 'A city romance', + 'cover': '/files/velvet.png', + 'genre': 'Romance', + 'totalEpisodes': 12, + 'isHot': true, + 'language': 'id', + }, + ], + }, + }), + ), + ); + final api = CatalogApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.search('velvet'); + + final request = adapter.requests.single; + expect(request.uri.path, '/app/search'); + expect(request.queryParameters['keyword'], 'velvet'); + expect(request.queryParameters['pageIndex'], '1'); + expect(request.queryParameters['pageSize'], '20'); + expect(page.total, 1); + expect(page.list.single.title, 'Velvet Nights'); + expect(page.list.single.language, 'id'); + }); +} diff --git a/test/discover/discover_search_test.dart b/test/discover/discover_search_test.dart new file mode 100644 index 0000000..92a45da --- /dev/null +++ b/test/discover/discover_search_test.dart @@ -0,0 +1,116 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/search_result.dart'; +import 'package:fruitshorts/core/data/remote/api_exception.dart'; +import 'package:fruitshorts/core/data/repositories/search_repository.dart'; +import 'package:fruitshorts/pages/discover/discover_logic.dart'; + +import '../support/fake_watch_history.dart'; + +Drama _drama(String id, {String language = 'id'}) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/$id.png', + language: language, + episodes: const [], +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('should_query_backend_and_render_results_when_search_text_changes', () async { + final repository = _FakeSearchRepository(results: [_drama('velvet')]); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: repository, + )..searchDebounce = Duration.zero; + + logic.search.text = 'velvet'; + logic.onSearchChanged(); + await pumpEventQueue(); + + expect(repository.calls, [(keyword: 'velvet', pageIndex: 1, pageSize: 20)]); + expect(logic.searchLoading, isFalse); + expect(logic.visibleDramas(const []).single.id, 'velvet'); + logic.dispose(); + }); + + test('should_keep_server_results_when_text_only_loses_whitespace', () async { + final repository = _FakeSearchRepository(results: [_drama('velvet')]); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: repository, + )..searchDebounce = Duration.zero; + + logic.search.text = 'velvet '; + logic.onSearchChanged(); + await pumpEventQueue(); + logic.search.text = 'velvet'; + logic.onSearchChanged(); + await pumpEventQueue(); + + expect(repository.calls, hasLength(1)); + expect(logic.visibleDramas(const []).single.id, 'velvet'); + logic.dispose(); + }); + + test('should_expose_error_message_when_search_fails', () async { + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: _FakeSearchRepository( + error: const ApiException(400, 'Keyword is invalid'), + ), + )..searchDebounce = Duration.zero; + + logic.search.text = '%%%'; + logic.onSearchChanged(); + await pumpEventQueue(); + + expect(logic.searchError, 'Keyword is invalid'); + expect(logic.searchLoading, isFalse); + logic.dispose(); + }); + + test('should_clear_server_results_when_search_text_is_empty', () async { + final repository = _FakeSearchRepository(results: [_drama('velvet')]); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: repository, + )..searchDebounce = Duration.zero; + + logic.search.text = 'velvet'; + logic.onSearchChanged(); + await pumpEventQueue(); + logic.search.clear(); + logic.onSearchChanged(); + + expect(logic.visibleDramas([_drama('local')]).single.id, 'local'); + logic.dispose(); + }); +} + +class _FakeSearchRepository implements SearchRepository { + _FakeSearchRepository({this.results = const [], this.error}); + + final List results; + final ApiException? error; + final List<({String keyword, int pageIndex, int pageSize})> calls = []; + + @override + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) async { + calls.add((keyword: keyword, pageIndex: pageIndex, pageSize: pageSize)); + if (error != null) throw error!; + return SearchResult(total: results.length, list: results); + } +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index de51edf..e515fc5 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -428,6 +428,7 @@ Future _pumpApp( LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, + searchRepository: catalog, categoryRepository: categoryRepository, watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(), diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 7c41eed..9cb85f4 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -1,9 +1,11 @@ -import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/search_result.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/search_repository.dart'; /// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。 -class FakeCatalogRepository implements CatalogRepository { +class FakeCatalogRepository implements CatalogRepository, SearchRepository { FakeCatalogRepository({List? dramas, this.banners = const []}) : dramas = dramas ?? demoDramas; @@ -21,6 +23,20 @@ class FakeCatalogRepository implements CatalogRepository { return dramas.firstWhere((drama) => drama.id == dramaId); } + @override + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) async { + final query = keyword.trim().toLowerCase(); + final hits = dramas.where((drama) { + final haystack = [drama.title, drama.synopsis, drama.genre].join(' '); + return haystack.toLowerCase().contains(query); + }).toList(); + return SearchResult(total: hits.length, list: hits); + } + HomeFeed _feed() => HomeFeed( banners: banners, sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)], @@ -45,6 +61,7 @@ Drama _drama(String id, String title, String genre, {required int format}) => synopsis: '$title synopsis', posterUrl: '/files/$id.png', genre: genre, + language: 'id', detailLoaded: true, episodes: [ for (var index = 0; index < 7; index++) From 30a5fa077633cff239afc53c3e52826ba8dbc14d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 04:35:48 +0800 Subject: [PATCH 009/124] =?UTF-8?q?feat(app):=20=E5=89=A7=E5=9C=BA?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=A2=9E=E5=8A=A0=E8=AF=AD=E7=A7=8D=E7=AD=9B?= =?UTF-8?q?=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_logic.dart | 29 +++++- lib/pages/discover/discover_view.dart | 70 ++++++++++++++- test/catalog/drama_model_test.dart | 2 + .../discover_language_filter_test.dart | 90 +++++++++++++++++++ test/discover/discover_logic_test.dart | 33 ++++++- 5 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 test/discover/discover_language_filter_test.dart diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index aefb818..ee27518 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -36,6 +36,7 @@ class DiscoverLogic extends ChangeNotifier { Duration searchDebounce = const Duration(milliseconds: 350); static const int searchPageSize = 20; List searchResults = const []; + String? selectedLanguage; int searchTotal = 0; bool searchLoading = false; String? searchError; @@ -87,8 +88,32 @@ class DiscoverLogic extends ChangeNotifier { /// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。 List visibleDramas(List fallback) { - if (!hasSearchQuery) return fallback; - return searchResults; + if (!hasSearchQuery) return _filterByLanguage(fallback); + return _filterByLanguage(searchResults); + } + + /// 当前 feed 中出现过的语种,按首次出现顺序去重。 + List languagesOf(List dramas) { + final languages = []; + for (final drama in dramas) { + final language = drama.language; + if (language.isNotEmpty && !languages.contains(language)) { + languages.add(language); + } + } + return languages; + } + + /// 按语种筛选;null 表示全部。 + void selectLanguage(String? code) { + selectedLanguage = code; + notify(); + } + + List _filterByLanguage(List dramas) { + final code = selectedLanguage; + if (code == null) return dramas; + return dramas.where((drama) => drama.language == code).toList(); } Future retrySearch() async { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index d33fabc..7d8e370 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -176,7 +176,12 @@ class _DiscoverPageState extends State { child: _categories(l10n, categories), ), ), - if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 12), + child: _languages(l10n, allDramas), + ), + ), if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), @@ -427,6 +432,69 @@ class _DiscoverPageState extends State { ); } + Widget _languages(AppLocalizations l10n, List dramas) { + final languages = logic.languagesOf(dramas); + if (languages.isEmpty) return const SizedBox.shrink(); + final selected = logic.selectedLanguage; + return SizedBox( + key: const Key('discover-language-chips'), + height: AppDimens.tapTarget, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 14), + scrollDirection: Axis.horizontal, + children: [ + _languageChip( + key: const Key('discover-language-all'), + label: l10n.all, + selected: selected == null, + onTap: () => logic.selectLanguage(null), + ), + for (final language in languages) + _languageChip( + key: Key('discover-language-$language'), + label: _languageLabel(l10n, language), + selected: selected == language, + onTap: () => logic.selectLanguage(language), + ), + ], + ), + ); + } + + Widget _languageChip({ + required Key key, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Center( + child: ChoiceChip( + key: key, + label: Text(label), + selected: selected, + showCheckmark: false, + onSelected: (_) => onTap(), + selectedColor: AppColors.blue, + backgroundColor: const Color(0xFF29272B), + side: BorderSide.none, + labelStyle: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: AppDimens.chipFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ); + } + + String _languageLabel(AppLocalizations l10n, String code) { + if (code == 'zh-CN' || code == 'zh') return l10n.languageChinese; + if (code == 'id') return l10n.languageIndonesian; + if (code == 'en') return l10n.languageEnglish; + return code; + } Widget _continueCard( AppLocalizations l10n, Drama drama, diff --git a/test/catalog/drama_model_test.dart b/test/catalog/drama_model_test.dart index 7b715cb..bb99b81 100644 --- a/test/catalog/drama_model_test.dart +++ b/test/catalog/drama_model_test.dart @@ -13,6 +13,7 @@ void main() { 'totalEpisodes': 77, 'isHot': true, 'viewCount': 3100000, + 'language': 'zh-CN', 'preview': { 'id': 9, 'number': 1, @@ -27,6 +28,7 @@ void main() { expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4'); expect(drama.episodeCount, 77); expect(drama.isHot, isTrue); + expect(drama.language, 'zh-CN'); }); test('should_have_no_episodes_when_list_card_has_no_playable_preview', () { diff --git a/test/discover/discover_language_filter_test.dart b/test/discover/discover_language_filter_test.dart new file mode 100644 index 0000000..db69362 --- /dev/null +++ b/test/discover/discover_language_filter_test.dart @@ -0,0 +1,90 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/category_repository.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_filter_theater_grid_when_language_chip_is_tapped', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: FakeCatalogRepository(), + categoryRepository: _MixedLanguageCategoryRepository(), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget); + expect(find.byKey(const Key('home-drama-card-id-demo')), findsOneWidget); + + await tester.tap(find.byKey(const Key('discover-language-zh-CN'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget); + expect(find.byKey(const Key('home-drama-card-id-demo')), findsNothing); + }); +} + +class _MixedLanguageCategoryRepository implements CategoryRepository { + @override + Future> fetchCategories() async => const []; + + @override + Future fetchDiscoverFeed() async => HomeFeed( + sections: [ + FeedSection( + code: 'hot', + title: 'Hot', + dramas: [ + _drama('zh-demo', 'Chinese Demo', 'zh-CN'), + _drama('id-demo', 'Indonesian Demo', 'id'), + ], + ), + ], + ); +} + +Drama _drama(String id, String title, String language) => Drama( + id: id, + title: title, + synopsis: '$title synopsis', + posterUrl: '/files/$id.png', + language: language, + detailLoaded: true, +); diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart index 55fc790..507dfe6 100644 --- a/test/discover/discover_logic_test.dart +++ b/test/discover/discover_logic_test.dart @@ -8,7 +8,8 @@ import 'package:fruitshorts/pages/discover/discover_logic.dart'; import '../support/fake_watch_history.dart'; -Drama _drama(String id, {List numbers = const [1, 2, 3]}) => Drama( +Drama _drama(String id, {List numbers = const [1, 2, 3], String language = ''}) => Drama( + language: language, id: id, title: id, synopsis: 'synopsis', @@ -64,6 +65,36 @@ void main() { logic.dispose(); }); + test('should_filter_visible_dramas_when_language_is_selected', () { + final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final dramas = [ + _drama('zh', language: 'zh-CN'), + _drama('id', language: 'id'), + _drama('unknown'), + ]; + + expect(logic.visibleDramas(dramas), hasLength(3)); + + logic.selectLanguage('id'); + expect(logic.visibleDramas(dramas).map((item) => item.id), ['id']); + + logic.selectLanguage(null); + expect(logic.visibleDramas(dramas), hasLength(3)); + logic.dispose(); + }); + + test('should_list_distinct_languages_when_feed_has_cards', () { + final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final dramas = [ + _drama('zh', language: 'zh-CN'), + _drama('id', language: 'id'), + _drama('id2', language: 'id'), + _drama('unknown'), + ]; + + expect(logic.languagesOf(dramas), ['zh-CN', 'id']); + logic.dispose(); + }); test('should_have_no_resume_position_when_server_has_no_record', () async { final history = FakeWatchHistory(); final logic = DiscoverLogic( From 2b2b7e991396aca5c507784c294577c38266e0d3 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 04:37:42 +0800 Subject: [PATCH 010/124] =?UTF-8?q?feat(app):=20=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=99=A8=E5=A2=9E=E5=8A=A0=E6=B8=85=E6=99=B0=E5=BA=A6=E6=A1=A3?= =?UTF-8?q?=E4=BD=8D=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/models/play_ticket.dart | 8 ++ .../repositories/playback_repository.dart | 1 + lib/pages/player/player_logic.dart | 3 + lib/pages/player/player_view.dart | 40 +++++++++- lib/pages/player/widgets/quality_sheet.dart | 79 +++++++++++++++++++ test/playback/play_ticket_quality_test.dart | 29 +++++++ test/playback/player_quality_test.dart | 44 +++++++++++ test/player/player_fullscreen_test.dart | 7 ++ test/player/quality_sheet_test.dart | 32 ++++++++ 9 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 lib/pages/player/widgets/quality_sheet.dart create mode 100644 test/playback/play_ticket_quality_test.dart create mode 100644 test/playback/player_quality_test.dart create mode 100644 test/player/quality_sheet_test.dart diff --git a/lib/core/data/models/play_ticket.dart b/lib/core/data/models/play_ticket.dart index bb4a899..a585079 100644 --- a/lib/core/data/models/play_ticket.dart +++ b/lib/core/data/models/play_ticket.dart @@ -13,6 +13,7 @@ class PlayTicket { this.durationSec = 0, this.unlockCoin = 0, this.previewSeconds = 0, + this.qualities = const [], }); static const String free = 'FREE'; @@ -35,6 +36,9 @@ class PlayTicket { /// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。 final int previewSeconds; + /// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。 + final List qualities; + bool get needsUnlock => accessType == coinRequired || accessType == vipRequired; @@ -50,6 +54,10 @@ class PlayTicket { durationSec: jsonToInt(json['durationSec']), unlockCoin: jsonToInt(json['unlockCoin']), previewSeconds: jsonToInt(json['previewSeconds']), + qualities: (json['qualities'] as List? ?? const []) + .whereType() + .where((quality) => quality.isNotEmpty) + .toList(), ); } } diff --git a/lib/core/data/repositories/playback_repository.dart b/lib/core/data/repositories/playback_repository.dart index 24bc443..fda310e 100644 --- a/lib/core/data/repositories/playback_repository.dart +++ b/lib/core/data/repositories/playback_repository.dart @@ -91,6 +91,7 @@ class FakePlaybackRepository implements PlaybackRepository { accessible: true, accessType: PlayTicket.free, playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc', + qualities: const ['AUTO', '720P', '1080P'], ); } } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 910aff0..e173466 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -72,6 +72,7 @@ class PlayerLogic extends ChangeNotifier { PlayTicket? blockedTicket; String? blockedEpisodeId; List sessionEpisodes = const []; + List qualities = const []; int pageRequest = 0; bool muted = false; double playbackSpeed = 1.0; @@ -217,6 +218,7 @@ class PlayerLogic extends ChangeNotifier { if (localPath != null) { blockedTicket = null; blockedEpisodeId = null; + qualities = const []; sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath)); return true; } @@ -227,6 +229,7 @@ class PlayerLogic extends ChangeNotifier { if (request != pageRequest) return false; blockedTicket = null; blockedEpisodeId = null; + qualities = ticket.qualities; if (ticket.accessible && ticket.playUrl.isNotEmpty) { // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 0528611..51bacbf 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -28,6 +28,7 @@ import 'player_logic.dart'; import 'widgets/home_feed_overlay.dart'; import 'widgets/exit_clear_screen_button.dart'; import 'widgets/player_center_toggle.dart'; +import 'widgets/quality_sheet.dart'; import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; @@ -402,6 +403,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, onSpeedTap: () => unawaited(_openSpeedSheet(context)), + qualities: logic.qualities, + onQualityTap: () => unawaited(_openQualitySheet(context)), controlsVisible: _chromeVisible, onClear: () => setState(() => _chromeVisible = false), onExitClear: () => setState(() => _chromeVisible = true), @@ -500,6 +503,15 @@ class _PlayerPageState extends State with WidgetsBindingObserver { await logic.setPlaybackSpeed(selected); } + Future _openQualitySheet(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (_) => QualitySheet(qualities: logic.qualities), + ); + } + Future _unlockEpisode(BuildContext context, int index) async { if (!await ensureSignedIn(context)) return; if (!context.mounted) return; @@ -693,6 +705,8 @@ class _PlaybackOverlay extends StatefulWidget { required this.playing, required this.playbackSpeed, required this.onSpeedTap, + required this.qualities, + required this.onQualityTap, required this.controlsVisible, required this.onTogglePlay, required this.onClear, @@ -720,6 +734,8 @@ class _PlaybackOverlay extends StatefulWidget { final bool playing; final double playbackSpeed; final VoidCallback onSpeedTap; + final List qualities; + final VoidCallback onQualityTap; final bool controlsVisible; final VoidCallback onTogglePlay; final VoidCallback onClear; @@ -953,7 +969,29 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { }); }, ), - ListTile( + if (widget.qualities.isNotEmpty) + ListTile( + key: const Key('player-more-quality'), + leading: const Icon( + Icons.high_quality_rounded, + color: AppColors.secondary, + ), + title: Text(widget.l10n.playbackQuality), + trailing: Text( + widget.qualities.contains(QualitySheet.autoQuality) + ? widget.l10n.qualityAuto + : widget.qualities.first, + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + widget.onQualityTap(); + }, + ), ListTile( key: const Key('player-more-download'), leading: const Icon( Icons.download_outlined, diff --git a/lib/pages/player/widgets/quality_sheet.dart b/lib/pages/player/widgets/quality_sheet.dart new file mode 100644 index 0000000..5f3d69c --- /dev/null +++ b/lib/pages/player/widgets/quality_sheet.dart @@ -0,0 +1,79 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 清晰度档位面板:展示后端下发档位;HLS 多码率由播放器自动切换。 +class QualitySheet extends StatelessWidget { + const QualitySheet({ + super.key, + required this.qualities, + this.selected = autoQuality, + }); + + static const String autoQuality = 'AUTO'; + + final List qualities; + final String selected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SafeArea( + child: Column( + key: const Key('player-quality-sheet'), + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.playbackQuality, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + for (final quality in qualities) + ListTile( + key: Key('player-quality-$quality'), + dense: true, + title: Text( + _label(l10n, quality), + style: TextStyle( + color: quality == selected ? AppColors.blue : AppColors.cream, + fontSize: 13, + fontWeight: quality == selected + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + trailing: quality == selected + ? const Icon( + Icons.check_rounded, + color: AppColors.blue, + size: 18, + ) + : null, + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 14), + child: Text( + l10n.qualityAutoHint, + style: const TextStyle(color: AppColors.muted, fontSize: 11), + ), + ), + ], + ), + ); + } + + String _label(AppLocalizations l10n, String quality) => + quality == autoQuality ? l10n.qualityAuto : quality; +} diff --git a/test/playback/play_ticket_quality_test.dart b/test/playback/play_ticket_quality_test.dart new file mode 100644 index 0000000..00a55c7 --- /dev/null +++ b/test/playback/play_ticket_quality_test.dart @@ -0,0 +1,29 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; + +void main() { + test('should_parse_all_qualities_when_ticket_returns_multiple_tracks', () { + final ticket = PlayTicket.fromJson(const { + 'episodeId': '12', + 'accessible': true, + 'accessType': 'FREE', + 'playUrl': '/files/episode/12.m3u8?e=1&s=abc', + 'qualities': ['AUTO', '720P', '1080P'], + }); + + expect(ticket.qualities, ['AUTO', '720P', '1080P']); + }); + + test('should_default_to_empty_qualities_when_field_is_missing', () { + final ticket = PlayTicket.fromJson(const { + 'episodeId': '12', + 'accessible': true, + 'accessType': 'FREE', + 'playUrl': '/files/episode/12.mp4', + }); + + expect(ticket.qualities, isEmpty); + }); +} diff --git a/test/playback/player_quality_test.dart b/test/playback/player_quality_test.dart new file mode 100644 index 0000000..41e9df8 --- /dev/null +++ b/test/playback/player_quality_test.dart @@ -0,0 +1,44 @@ +// Author: fruit.com.cn + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; +import 'package:fruitshorts/pages/player/player_logic.dart'; + +import '../support/fake_watch_history.dart'; + +void main() { + test('should_expose_ticket_qualities_when_ticket_is_loaded', () async { + final logic = PlayerLogic( + history: FakeWatchHistory(), + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: '12', + accessible: true, + accessType: PlayTicket.free, + playUrl: '/files/episode/12.m3u8?e=1&s=abc', + qualities: ['AUTO', '720P', '1080P'], + ), + ), + pictureInPicture: AndroidPictureInPicture( + channel: const MethodChannel('test/player_quality/pip'), + ), + ); + logic.sessionEpisodes = [ + Episode( + id: '12', + number: 1, + title: 'Episode 1', + isFree: true, + ), + ]; + + final ready = await logic.ensureTicket(0); + + expect(ready, isTrue); + expect(logic.qualities, ['AUTO', '720P', '1080P']); + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 45fc167..b5ecb15 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -38,8 +38,15 @@ void main() { expect(find.byType(PageView), findsOneWidget); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-more-quality')), findsOneWidget); expect(find.byKey(const Key('player-more-download')), findsOneWidget); expect(find.byKey(const Key('player-more-pip')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more-quality'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-quality-sheet')), findsOneWidget); + expect(find.text('720P'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); diff --git a/test/player/quality_sheet_test.dart b/test/player/quality_sheet_test.dart new file mode 100644 index 0000000..d9c8024 --- /dev/null +++ b/test/player/quality_sheet_test.dart @@ -0,0 +1,32 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/quality_sheet.dart'; + +void main() { + testWidgets('should_list_every_returned_quality_when_sheet_opens', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: QualitySheet(qualities: ['AUTO', '720P', '1080P']), + ), + ), + ); + + expect(find.text('Auto'), findsOneWidget); + expect(find.text('720P'), findsOneWidget); + expect(find.text('1080P'), findsOneWidget); + expect(find.byIcon(Icons.check_rounded), findsOneWidget); + expect( + find.text('HLS adjusts the bitrate automatically for your connection.'), + findsOneWidget, + ); + }); +} From 7850dfc74603f86a809bc640b113bb01a30547d8 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 04:38:36 +0800 Subject: [PATCH 011/124] =?UTF-8?q?style(app):=20=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=8C=96=E5=B9=B6=E6=8B=86=E5=88=86=E9=95=BF=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 3 +- lib/core/data/models/order.dart | 2 +- lib/core/data/models/product.dart | 2 +- lib/core/data/models/search_result.dart | 2 +- lib/core/data/remote/commerce_api.dart | 2 +- .../repositories/playback_repository.dart | 3 +- .../data/repositories/search_repository.dart | 2 +- .../data/repositories/store_repository.dart | 2 +- lib/core/data/services/store_controller.dart | 2 +- lib/pages/discover/discover_binding.dart | 8 +- lib/pages/discover/discover_logic.dart | 3 +- lib/pages/discover/discover_view.dart | 4 +- .../discover/widgets/home_drama_grid.dart | 100 ++++++----- lib/pages/player/player_logic.dart | 5 +- lib/pages/player/player_view.dart | 3 +- lib/pages/player/widgets/quality_sheet.dart | 94 +++++----- lib/pages/store/recharge_page.dart | 2 +- lib/pages/store/store_page.dart | 147 ++++++++-------- lib/pages/store/vip_page.dart | 2 +- lib/pages/wallet/wallet_page.dart | 9 +- test/catalog/search_api_test.dart | 79 +++++---- test/commerce/store_api_test.dart | 2 +- test/commerce/store_controller_test.dart | 8 +- .../discover_language_filter_test.dart | 2 +- test/discover/discover_logic_test.dart | 166 +++++++++++------- test/discover/discover_search_test.dart | 37 ++-- test/playback/play_ticket_quality_test.dart | 2 +- test/playback/player_quality_test.dart | 7 +- test/player/quality_sheet_test.dart | 2 +- test/store/store_pages_test.dart | 2 +- test/support/fake_catalog.dart | 2 +- 31 files changed, 388 insertions(+), 318 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 8ddc4ee..84f1e7f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -146,7 +146,8 @@ class _LumaDramaAppState extends State { ); late final CommerceRepository _commerceRepository = - widget.commerceRepository ?? HttpCommerceRepository(CommerceApi(_apiClient)); + widget.commerceRepository ?? + HttpCommerceRepository(CommerceApi(_apiClient)); late final WalletController _walletController = WalletController( _commerceRepository, diff --git a/lib/core/data/models/order.dart b/lib/core/data/models/order.dart index 27c7501..39a9dcf 100644 --- a/lib/core/data/models/order.dart +++ b/lib/core/data/models/order.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'json_numbers.dart'; diff --git a/lib/core/data/models/product.dart b/lib/core/data/models/product.dart index e9072a9..5f277e6 100644 --- a/lib/core/data/models/product.dart +++ b/lib/core/data/models/product.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'json_numbers.dart'; diff --git a/lib/core/data/models/search_result.dart b/lib/core/data/models/search_result.dart index f33781d..9e7b852 100644 --- a/lib/core/data/models/search_result.dart +++ b/lib/core/data/models/search_result.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'drama.dart'; import 'json_numbers.dart'; diff --git a/lib/core/data/remote/commerce_api.dart b/lib/core/data/remote/commerce_api.dart index 7364130..5728a69 100644 --- a/lib/core/data/remote/commerce_api.dart +++ b/lib/core/data/remote/commerce_api.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import '../models/order.dart'; import '../models/product.dart'; diff --git a/lib/core/data/repositories/playback_repository.dart b/lib/core/data/repositories/playback_repository.dart index fda310e..d68065a 100644 --- a/lib/core/data/repositories/playback_repository.dart +++ b/lib/core/data/repositories/playback_repository.dart @@ -59,7 +59,8 @@ class _CachedTicket { final PlayTicket ticket; final DateTime fetchedAt; - bool isExpired(DateTime now, Duration ttl) => now.difference(fetchedAt) >= ttl; + bool isExpired(DateTime now, Duration ttl) => + now.difference(fetchedAt) >= ttl; } /// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。 diff --git a/lib/core/data/repositories/search_repository.dart b/lib/core/data/repositories/search_repository.dart index 9380011..c59816e 100644 --- a/lib/core/data/repositories/search_repository.dart +++ b/lib/core/data/repositories/search_repository.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import '../models/search_result.dart'; import '../remote/catalog_api.dart'; diff --git a/lib/core/data/repositories/store_repository.dart b/lib/core/data/repositories/store_repository.dart index d2d2e77..71cb76b 100644 --- a/lib/core/data/repositories/store_repository.dart +++ b/lib/core/data/repositories/store_repository.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import '../models/order.dart'; import '../models/product.dart'; diff --git a/lib/core/data/services/store_controller.dart b/lib/core/data/services/store_controller.dart index 5e8adb2..e941c13 100644 --- a/lib/core/data/services/store_controller.dart +++ b/lib/core/data/services/store_controller.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'package:flutter/foundation.dart'; diff --git a/lib/pages/discover/discover_binding.dart b/lib/pages/discover/discover_binding.dart index c85dbbe..0f62d70 100644 --- a/lib/pages/discover/discover_binding.dart +++ b/lib/pages/discover/discover_binding.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import '../../core/data/models/drama.dart'; import '../../core/data/repositories/search_repository.dart'; @@ -10,9 +10,5 @@ class DiscoverBinding { required Drama drama, required WatchHistoryRepository history, SearchRepository? search, - }) => DiscoverLogic( - drama: drama, - history: history, - searchRepository: search, - ); + }) => DiscoverLogic(drama: drama, history: history, searchRepository: search); } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index ee27518..55e5972 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -134,7 +134,8 @@ class DiscoverLogic extends ChangeNotifier { void _scheduleSearch(String query) { final alreadyRequested = query == _lastRequestedQuery; - if (alreadyRequested && (searchLoading || searchResults.isNotEmpty || searchError != null)) { + if (alreadyRequested && + (searchLoading || searchResults.isNotEmpty || searchError != null)) { return; } _lastRequestedQuery = query; diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 7d8e370..35f0198 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -181,7 +181,8 @@ class _DiscoverPageState extends State { padding: const EdgeInsets.fromLTRB(0, 0, 0, 12), child: _languages(l10n, allDramas), ), - ), if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) + ), + if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), @@ -495,6 +496,7 @@ class _DiscoverPageState extends State { if (code == 'en') return l10n.languageEnglish; return code; } + Widget _continueCard( AppLocalizations l10n, Drama drama, diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index 44ec014..f1ca78a 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -25,24 +25,40 @@ class _HomeDramaGrid extends StatelessWidget { @override Widget build(BuildContext context) { + final loading = _loadingState(); + if (loading != null) return loading; + final search = _searchState(); + if (search != null) return search; + if (dramas.isEmpty) return _emptyState(); + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter(child: _masonry(dramas)), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + } + + Widget? _loadingState() { if (categories.loading && categories.feed.sections.isEmpty) { return const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } - if (categories.error != null) { - return _fill( - EmptyState( - key: const Key('home-categories-error'), - icon: Icons.cloud_off_rounded, - title: l10n.categoriesError, - subtitle: l10n.emptyHint, - actionLabel: l10n.retry, - onAction: categories.load, - ), - ); - } + if (categories.error == null) return null; + return _fill( + EmptyState( + key: const Key('home-categories-error'), + icon: Icons.cloud_off_rounded, + title: l10n.categoriesError, + subtitle: l10n.emptyHint, + actionLabel: l10n.retry, + onAction: categories.load, + ), + ); + } + + Widget? _searchState() { if (searching && searchLoading) { return const SliverFillRemaining( hasScrollBody: false, @@ -54,39 +70,33 @@ class _HomeDramaGrid extends StatelessWidget { ), ); } - if (searching && searchError != null) { - return _fill( - EmptyState( - key: const Key('discover-search-error'), - icon: Icons.cloud_off_rounded, - title: errorText(l10n, searchError, l10n.searchFailed), - actionLabel: l10n.retry, - actionKey: const Key('discover-search-retry'), - onAction: onRetrySearch, - ), - ); - } - if (dramas.isEmpty) { - return _fill( - EmptyState( - key: const Key('home-all-dramas-empty'), - icon: searching - ? Icons.search_off_rounded - : Icons.movie_filter_outlined, - title: searching - ? l10n.noSearchResults - : categories.selectedSectionMissing - ? l10n.noCategoryDramas - : l10n.noSearchResults, - subtitle: l10n.emptyHint, - ), - ); - } - return SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter(child: _masonry(dramas)), - const SliverToBoxAdapter(child: SizedBox(height: 24)), - ], + if (!searching || searchError == null) return null; + return _fill( + EmptyState( + key: const Key('discover-search-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, searchError, l10n.searchFailed), + actionLabel: l10n.retry, + actionKey: const Key('discover-search-retry'), + onAction: onRetrySearch, + ), + ); + } + + Widget _emptyState() { + return _fill( + EmptyState( + key: const Key('home-all-dramas-empty'), + icon: searching + ? Icons.search_off_rounded + : Icons.movie_filter_outlined, + title: searching + ? l10n.noSearchResults + : categories.selectedSectionMissing + ? l10n.noCategoryDramas + : l10n.noSearchResults, + subtitle: l10n.emptyHint, + ), ); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index e173466..f7f8ed9 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -283,10 +283,7 @@ class PlayerLogic extends ChangeNotifier { } final ready = await ensureTicket(index, forceRefresh: true); if (ready) { - await session.select( - index, - preloadIndex: nextVisibleIndex(index), - ); + await session.select(index, preloadIndex: nextVisibleIndex(index)); } return ready; } finally { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 51bacbf..986f88c 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -991,7 +991,8 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { Navigator.pop(sheetContext); widget.onQualityTap(); }, - ), ListTile( + ), + ListTile( key: const Key('player-more-download'), leading: const Icon( Icons.download_outlined, diff --git a/lib/pages/player/widgets/quality_sheet.dart b/lib/pages/player/widgets/quality_sheet.dart index 5f3d69c..fcb6c50 100644 --- a/lib/pages/player/widgets/quality_sheet.dart +++ b/lib/pages/player/widgets/quality_sheet.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'package:flutter/material.dart'; @@ -26,54 +26,60 @@ class QualitySheet extends StatelessWidget { key: const Key('player-quality-sheet'), mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - l10n.playbackQuality, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - for (final quality in qualities) - ListTile( - key: Key('player-quality-$quality'), - dense: true, - title: Text( - _label(l10n, quality), - style: TextStyle( - color: quality == selected ? AppColors.blue : AppColors.cream, - fontSize: 13, - fontWeight: quality == selected - ? FontWeight.w700 - : FontWeight.w500, - ), - ), - trailing: quality == selected - ? const Icon( - Icons.check_rounded, - color: AppColors.blue, - size: 18, - ) - : null, - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 6, 20, 14), - child: Text( - l10n.qualityAutoHint, - style: const TextStyle(color: AppColors.muted, fontSize: 11), - ), - ), + _header(l10n), + for (final quality in qualities) _qualityTile(l10n, quality), + _hint(l10n), ], ), ); } + Widget _header(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.playbackQuality, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + Widget _qualityTile(AppLocalizations l10n, String quality) { + final active = quality == selected; + return ListTile( + key: Key('player-quality-$quality'), + dense: true, + title: Text( + _label(l10n, quality), + style: TextStyle( + color: active ? AppColors.blue : AppColors.cream, + fontSize: 13, + fontWeight: active ? FontWeight.w700 : FontWeight.w500, + ), + ), + trailing: active + ? const Icon(Icons.check_rounded, color: AppColors.blue, size: 18) + : null, + ); + } + + Widget _hint(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 14), + child: Text( + l10n.qualityAutoHint, + style: const TextStyle(color: AppColors.muted, fontSize: 11), + ), + ); + } + String _label(AppLocalizations l10n, String quality) => quality == autoQuality ? l10n.qualityAuto : quality; } diff --git a/lib/pages/store/recharge_page.dart b/lib/pages/store/recharge_page.dart index 007d971..50645d7 100644 --- a/lib/pages/store/recharge_page.dart +++ b/lib/pages/store/recharge_page.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'package:flutter/material.dart'; diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart index cffb358..e5501bd 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'dart:async'; @@ -122,7 +122,9 @@ class _StorePageState extends State { controller.purchaseError, l10n.orderCreateFailed, ); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); return; } widget.onPurchased?.call(); @@ -162,80 +164,85 @@ class _ProductTile extends StatelessWidget { children: [ _ProductBadge(vip: product.isVip), const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - product.name, - key: Key('store-product-name-${product.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 4), - Text( - _detail(l10n), - style: const TextStyle( - color: AppColors.muted, - fontSize: 11, - ), - ), - ], - ), - ), + Expanded(child: _info(l10n)), const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - product.displayPrice, - key: Key('store-product-price-${product.id}'), - style: const TextStyle( - color: AppColors.gold, - fontSize: 13, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 8), - SizedBox( - width: 88, - height: 32, - child: FilledButton( - key: Key('store-buy-${product.id}'), - onPressed: purchasing ? null : onBuy, - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - padding: EdgeInsets.zero, - textStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - child: purchasing - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.onPrimary, - ), - ) - : Text(l10n.buy), - ), - ), - ], - ), + _price(l10n), ], ), ); } + Widget _info(AppLocalizations l10n) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + key: Key('store-product-name-${product.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + _detail(l10n), + style: const TextStyle(color: AppColors.muted, fontSize: 11), + ), + ], + ); + } + + Widget _price(AppLocalizations l10n) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + product.displayPrice, + key: Key('store-product-price-${product.id}'), + style: const TextStyle( + color: AppColors.gold, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: 88, + height: 32, + child: FilledButton( + key: Key('store-buy-${product.id}'), + onPressed: purchasing ? null : onBuy, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: purchasing ? _buyProgress() : Text(l10n.buy), + ), + ), + ], + ); + } + + Widget _buyProgress() { + return const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.onPrimary, + ), + ); + } + String _detail(AppLocalizations l10n) { if (product.isVip) return l10n.vipDuration(product.vipDays); final base = l10n.walletBalanceShort(product.coinAmount); diff --git a/lib/pages/store/vip_page.dart b/lib/pages/store/vip_page.dart index f6430b9..4df4e10 100644 --- a/lib/pages/store/vip_page.dart +++ b/lib/pages/store/vip_page.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'package:flutter/material.dart'; diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart index bc2f055..b9361de 100644 --- a/lib/pages/wallet/wallet_page.dart +++ b/lib/pages/wallet/wallet_page.dart @@ -90,9 +90,8 @@ class _WalletPageState extends State { void _openRecharge(WalletController wallet) { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => RechargePage( - onPurchased: () => unawaited(wallet.load()), - ), + builder: (_) => + RechargePage(onPurchased: () => unawaited(wallet.load())), ), ); } @@ -320,7 +319,9 @@ class _LedgerTile extends StatelessWidget { Widget build(BuildContext context) { final positive = entry.changeAmount >= 0; final color = positive ? AppColors.success : AppColors.accent; - final amount = positive ? '+${entry.changeAmount}' : '${entry.changeAmount}'; + final amount = positive + ? '+${entry.changeAmount}' + : '${entry.changeAmount}'; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( diff --git a/test/catalog/search_api_test.dart b/test/catalog/search_api_test.dart index 7e54257..e9ff252 100644 --- a/test/catalog/search_api_test.dart +++ b/test/catalog/search_api_test.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'dart:convert'; @@ -9,43 +9,46 @@ import 'package:fruitshorts/core/data/remote/catalog_api.dart'; import '../support/fake_http_adapter.dart'; void main() { - test('should_request_search_with_keyword_and_paging_when_searching', () async { - final adapter = FakeHttpAdapter( - (options) async => FakeHttpResponse( - jsonEncode({ - 'code': 0, - 'message': 'ok', - 'data': { - 'total': 1, - 'list': [ - { - 'id': '2111012753262120962', - 'title': 'Velvet Nights', - 'synopsis': 'A city romance', - 'cover': '/files/velvet.png', - 'genre': 'Romance', - 'totalEpisodes': 12, - 'isHot': true, - 'language': 'id', - }, - ], - }, - }), - ), - ); - final api = CatalogApi( - ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), - ); + test( + 'should_request_search_with_keyword_and_paging_when_searching', + () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'total': 1, + 'list': [ + { + 'id': '2111012753262120962', + 'title': 'Velvet Nights', + 'synopsis': 'A city romance', + 'cover': '/files/velvet.png', + 'genre': 'Romance', + 'totalEpisodes': 12, + 'isHot': true, + 'language': 'id', + }, + ], + }, + }), + ), + ); + final api = CatalogApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); - final page = await api.search('velvet'); + final page = await api.search('velvet'); - final request = adapter.requests.single; - expect(request.uri.path, '/app/search'); - expect(request.queryParameters['keyword'], 'velvet'); - expect(request.queryParameters['pageIndex'], '1'); - expect(request.queryParameters['pageSize'], '20'); - expect(page.total, 1); - expect(page.list.single.title, 'Velvet Nights'); - expect(page.list.single.language, 'id'); - }); + final request = adapter.requests.single; + expect(request.uri.path, '/app/search'); + expect(request.queryParameters['keyword'], 'velvet'); + expect(request.queryParameters['pageIndex'], '1'); + expect(request.queryParameters['pageSize'], '20'); + expect(page.total, 1); + expect(page.list.single.title, 'Velvet Nights'); + expect(page.list.single.language, 'id'); + }, + ); } diff --git a/test/commerce/store_api_test.dart b/test/commerce/store_api_test.dart index 89b18fb..7e623c6 100644 --- a/test/commerce/store_api_test.dart +++ b/test/commerce/store_api_test.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'dart:convert'; diff --git a/test/commerce/store_controller_test.dart b/test/commerce/store_controller_test.dart index d1b83df..726fb9d 100644 --- a/test/commerce/store_controller_test.dart +++ b/test/commerce/store_controller_test.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/order.dart'; @@ -81,11 +81,7 @@ AppProduct _product(String name, {int coinAmount = 550, int bonusCoin = 0}) => ); class _FakeStoreRepository implements StoreRepository { - _FakeStoreRepository({ - this.products = const [], - this.order, - this.orderError, - }); + _FakeStoreRepository({this.products = const [], this.order, this.orderError}); final List products; final AppOrder? order; diff --git a/test/discover/discover_language_filter_test.dart b/test/discover/discover_language_filter_test.dart index db69362..003d3db 100644 --- a/test/discover/discover_language_filter_test.dart +++ b/test/discover/discover_language_filter_test.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart index 507dfe6..08c02fa 100644 --- a/test/discover/discover_logic_test.dart +++ b/test/discover/discover_logic_test.dart @@ -8,7 +8,11 @@ import 'package:fruitshorts/pages/discover/discover_logic.dart'; import '../support/fake_watch_history.dart'; -Drama _drama(String id, {List numbers = const [1, 2, 3], String language = ''}) => Drama( +Drama _drama( + String id, { + List numbers = const [1, 2, 3], + String language = '', +}) => Drama( language: language, id: id, title: id, @@ -29,15 +33,18 @@ Drama _drama(String id, {List numbers = const [1, 2, 3], String language = void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('should_resolve_resume_position_of_episode_one_when_constructed', () async { - final history = FakeWatchHistory()..saved = const Duration(seconds: 4); - final logic = DiscoverLogic(history: history, drama: _drama('signal')); + test( + 'should_resolve_resume_position_of_episode_one_when_constructed', + () async { + final history = FakeWatchHistory()..saved = const Duration(seconds: 4); + final logic = DiscoverLogic(history: history, drama: _drama('signal')); - expect(await logic.position, const Duration(seconds: 4)); - expect(history.reads, ['signal']); - expect(logic.searchOpen, isFalse); - logic.dispose(); - }); + expect(await logic.position, const Duration(seconds: 4)); + expect(history.reads, ['signal']); + expect(logic.searchOpen, isFalse); + logic.dispose(); + }, + ); test('should_reread_resume_position_when_drama_is_swapped', () async { final history = FakeWatchHistory(); @@ -51,22 +58,28 @@ void main() { logic.dispose(); }); - test('should_resume_from_last_watch_when_list_card_has_no_episodes', () async { - final history = FakeWatchHistory() - ..saved = const Duration(seconds: 9) - ..lastEpisode = 'signal-2'; - final logic = DiscoverLogic( - history: history, - drama: _drama('signal', numbers: const []), - ); + test( + 'should_resume_from_last_watch_when_list_card_has_no_episodes', + () async { + final history = FakeWatchHistory() + ..saved = const Duration(seconds: 9) + ..lastEpisode = 'signal-2'; + final logic = DiscoverLogic( + history: history, + drama: _drama('signal', numbers: const []), + ); - expect(await logic.position, const Duration(seconds: 9)); - expect(history.reads, ['signal']); - logic.dispose(); - }); + expect(await logic.position, const Duration(seconds: 9)); + expect(history.reads, ['signal']); + logic.dispose(); + }, + ); test('should_filter_visible_dramas_when_language_is_selected', () { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); final dramas = [ _drama('zh', language: 'zh-CN'), _drama('id', language: 'id'), @@ -84,7 +97,10 @@ void main() { }); test('should_list_distinct_languages_when_feed_has_cards', () { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); final dramas = [ _drama('zh', language: 'zh-CN'), _drama('id', language: 'id'), @@ -109,7 +125,10 @@ void main() { }); test('should_show_search_when_flag_is_on_or_text_is_typed', () { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); expect(logic.showSearch, isFalse); @@ -126,7 +145,10 @@ void main() { }); test('should_match_by_id_and_tolerate_missing_id_when_looking_up_drama', () { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); final catalog = [_drama('signal'), _drama('city')]; expect(logic.dramaFor('city', catalog)?.id, 'city'); @@ -167,7 +189,12 @@ void main() { // Staying active with a changed drama re-reads history. history.reads.clear(); - sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true); + sync( + _drama('vengeance'), + active: true, + wasActive: true, + dramaChanged: true, + ); expect(logic.drama.id, 'vengeance'); expect(history.reads, ['vengeance']); @@ -179,43 +206,50 @@ void main() { logic.dispose(); }); - testWidgets('should_open_and_focus_field_when_search_is_requested_and_page_is_active', ( - tester, - ) async { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: TextField( - controller: logic.search, - focusNode: logic.searchFocus, + testWidgets( + 'should_open_and_focus_field_when_search_is_requested_and_page_is_active', + (tester) async { + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + controller: logic.search, + focusNode: logic.searchFocus, + ), ), ), - ), - ); + ); - logic.syncDrama( - active: true, - wasActive: true, - nextDrama: logic.drama, - dramaChanged: false, - searchRequest: 1, - previousSearchRequest: 0, - isActive: () => true, - ); + logic.syncDrama( + active: true, + wasActive: true, + nextDrama: logic.drama, + dramaChanged: false, + searchRequest: 1, + previousSearchRequest: 0, + isActive: () => true, + ); - expect(logic.searchOpen, isTrue); - await tester.pumpAndSettle(); - expect(logic.searchFocus.hasFocus, isTrue); + expect(logic.searchOpen, isTrue); + await tester.pumpAndSettle(); + expect(logic.searchFocus.hasFocus, isTrue); - await tester.pumpWidget(const SizedBox.shrink()); - logic.dispose(); - }); + await tester.pumpWidget(const SizedBox.shrink()); + logic.dispose(); + }, + ); testWidgets('should_keep_search_field_closed_when_page_is_hidden', ( tester, ) async { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -250,7 +284,10 @@ void main() { testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', ( tester, ) async { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); final forwarded = []; final popped = Completer(); late BuildContext pageContext; @@ -297,7 +334,10 @@ void main() { testWidgets('should_show_artwork_notice_when_card_has_no_playable_media', ( tester, ) async { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); late BuildContext context; await tester.pumpWidget( @@ -322,8 +362,13 @@ void main() { logic.dispose(); }); - testWidgets('should_forward_chosen_drama_when_card_is_embedded', (tester) async { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + testWidgets('should_forward_chosen_drama_when_card_is_embedded', ( + tester, + ) async { + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); late BuildContext context; Drama? selected; @@ -350,7 +395,10 @@ void main() { }); test('should_do_nothing_when_notifying_after_dispose', () { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + ); var notifications = 0; logic.addListener(() => notifications++); diff --git a/test/discover/discover_search_test.dart b/test/discover/discover_search_test.dart index 92a45da..0ddd7e1 100644 --- a/test/discover/discover_search_test.dart +++ b/test/discover/discover_search_test.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; @@ -21,23 +21,28 @@ Drama _drama(String id, {String language = 'id'}) => Drama( void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('should_query_backend_and_render_results_when_search_text_changes', () async { - final repository = _FakeSearchRepository(results: [_drama('velvet')]); - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('signal'), - searchRepository: repository, - )..searchDebounce = Duration.zero; + test( + 'should_query_backend_and_render_results_when_search_text_changes', + () async { + final repository = _FakeSearchRepository(results: [_drama('velvet')]); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: repository, + )..searchDebounce = Duration.zero; - logic.search.text = 'velvet'; - logic.onSearchChanged(); - await pumpEventQueue(); + logic.search.text = 'velvet'; + logic.onSearchChanged(); + await pumpEventQueue(); - expect(repository.calls, [(keyword: 'velvet', pageIndex: 1, pageSize: 20)]); - expect(logic.searchLoading, isFalse); - expect(logic.visibleDramas(const []).single.id, 'velvet'); - logic.dispose(); - }); + expect(repository.calls, [ + (keyword: 'velvet', pageIndex: 1, pageSize: 20), + ]); + expect(logic.searchLoading, isFalse); + expect(logic.visibleDramas(const []).single.id, 'velvet'); + logic.dispose(); + }, + ); test('should_keep_server_results_when_text_only_loses_whitespace', () async { final repository = _FakeSearchRepository(results: [_drama('velvet')]); diff --git a/test/playback/play_ticket_quality_test.dart b/test/playback/play_ticket_quality_test.dart index 00a55c7..809a7b5 100644 --- a/test/playback/play_ticket_quality_test.dart +++ b/test/playback/play_ticket_quality_test.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; diff --git a/test/playback/player_quality_test.dart b/test/playback/player_quality_test.dart index 41e9df8..b470753 100644 --- a/test/playback/player_quality_test.dart +++ b/test/playback/player_quality_test.dart @@ -28,12 +28,7 @@ void main() { ), ); logic.sessionEpisodes = [ - Episode( - id: '12', - number: 1, - title: 'Episode 1', - isFree: true, - ), + Episode(id: '12', number: 1, title: 'Episode 1', isFree: true), ]; final ready = await logic.ensureTicket(0); diff --git a/test/player/quality_sheet_test.dart b/test/player/quality_sheet_test.dart index d9c8024..8ff9f6f 100644 --- a/test/player/quality_sheet_test.dart +++ b/test/player/quality_sheet_test.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/store/store_pages_test.dart b/test/store/store_pages_test.dart index c851692..de4fe9e 100644 --- a/test/store/store_pages_test.dart +++ b/test/store/store_pages_test.dart @@ -1,4 +1,4 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 9cb85f4..4b49b52 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -1,4 +1,4 @@ -import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/models/search_result.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; From 19caaa0a225be3f4649ea3c6905d43d4a3729b70 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 05:04:36 +0800 Subject: [PATCH 012/124] =?UTF-8?q?fix(app):=20=E5=A2=9E=E5=8A=A0=E5=85=AC?= =?UTF-8?q?=E7=BD=91=E5=9C=B0=E5=9D=80=E5=9B=9E=E9=80=80=EF=BC=8C=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B/=E7=A7=BB=E5=8A=A8=E7=BD=91=E7=BB=9C=E5=8F=AF?= =?UTF-8?q?=E8=BF=9E=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_config.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index ae4f632..c377453 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -18,6 +18,9 @@ class AppConfig { /// 办公室内网(Wi-Fi)直连地址,双网络自动切换时的首选。 static const String lanBaseUrl = 'http://192.168.31.250:8081'; + /// 公网地址(远程/移动网络访问时使用)。 + static const String publicBaseUrl = 'http://39.162.50.99:8081'; + static const String _loopbackBaseUrl = 'http://127.0.0.1:8081'; static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL'); @@ -83,7 +86,7 @@ class AppConfig { // 内网不可达,回落到公网。 } applyRuntimeBaseUrl( - compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl, + compiledBaseUrl.isNotEmpty ? compiledBaseUrl : publicBaseUrl, ); } From dc8ca7b743f421435a6022ed0405f2e55ee4dbec Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 05:14:52 +0800 Subject: [PATCH 013/124] =?UTF-8?q?feat(app):=20=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E7=B2=BE=E7=AE=80=E4=B8=BA=E4=B8=89=20Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/home/home_view.dart | 19 ------- test/home/home_nav_ui_test.dart | 10 +--- test/ranking/ranking_view_test.dart | 88 ----------------------------- 3 files changed, 2 insertions(+), 115 deletions(-) delete mode 100644 test/ranking/ranking_view_test.dart diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index e522637..52542df 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -22,7 +22,6 @@ 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'; @@ -75,7 +74,6 @@ class HomePage extends StatelessWidget { return _HomeTabs( key: ValueKey(catalog.dramas.first.id), dramas: catalog.dramas, - sections: catalog.sections, banners: catalog.banners, ); } @@ -104,12 +102,10 @@ class _HomeTabs extends StatefulWidget { const _HomeTabs({ super.key, required this.dramas, - required this.sections, required this.banners, }); final List dramas; - final List sections; final List banners; @override @@ -214,13 +210,6 @@ 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 @@ -244,14 +233,6 @@ 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/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 9ae1b1c..5075153 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -20,7 +20,6 @@ const _keys = [ Key('home-tabs-bar'), Key('tab-home'), Key('tab-discover'), - Key('tab-ranking'), Key('tab-my'), ]; @@ -34,7 +33,7 @@ void main() { }); }); - testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', ( + testWidgets('should_theme_three_tabs_and_switch_selected_state_when_tapped', ( tester, ) async { final haptics = FakeHaptics(); @@ -96,14 +95,13 @@ void main() { ); }); - testWidgets('should_map_four_tabs_to_home_discover_ranking_and_me', ( + testWidgets('should_map_three_tabs_to_home_discover_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); @@ -111,10 +109,6 @@ 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_view_test.dart b/test/ranking/ranking_view_test.dart deleted file mode 100644 index 7c226a8..0000000 --- a/test/ranking/ranking_view_test.dart +++ /dev/null @@ -1,88 +0,0 @@ -// 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 35338cbfc8ff6d4e3a24209694cdcd8359617f79 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 05:28:51 +0800 Subject: [PATCH 014/124] =?UTF-8?q?feat(app):=20=E5=89=A7=E5=9C=BA?= =?UTF-8?q?=E9=A1=B5=E4=B9=9D=E5=AE=AB=E6=A0=BC=E4=B8=8E=E6=9E=81=E7=AE=80?= =?UTF-8?q?=E6=88=91=E7=9A=84=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 40 +- lib/core/config/app_locale.dart | 12 + lib/core/config/locale_controller.dart | 56 - .../data/services/category_controller.dart | 34 +- lib/pages/discover/category_dramas_page.dart | 71 ++ lib/pages/discover/discover_binding.dart | 9 +- lib/pages/discover/discover_logic.dart | 174 +-- lib/pages/discover/discover_types.dart | 23 + lib/pages/discover/discover_view.dart | 883 ++++---------- .../{ranking => discover}/ranking_logic.dart | 0 lib/pages/discover/widgets/category_grid.dart | 114 ++ .../discover/widgets/discover_header.dart | 134 +++ .../widgets/discover_search_field.dart | 68 ++ .../widgets/discover_search_results.dart | 65 + lib/pages/discover/widgets/drama_grid.dart | 187 +++ .../discover/widgets/home_drama_grid.dart | 258 ---- .../discover/widgets/ranking_section.dart | 268 +++++ lib/pages/home/home_logic.dart | 72 +- lib/pages/home/home_view.dart | 218 ++-- lib/pages/home/widgets/home_menu_sheet.dart | 288 +++-- lib/pages/mine/mine_binding.dart | 13 +- lib/pages/mine/mine_logic.dart | 110 +- lib/pages/mine/mine_types.dart | 18 + lib/pages/mine/mine_view.dart | 1066 +++-------------- .../mine/widgets/continue_watching_card.dart | 114 ++ lib/pages/mine/widgets/mine_header.dart | 84 ++ lib/pages/mine/widgets/mine_menu.dart | 113 ++ lib/pages/player/player_logic.dart | 48 +- lib/pages/ranking/ranking_view.dart | 443 ------- lib/pages/settings/settings_page.dart | 91 ++ test/account/mine_account_entry_test.dart | 5 +- test/category/category_controller_test.dart | 71 +- test/config/app_locale_test.dart | 19 + test/config/locale_controller_test.dart | 123 -- test/discover/discover_logic_test.dart | 354 +----- test/discover/discover_search_test.dart | 32 +- test/discover/home_all_dramas_grid_test.dart | 389 +----- test/home/home_logic_test.dart | 121 +- test/home/home_nav_ui_test.dart | 17 +- test/mine/mine_logic_test.dart | 239 +--- test/mine/mine_preview_layout_test.dart | 74 +- test/mine/server_endpoint_setting_test.dart | 73 -- .../settings_page_test.dart} | 64 +- test/notice/notices_entry_test.dart | 6 +- test/ranking/ranking_logic_test.dart | 2 +- test/widget_test.dart | 48 +- 46 files changed, 2353 insertions(+), 4358 deletions(-) create mode 100644 lib/core/config/app_locale.dart delete mode 100644 lib/core/config/locale_controller.dart create mode 100644 lib/pages/discover/category_dramas_page.dart create mode 100644 lib/pages/discover/discover_types.dart rename lib/pages/{ranking => discover}/ranking_logic.dart (100%) create mode 100644 lib/pages/discover/widgets/category_grid.dart create mode 100644 lib/pages/discover/widgets/discover_header.dart create mode 100644 lib/pages/discover/widgets/discover_search_field.dart create mode 100644 lib/pages/discover/widgets/discover_search_results.dart create mode 100644 lib/pages/discover/widgets/drama_grid.dart delete mode 100644 lib/pages/discover/widgets/home_drama_grid.dart create mode 100644 lib/pages/discover/widgets/ranking_section.dart create mode 100644 lib/pages/mine/mine_types.dart create mode 100644 lib/pages/mine/widgets/continue_watching_card.dart create mode 100644 lib/pages/mine/widgets/mine_header.dart create mode 100644 lib/pages/mine/widgets/mine_menu.dart delete mode 100644 lib/pages/ranking/ranking_view.dart create mode 100644 lib/pages/settings/settings_page.dart create mode 100644 test/config/app_locale_test.dart delete mode 100644 test/config/locale_controller_test.dart delete mode 100644 test/mine/server_endpoint_setting_test.dart rename test/{discover/discover_language_filter_test.dart => mine/settings_page_test.dart} (52%) diff --git a/lib/app.dart b/lib/app.dart index 84f1e7f..b697138 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'core/config/locale_controller.dart'; +import 'core/config/app_locale.dart'; import 'core/data/remote/account_api.dart'; import 'core/data/remote/api_client.dart'; import 'core/data/remote/auth_api.dart'; @@ -238,35 +238,31 @@ class _LumaDramaAppState extends State { ChangeNotifierProvider( create: (_) => CategoryController(_categoryRepository)..load(), ), - ChangeNotifierProvider(create: (_) => LocaleController()..load()), ChangeNotifierProvider.value(value: _favoriteController), ChangeNotifierProvider.value(value: _downloadController), ChangeNotifierProvider.value(value: _walletController), ChangeNotifierProvider.value(value: _interactionController), ChangeNotifierProvider.value(value: _auth), ], - child: Consumer( - builder: (context, locale, _) => MaterialApp( - onGenerateTitle: (context) => AppLocalizations.of(context)!.appName, - locale: locale.locale, - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalizations.localizationsDelegates, - localeResolutionCallback: (deviceLocale, _) => - resolveAppLocale(locale.locale, deviceLocale), - debugShowCheckedModeBanner: false, - themeMode: ThemeMode.dark, - theme: AppTheme.light(), - darkTheme: AppTheme.dark(), - builder: (context, child) => AnnotatedRegion( - value: SystemUiOverlayStyle.light.copyWith( - statusBarColor: Colors.transparent, - systemNavigationBarColor: AppColors.bar, - systemNavigationBarIconBrightness: Brightness.light, - ), - child: child ?? const SizedBox.shrink(), + child: MaterialApp( + onGenerateTitle: (context) => AppLocalizations.of(context)!.appName, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + localeResolutionCallback: (deviceLocale, _) => + resolveAppLocale(deviceLocale), + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.dark, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + builder: (context, child) => AnnotatedRegion( + value: SystemUiOverlayStyle.light.copyWith( + statusBarColor: Colors.transparent, + systemNavigationBarColor: AppColors.bar, + systemNavigationBarIconBrightness: Brightness.light, ), - home: const HomePage(), + child: child ?? const SizedBox.shrink(), ), + home: const HomePage(), ), ); } diff --git a/lib/core/config/app_locale.dart b/lib/core/config/app_locale.dart new file mode 100644 index 0000000..5e5b725 --- /dev/null +++ b/lib/core/config/app_locale.dart @@ -0,0 +1,12 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; + +/// 将系统语言解析为受支持的语言;不支持时回退印尼语。 +Locale resolveAppLocale(Locale? deviceLocale) { + return switch (deviceLocale?.languageCode) { + 'zh' => const Locale('zh'), + 'en' => const Locale('en'), + _ => const Locale('id'), + }; +} diff --git a/lib/core/config/locale_controller.dart b/lib/core/config/locale_controller.dart deleted file mode 100644 index 9404b4c..0000000 --- a/lib/core/config/locale_controller.dart +++ /dev/null @@ -1,56 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/widgets.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -/// Resolves the selected or system language to one of the supported app locales. -/// -/// Indonesian is the launch-market fallback when a device uses an unsupported -/// language. The legacy `in` code is treated as Indonesian as well. -Locale resolveAppLocale(Locale? selected, Locale? deviceLocale) { - final code = (selected ?? deviceLocale)?.languageCode; - return switch (code) { - 'zh' => const Locale('zh'), - 'en' => const Locale('en'), - _ => const Locale('id'), - }; -} - -class LocaleController extends ChangeNotifier { - LocaleController({SharedPreferencesAsync? preferences}) - : _preferences = preferences ?? SharedPreferencesAsync(); - - final SharedPreferencesAsync _preferences; - Locale? _locale; - Locale? get locale => _locale; - - Future load() async { - try { - final code = await _preferences.getString('ui.locale'); - _locale = switch (code) { - 'en' => const Locale('en'), - 'zh' => const Locale('zh'), - 'id' => const Locale('id'), - _ => null, - }; - notifyListeners(); - } catch (_) { - // The system locale remains usable if preferences are unavailable. - } - } - - Future select(String code) async { - _locale = switch (code) { - 'en' => const Locale('en'), - 'zh' => const Locale('zh'), - 'id' => const Locale('id'), - _ => null, - }; - notifyListeners(); - try { - await _preferences.setString('ui.locale', code); - } catch (_) { - // Preference persistence is best effort for this prototype. - } - } -} diff --git a/lib/core/data/services/category_controller.dart b/lib/core/data/services/category_controller.dart index efeada2..026f17f 100644 --- a/lib/core/data/services/category_controller.dart +++ b/lib/core/data/services/category_controller.dart @@ -7,38 +7,25 @@ import '../models/drama.dart'; import '../models/home_feed.dart'; import '../repositories/category_repository.dart'; -/// 发现页分类状态:服务端分类清单、发现 feed 与当前筛选。 +/// 剧场页数据:服务端分类清单与发现 feed。 class CategoryController extends ChangeNotifier { CategoryController(this._repository); - static const String allCode = 'all'; - static const String hotCode = 'hot'; - static const String newCode = 'new'; - final CategoryRepository _repository; List categories = const []; HomeFeed feed = const HomeFeed(); - String selectedCode = allCode; bool loading = false; String? error; bool _disposed = false; - /// 当前筛选对应的分区 code;全部分类为空字符串语义。 - bool get selectedSectionMissing => - selectedCode != allCode && _selectedSection == null; - - /// 当前筛选应展示的剧集;全部分类按 feed 顺序去重。 - List get visibleDramas { - if (selectedCode == allCode) return feed.dramas; - return _selectedSection?.dramas ?? const []; - } - - FeedSection? get _selectedSection { + /// 分类九宫格点击后读取该分类的剧目;缺失分区返回空列表。 + List dramasOf(String categoryId) { + final code = 'category-$categoryId'; for (final section in feed.sections) { - if (section.code == selectedCode) return section; + if (section.code == code) return section.dramas; } - return null; + return const []; } /// 加载分类与发现 feed;失败保留可重试错误态。 @@ -61,15 +48,6 @@ class CategoryController extends ChangeNotifier { } } - /// 选择服务端分类,内容只匹配 `category-{id}`。 - void selectCategory(String id) => selectCode('category-$id'); - - /// 选择内置或分区 code。 - void selectCode(String code) { - selectedCode = code; - _notify(); - } - void _notify() { if (!_disposed) notifyListeners(); } diff --git a/lib/pages/discover/category_dramas_page.dart b/lib/pages/discover/category_dramas_page.dart new file mode 100644 index 0000000..385a52c --- /dev/null +++ b/lib/pages/discover/category_dramas_page.dart @@ -0,0 +1,71 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; +import '../drama_detail/drama_detail_view.dart'; +import 'widgets/drama_grid.dart'; + +/// 从剧场分类九宫格进入的分类剧目列表。 +class CategoryDramasPage extends StatelessWidget { + const CategoryDramasPage({ + super.key, + required this.title, + required this.dramas, + this.onOpen, + }); + + final String title; + final List dramas; + final ValueChanged? onOpen; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('category-dramas-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onSurface, + title: Text(title), + ), + body: SafeArea( + top: false, + child: dramas.isEmpty + ? EmptyState( + key: const Key('category-dramas-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.noCategoryDramas, + subtitle: l10n.emptyHint, + ) + : SingleChildScrollView( + padding: const EdgeInsets.only(top: 12, bottom: 28), + child: DramaGrid( + l10n: l10n, + dramas: dramas, + onOpen: (drama) => _open(context, drama), + ), + ), + ), + ), + ); + } + + void _open(BuildContext context, Drama drama) { + final callback = onOpen; + if (callback != null) { + callback(drama); + return; + } + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + ); + } +} diff --git a/lib/pages/discover/discover_binding.dart b/lib/pages/discover/discover_binding.dart index 0f62d70..ec1e157 100644 --- a/lib/pages/discover/discover_binding.dart +++ b/lib/pages/discover/discover_binding.dart @@ -1,14 +1,9 @@ // Author: fruit.com.cn -import '../../core/data/models/drama.dart'; import '../../core/data/repositories/search_repository.dart'; -import '../../core/data/repositories/watch_history_repository.dart'; import 'discover_logic.dart'; class DiscoverBinding { - DiscoverLogic create({ - required Drama drama, - required WatchHistoryRepository history, - SearchRepository? search, - }) => DiscoverLogic(drama: drama, history: history, searchRepository: search); + DiscoverLogic create({SearchRepository? search}) => + DiscoverLogic(searchRepository: search); } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 55e5972..7df910f 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -5,39 +5,23 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/models/watch_progress.dart'; import '../../core/data/remote/api_exception.dart'; import '../../core/data/repositories/search_repository.dart'; -import '../../core/data/repositories/watch_history_repository.dart'; -import '../../l10n/app_localizations.dart'; - -enum DiscoverPageAction { home, my, watchlist, detail, resume } +import 'discover_types.dart'; +/// 剧场页搜索状态:服务端检索、防抖与错误态。 class DiscoverLogic extends ChangeNotifier { - DiscoverLogic({ - required this.history, - required this.drama, - this.searchRepository, - }) { - refreshPosition(); - } + DiscoverLogic({this.searchRepository}); - final WatchHistoryRepository history; final SearchRepository? searchRepository; - Drama drama; - late Future position; - WatchProgress? resumeRecord; final TextEditingController search = TextEditingController(); final FocusNode searchFocus = FocusNode(); final ScrollController scroll = ScrollController(); - bool searchOpen = false; /// 搜索防抖与分页口径:与后端 `/app/search` 一致。 Duration searchDebounce = const Duration(milliseconds: 350); static const int searchPageSize = 20; List searchResults = const []; - String? selectedLanguage; - int searchTotal = 0; bool searchLoading = false; String? searchError; Timer? _searchTimer; @@ -45,36 +29,12 @@ class DiscoverLogic extends ChangeNotifier { int _searchRequest = 0; bool _disposed = false; + bool get hasSearchQuery => search.text.trim().isNotEmpty; + void notify() { if (!_disposed) notifyListeners(); } - void refreshPosition() { - // 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看" - position = history - .resumePointFor(drama.id) - .then((point) => point?.position) - .catchError((Object _) => null); - refreshResume(); - } - - Future refreshResume() async { - try { - final recent = await history.recent(limit: 1); - if (recent.isEmpty || recent.first.finished) { - resumeRecord = null; - } else { - resumeRecord = recent.first; - } - } catch (_) { - resumeRecord = null; - } - notify(); - } - - bool get hasSearchQuery => search.text.trim().isNotEmpty; - - /// 输入变化只触发后端查询;本地不再做标题/简介匹配。 void onSearchChanged() { final query = search.text.trim(); if (query.isEmpty) { @@ -86,47 +46,17 @@ class DiscoverLogic extends ChangeNotifier { _scheduleSearch(query); } - /// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。 - List visibleDramas(List fallback) { - if (!hasSearchQuery) return _filterByLanguage(fallback); - return _filterByLanguage(searchResults); - } + void openSearch() => searchFocus.requestFocus(); - /// 当前 feed 中出现过的语种,按首次出现顺序去重。 - List languagesOf(List dramas) { - final languages = []; - for (final drama in dramas) { - final language = drama.language; - if (language.isNotEmpty && !languages.contains(language)) { - languages.add(language); - } - } - return languages; - } - - /// 按语种筛选;null 表示全部。 - void selectLanguage(String? code) { - selectedLanguage = code; - notify(); - } - - List _filterByLanguage(List dramas) { - final code = selectedLanguage; - if (code == null) return dramas; - return dramas.where((drama) => drama.language == code).toList(); - } - - Future retrySearch() async { + void retrySearch() { final query = search.text.trim(); - if (query.isEmpty) return; - await _runSearch(query); + if (query.isNotEmpty) unawaited(_runSearch(query)); } void _resetSearch() { _searchTimer?.cancel(); _searchRequest++; searchResults = const []; - searchTotal = 0; searchLoading = false; searchError = null; _lastRequestedQuery = ''; @@ -162,7 +92,6 @@ class DiscoverLogic extends ChangeNotifier { ); if (request != _searchRequest) return; searchResults = result.list; - searchTotal = result.total; } on ApiException catch (exception) { _applySearchError(request, exception.message); } catch (_) { @@ -179,47 +108,20 @@ class DiscoverLogic extends ChangeNotifier { if (request != _searchRequest) return; searchError = message; searchResults = const []; - searchTotal = 0; } - Drama? resumeDrama(List dramas) { - final record = resumeRecord; - if (record == null) return null; - for (final drama in dramas) { - if (drama.id == record.dramaId) return drama; - } - return null; - } - - bool get showSearch => searchOpen || search.text.isNotEmpty; - - void openSearch() { - searchOpen = true; - notify(); - searchFocus.requestFocus(); - } - - void syncDrama({ + /// 从播放器发起的搜索请求只在剧场页可见时抢焦点。 + void syncSearch({ required bool active, - required bool wasActive, - required Drama nextDrama, - required bool dramaChanged, - required int searchRequest, - required int previousSearchRequest, + required int request, + required int previousRequest, required bool Function() isActive, }) { - if (active && (!wasActive || dramaChanged)) { - drama = nextDrama; - refreshPosition(); - notify(); - } - if (active && searchRequest != previousSearchRequest) { - searchOpen = true; - notify(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (isActive()) searchFocus.requestFocus(); - }); - } + if (!active || request == previousRequest) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isActive()) searchFocus.requestFocus(); + }); + WidgetsBinding.instance.scheduleFrame(); } void act( @@ -235,48 +137,6 @@ class DiscoverLogic extends ChangeNotifier { } } - Drama? dramaFor(String? dramaId, List dramas) { - for (final item in dramas) { - if (item.id == dramaId) return item; - } - return null; - } - - void openCard( - BuildContext context, - Drama? selected, { - required bool embedded, - ValueChanged? onDramaSelected, - ValueChanged? onAction, - }) { - if (selected == null) { - unavailable(context); - } else if (embedded && onDramaSelected != null) { - onDramaSelected(selected); - } else { - act( - context, - DiscoverPageAction.detail, - embedded: embedded, - onAction: onAction, - ); - } - } - - void unavailable(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.artworkOnlyNotice))); - } - - void catalogInfo(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice))); - } - @override void dispose() { _disposed = true; diff --git a/lib/pages/discover/discover_types.dart b/lib/pages/discover/discover_types.dart new file mode 100644 index 0000000..a609965 --- /dev/null +++ b/lib/pages/discover/discover_types.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../../core/data/models/drama.dart'; + +/// 剧场页与播放器之间的返回动作。 +enum DiscoverPageAction { home, my } + +/// 剧场页导航回调;没有 `onAction` 时按独立页面处理。 +class DiscoverPageCallbacks { + const DiscoverPageCallbacks({ + this.onAction, + this.onDramaSelected, + this.onProfile, + }); + + final ValueChanged? onAction; + final ValueChanged? onDramaSelected; + final VoidCallback? onProfile; + + bool get embedded => onAction != null; +} diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 35f0198..bfa0d41 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -4,12 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../../core/config/app_config.dart'; +import '../../core/data/models/content_category.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/catalog_repository.dart'; import '../../core/data/repositories/search_repository.dart'; -import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; @@ -17,53 +17,34 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; +import '../drama_detail/drama_detail_view.dart'; +import 'category_dramas_page.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; +import 'discover_types.dart'; +import 'widgets/category_grid.dart'; +import 'widgets/discover_header.dart'; +import 'widgets/discover_search_field.dart'; +import 'widgets/discover_search_results.dart'; +import 'widgets/drama_grid.dart'; import 'widgets/feed_banner_carousel.dart'; +import 'widgets/ranking_section.dart'; -part 'widgets/home_drama_grid.dart'; - +/// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。 class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, - required this.drama, - required this.duration, - this.focusSearch = false, - this.history, - this.searchRepository, - this.embedded = false, this.active = true, this.searchRequest = 0, - this.signedIn = false, - this.onSignIn, - this.onCoins, - this.onRewards, - this.onAction, - this.dramas, - this.onDramaSelected, - this.banners = const [], - this.onBannerSelected, + this.focusSearch = false, + this.callbacks = const DiscoverPageCallbacks(), }); - final Drama drama; - final Duration duration; - final bool focusSearch; - final WatchHistoryRepository? history; - final SearchRepository? searchRepository; - final bool embedded; final bool active; final int searchRequest; - final bool signedIn; - final VoidCallback? onSignIn; - final VoidCallback? onCoins; - final VoidCallback? onRewards; - final ValueChanged? onAction; - final List? dramas; - final ValueChanged? onDramaSelected; - final List banners; - final ValueChanged? onBannerSelected; + final bool focusSearch; + final DiscoverPageCallbacks callbacks; @override State createState() => _DiscoverPageState(); @@ -72,32 +53,28 @@ class DiscoverPage extends StatefulWidget { class _DiscoverPageState extends State { late final DiscoverLogic logic; + bool get embedded => widget.callbacks.embedded; + @override void initState() { super.initState(); - logic = DiscoverBinding().create( - drama: widget.drama, - history: widget.history ?? context.read(), - search: widget.searchRepository ?? context.read(), - ); - if (widget.focusSearch) { - logic.searchOpen = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && widget.active) logic.searchFocus.requestFocus(); - }); - } + logic = DiscoverBinding().create(search: context.read()); + if (widget.focusSearch) _focusOnFirstFrame(); + } + + void _focusOnFirstFrame() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.active) logic.openSearch(); + }); } @override void didUpdateWidget(covariant DiscoverPage oldWidget) { super.didUpdateWidget(oldWidget); - logic.syncDrama( + logic.syncSearch( active: widget.active, - wasActive: oldWidget.active, - nextDrama: widget.drama, - dramaChanged: oldWidget.drama.id != widget.drama.id, - searchRequest: widget.searchRequest, - previousSearchRequest: oldWidget.searchRequest, + request: widget.searchRequest, + previousRequest: oldWidget.searchRequest, isActive: () => mounted && widget.active, ); } @@ -119,15 +96,14 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); - final allDramas = [...?widget.dramas, ...categories.visibleDramas]; - final visible = logic.visibleDramas(categories.visibleDramas); + final banners = context.watch().banners; final content = SafeArea( - bottom: !widget.embedded, + bottom: !embedded, child: Column( children: [ - _header(l10n), - Expanded(child: _feed(l10n, categories, allDramas, visible)), - if (!widget.embedded) _bottomNav(l10n), + DiscoverHeader(onSearch: logic.openSearch, onProfile: _openProfile), + Expanded(child: _feed(l10n, categories, banners)), + if (!embedded) _bottomNav(l10n), ], ), ); @@ -135,7 +111,7 @@ class _DiscoverPageState extends State { value: SystemUiOverlayStyle.light, child: Theme( data: AppTheme.dark(), - child: widget.embedded + child: embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), ), @@ -145,523 +121,222 @@ class _DiscoverPageState extends State { Widget _feed( AppLocalizations l10n, CategoryController categories, - List allDramas, - List visible, - ) => RefreshIndicator( - key: const Key('discover-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, - onRefresh: () async { - await Future.wait([categories.load(), logic.refreshResume()]); - }, - child: CustomScrollView( - 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), - ), + List banners, + ) { + return RefreshIndicator( + key: const Key('discover-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: categories.load, + child: ListView( + controller: logic.scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 24), + children: [ + Padding( + padding: const EdgeInsets.only(top: 8), + child: DiscoverSearchField( + controller: logic.search, + focusNode: logic.searchFocus, + onChanged: (_) => logic.onSearchChanged(), + loading: logic.searchLoading, ), ), - SliverToBoxAdapter(child: _search()), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 14, 0, 12), - child: _categories(l10n, categories), + ..._content(l10n, categories, banners), + ], + ), + ); + } + + List _content( + AppLocalizations l10n, + CategoryController categories, + List banners, + ) { + if (logic.hasSearchQuery) return [_searchContent(l10n)]; + return _theaterContent(l10n, categories, banners); + } + + Widget _searchContent(AppLocalizations l10n) => DiscoverSearchResults( + loading: logic.searchLoading, + error: logic.searchError, + results: logic.searchResults, + onRetry: logic.retrySearch, + onOpen: _openDrama, + ); + + List _theaterContent( + AppLocalizations l10n, + CategoryController categories, + List banners, + ) { + if (categories.loading && categories.feed.sections.isEmpty) { + return [_block(const CircularProgressIndicator(strokeWidth: 2))]; + } + final error = categories.error; + if (error != null) { + return [ + _block( + EmptyState( + key: const Key('home-categories-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, error, l10n.categoriesError), + subtitle: l10n.emptyHint, + actionLabel: l10n.retry, + onAction: categories.load, ), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 12), - child: _languages(l10n, allDramas), + ]; + } + return _theaterBlocks(l10n, categories, banners); + } + + List _theaterBlocks( + AppLocalizations l10n, + CategoryController categories, + List banners, + ) { + final dramas = categories.feed.dramas; + return [ + Padding( + padding: const EdgeInsets.only(top: 12), + child: CategoryGrid( + categories: categories.categories, + onTap: (category) => _openCategory(category, categories), + ), + ), + if (banners.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: FeedBannerCarousel( + banners: banners, + active: widget.active, + onOpen: _openBanner, ), ), - if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), - child: _continueCard( - l10n, - logic.resumeDrama(allDramas)!, - logic.resumeRecord!, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), - child: _quickActions(l10n, categories), - ), + if (categories.feed.sections.isNotEmpty || dramas.isNotEmpty) + RankingSection( + sections: categories.feed.sections, + fallback: dramas, + onOpen: _openDrama, ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), - child: Row( - children: [ - Container( - width: 3, - height: 18, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 6), - Expanded( - child: Text( - l10n.trendingMicroDramas, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.previewSectionTitle, - fontWeight: FontWeight.w800, - ), - ), - ), - TextButton( - onPressed: () => - categories.selectCode(CategoryController.allCode), - style: TextButton.styleFrom( - foregroundColor: AppColors.accent, - minimumSize: const Size(44, 44), - padding: EdgeInsets.zero, - ), - child: Text( - '${l10n.all} ›', - style: const TextStyle(fontSize: 10), - ), - ), - ], - ), - ), - ), - _HomeDramaGrid( - l10n: l10n, - categories: categories, - dramas: visible, - searching: logic.hasSearchQuery, - searchLoading: logic.searchLoading, - searchError: logic.searchError, - onRetrySearch: () => logic.retrySearch(), - onOpen: (drama) => logic.openCard( - context, - drama, - embedded: widget.embedded, - onDramaSelected: widget.onDramaSelected, - onAction: widget.onAction, - ), - ), - ], + _sectionTitle(l10n.allDramas), + if (dramas.isEmpty) _emptyCatalog(l10n) else _dramaGrid(l10n, dramas), + ]; + } + + Widget _block(Widget child) => + SizedBox(height: 280, child: Center(child: child)); + + Widget _emptyCatalog(AppLocalizations l10n) => Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: EmptyState( + key: const Key('discover-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.noStories, + subtitle: l10n.emptyHint, ), ); - Widget _header(AppLocalizations l10n) { - return SizedBox( - height: 46, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - children: [ - Container( - width: 24, - height: 24, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(7), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 16, - ), + Widget _dramaGrid(AppLocalizations l10n, List dramas) { + return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama); + } + + Widget _sectionTitle(String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 18, 14, 10), + child: Row( + children: [ + Container( + width: 3, + height: 18, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(2), ), - const SizedBox(width: 8), - Text( - l10n.discoverTab, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( - fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: 15, + fontSize: AppDimens.previewSectionTitle, fontWeight: FontWeight.w800, ), ), - const Spacer(), - _HeaderIcon( - key: const Key('discover-search-button'), - icon: Icons.search_rounded, - tooltip: l10n.search, - onTap: logic.openSearch, - ), - const SizedBox(width: 8), - Semantics( - button: true, - label: widget.signedIn ? l10n.myTab : l10n.logIn, - child: InkWell( - key: const Key('discover-profile'), - customBorder: const CircleBorder(), - onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF6A5CFF), AppColors.blue], - ), - ), - child: const Icon( - Icons.person_rounded, - color: AppColors.onSurface, - size: 18, - ), - ), - ), - ), - ], - ), - ), - ); - } - - Widget _search() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: SizedBox( - height: 36, - child: TextField( - key: const Key('discover-search'), - controller: logic.search, - focusNode: logic.searchFocus, - onChanged: (_) => logic.onSearchChanged(), - - onTapOutside: (_) => logic.searchFocus.unfocus(), - textInputAction: TextInputAction.search, - style: const TextStyle(color: AppColors.onSurface, fontSize: 13), - decoration: InputDecoration( - isDense: true, - filled: true, - fillColor: AppColors.input, - hintText: AppLocalizations.of(context)!.searchDiscover, - hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11), - prefixIcon: const Icon( - Icons.search_rounded, - color: AppColors.subtle, - size: 17, - ), - suffixIcon: logic.searchLoading - ? const Padding( - padding: EdgeInsets.all(11), - child: CircularProgressIndicator(strokeWidth: 2), - ) - : null, - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: const BorderSide(color: AppColors.blue, width: 1), - ), - ), - ), - ), - ); - } - - Widget _categories(AppLocalizations l10n, CategoryController categories) { - final choices = <({String code, String label})>[ - (code: CategoryController.allCode, label: l10n.recommended), - for (final category in categories.categories) - (code: 'category-${category.id}', label: category.name), - ]; - return SizedBox( - key: const Key('home-category-chips'), - height: AppDimens.tapTarget, - child: ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 14), - scrollDirection: Axis.horizontal, - itemCount: choices.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final choice = choices[index]; - final selected = categories.selectedCode == choice.code; - return Semantics( - selected: selected, - button: true, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => categories.selectCode(choice.code), - child: Center( - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 15), - decoration: BoxDecoration( - color: selected ? AppColors.blue : const Color(0xFF29272B), - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: selected ? AppColors.blue : Colors.transparent, - ), - ), - child: Text( - choice.label, - maxLines: 1, - style: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.muted, - fontSize: AppDimens.chipFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - height: 1.1, - ), - ), - ), - ), - ), - ); - }, - ), - ); - } - - Widget _languages(AppLocalizations l10n, List dramas) { - final languages = logic.languagesOf(dramas); - if (languages.isEmpty) return const SizedBox.shrink(); - final selected = logic.selectedLanguage; - return SizedBox( - key: const Key('discover-language-chips'), - height: AppDimens.tapTarget, - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 14), - scrollDirection: Axis.horizontal, - children: [ - _languageChip( - key: const Key('discover-language-all'), - label: l10n.all, - selected: selected == null, - onTap: () => logic.selectLanguage(null), - ), - for (final language in languages) - _languageChip( - key: Key('discover-language-$language'), - label: _languageLabel(l10n, language), - selected: selected == language, - onTap: () => logic.selectLanguage(language), - ), - ], - ), - ); - } - - Widget _languageChip({ - required Key key, - required String label, - required bool selected, - required VoidCallback onTap, - }) { - return Padding( - padding: const EdgeInsets.only(right: 8), - child: Center( - child: ChoiceChip( - key: key, - label: Text(label), - selected: selected, - showCheckmark: false, - onSelected: (_) => onTap(), - selectedColor: AppColors.blue, - backgroundColor: const Color(0xFF29272B), - side: BorderSide.none, - labelStyle: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.muted, - fontSize: AppDimens.chipFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - ), - ), - ), - ); - } - - String _languageLabel(AppLocalizations l10n, String code) { - if (code == 'zh-CN' || code == 'zh') return l10n.languageChinese; - if (code == 'id') return l10n.languageIndonesian; - if (code == 'en') return l10n.languageEnglish; - return code; - } - - Widget _continueCard( - AppLocalizations l10n, - Drama drama, - WatchProgress record, - ) { - final progress = record.fraction; - return Container( - key: const Key('discover-continue-watching'), - padding: const EdgeInsets.all(9), - decoration: BoxDecoration( - color: AppColors.cardAlt, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SizedBox( - width: 52, - height: 66, - child: RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - ), - ), - const SizedBox(width: 9), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.continueWatching, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.accent, - fontSize: 8, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - drama.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 12, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - _resumeShort(drama, record), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), - ), - const SizedBox(height: 7), - ClipRRect( - borderRadius: BorderRadius.circular(999), - child: LinearProgressIndicator( - value: progress <= 0 ? null : progress, - minHeight: 3, - backgroundColor: const Color(0xFF474248), - valueColor: const AlwaysStoppedAnimation(AppColors.blue), - ), - ), - ], - ), - ), - const SizedBox(width: 10), - FilledButton( - key: const Key('discover-continue-play'), - onPressed: () => logic.openCard( - context, - drama, - embedded: widget.embedded, - onDramaSelected: widget.onDramaSelected, - onAction: widget.onAction, - ), - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - minimumSize: const Size(56, 28), - padding: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(9), - ), - textStyle: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - ), - ), - child: Text(l10n.resume), ), ], ), ); } - String _resumeShort(Drama drama, WatchProgress record) { - final label = AppLocalizations.of(context)!; - Episode? episode; - for (final item in drama.episodes) { - if (item.id == record.episodeId) { - episode = item; - break; + void _openProfile() { + final callback = widget.callbacks.onProfile; + if (callback != null) { + callback(); + return; + } + _act(DiscoverPageAction.my); + } + + void _act(DiscoverPageAction action) { + logic.act( + context, + action, + embedded: embedded, + onAction: widget.callbacks.onAction, + ); + } + + void _openCategory(ContentCategory category, CategoryController categories) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CategoryDramasPage( + title: category.name, + dramas: categories.dramasOf(category.id), + onOpen: widget.callbacks.onDramaSelected == null ? null : _openDrama, + ), + ), + ); + } + + void _openDrama(Drama drama) { + final callback = widget.callbacks.onDramaSelected; + if (callback != null) { + callback(drama); + return; + } + _pushDetail(drama); + } + + void _pushDetail(Drama drama) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + ); + } + + Future _openBanner(FeedBanner banner) async { + final dramaId = banner.dramaId; + if (dramaId == null) return; + for (final drama in context.read().dramas) { + if (drama.id == dramaId) { + _pushDetail(drama); + return; } } - if (episode == null) return _duration(record.position); - return label.episodeOfTotal(episode.number, drama.episodeCount); - } - - Widget _quickActions(AppLocalizations l10n, CategoryController categories) { - return Row( - children: [ - Expanded( - child: _QuickAction( - key: const Key('discover-quick-filter'), - icon: Icons.tune_rounded, - label: l10n.filter, - color: AppColors.secondary, - onTap: logic.openSearch, - ), - ), - const SizedBox(width: 7), - Expanded( - child: _QuickAction( - key: const Key('discover-quick-charts'), - icon: Icons.local_fire_department_rounded, - label: l10n.topCharts, - color: AppColors.blue, - onTap: () => categories.selectCode(CategoryController.hotCode), - ), - ), - const SizedBox(width: 7), - Expanded( - child: _QuickAction( - key: const Key('discover-quick-new'), - icon: Icons.auto_awesome_rounded, - label: l10n.newReleases, - color: AppColors.gold, - onTap: () => categories.selectCode(CategoryController.newCode), - ), - ), - const SizedBox(width: 7), - Expanded( - child: _QuickAction( - key: const Key('discover-quick-watchlist'), - icon: Icons.bookmark_border_rounded, - label: l10n.watchlist, - color: AppColors.success, - onTap: () => logic.act( - context, - DiscoverPageAction.watchlist, - embedded: widget.embedded, - onAction: widget.onAction, - ), - ), - ), - ], - ); + try { + final drama = await context.read().fetchDrama(dramaId); + if (mounted) _pushDetail(drama); + } catch (_) { + if (!mounted) return; + final message = AppLocalizations.of(context)!.detailLoadFailed; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } } Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( @@ -671,12 +346,7 @@ class _DiscoverPageState extends State { icon: Icons.play_circle_outline_rounded, selectedIcon: Icons.play_circle_fill_rounded, label: l10n.homeTab, - onTap: () => logic.act( - context, - DiscoverPageAction.home, - embedded: widget.embedded, - onAction: widget.onAction, - ), + onTap: () => _act(DiscoverPageAction.home), ), AppBottomNavItem( tabKey: const Key('discover-nav-current'), @@ -691,101 +361,8 @@ class _DiscoverPageState extends State { icon: Icons.video_library_outlined, selectedIcon: Icons.video_library_rounded, label: l10n.myTab, - onTap: () => logic.act( - context, - DiscoverPageAction.my, - embedded: widget.embedded, - onAction: widget.onAction, - ), + onTap: () => _act(DiscoverPageAction.my), ), ], ); } - -class _HeaderIcon extends StatelessWidget { - const _HeaderIcon({ - super.key, - required this.icon, - required this.tooltip, - required this.onTap, - }); - - final IconData icon; - final String tooltip; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 36, - height: 36, - child: IconButton( - tooltip: tooltip, - onPressed: onTap, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: const Color(0xFF2C2D33), - foregroundColor: AppColors.onSurface, - ), - icon: Icon(icon, size: 18), - ), - ); - } -} - -class _QuickAction extends StatelessWidget { - const _QuickAction({ - super.key, - required this.icon, - required this.label, - required this.color, - required this.onTap, - }); - - final IconData icon; - final String label; - final Color color; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: AppColors.quick, - borderRadius: BorderRadius.circular(12), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Container( - height: 62, - padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5), - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: color, size: 16), - const SizedBox(height: 4), - Text( - label, - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 9, - fontWeight: FontWeight.w500, - height: 1.05, - ), - ), - ], - ), - ), - ), - ); - } -} - -String _duration(Duration value) { - final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; -} diff --git a/lib/pages/ranking/ranking_logic.dart b/lib/pages/discover/ranking_logic.dart similarity index 100% rename from lib/pages/ranking/ranking_logic.dart rename to lib/pages/discover/ranking_logic.dart diff --git a/lib/pages/discover/widgets/category_grid.dart b/lib/pages/discover/widgets/category_grid.dart new file mode 100644 index 0000000..63ae42f --- /dev/null +++ b/lib/pages/discover/widgets/category_grid.dart @@ -0,0 +1,114 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/content_category.dart'; +import '../../../core/theme/app_colors.dart'; + +const double _gridPadding = 14; +const double _tileSpacing = 8; +const double _tileAspectRatio = 1.1; +const int _columns = 3; +const List _icons = [ + Icons.favorite_rounded, + Icons.bolt_rounded, + Icons.theater_comedy_rounded, + Icons.auto_awesome_rounded, + Icons.psychology_alt_rounded, + Icons.local_fire_department_rounded, + Icons.family_restroom_rounded, + Icons.work_history_rounded, +]; + +/// 剧场顶部分类九宫格。 +class CategoryGrid extends StatelessWidget { + const CategoryGrid({ + super.key, + required this.categories, + required this.onTap, + }); + + final List categories; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + if (categories.isEmpty) return const SizedBox.shrink(); + return GridView.builder( + key: const Key('discover-category-grid'), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: _gridPadding), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _columns, + mainAxisSpacing: _tileSpacing, + crossAxisSpacing: _tileSpacing, + childAspectRatio: _tileAspectRatio, + ), + itemCount: categories.length, + itemBuilder: (context, index) { + final category = categories[index]; + return _CategoryTile( + category: category, + icon: _icons[index % _icons.length], + onTap: () => onTap(category), + ); + }, + ); + } +} + +class _CategoryTile extends StatelessWidget { + const _CategoryTile({ + required this.category, + required this.icon, + required this.onTap, + }); + + final ContentCategory category; + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + key: Key('discover-category-${category.id}'), + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: AppColors.blueSoft.withValues(alpha: 0.16), + shape: BoxShape.circle, + ), + child: Icon(icon, color: AppColors.blueSoft, size: 16), + ), + const SizedBox(height: 5), + Text( + category.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 9, + fontWeight: FontWeight.w600, + height: 1.1, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart new file mode 100644 index 0000000..1d20061 --- /dev/null +++ b/lib/pages/discover/widgets/discover_header.dart @@ -0,0 +1,134 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 剧场页页头:品牌、搜索与个人页入口。 +class DiscoverHeader extends StatelessWidget { + const DiscoverHeader({ + super.key, + required this.onSearch, + required this.onProfile, + }); + + final VoidCallback onSearch; + final VoidCallback onProfile; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SizedBox( + height: 46, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + _brand(l10n), + const Spacer(), + _HeaderIcon( + key: const Key('discover-search-button'), + icon: Icons.search_rounded, + tooltip: l10n.search, + onTap: onSearch, + ), + const SizedBox(width: 8), + _profile(l10n), + ], + ), + ), + ); + } + + Widget _brand(AppLocalizations l10n) { + return Row( + children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, + ), + ), + const SizedBox(width: 8), + Text( + l10n.discoverTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ], + ); + } + + Widget _profile(AppLocalizations l10n) { + return Semantics( + button: true, + label: l10n.myTab, + child: InkWell( + key: const Key('discover-profile'), + customBorder: const CircleBorder(), + onTap: onProfile, + child: Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 18, + ), + ), + ), + ); + } +} + +class _HeaderIcon extends StatelessWidget { + const _HeaderIcon({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 18), + ), + ); + } +} diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart new file mode 100644 index 0000000..de549af --- /dev/null +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -0,0 +1,68 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 剧场顶部搜索框。 +class DiscoverSearchField extends StatelessWidget { + const DiscoverSearchField({ + super.key, + required this.controller, + required this.focusNode, + required this.onChanged, + required this.loading, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + final bool loading; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: SizedBox( + height: 36, + child: TextField( + key: const Key('discover-search'), + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + onTapOutside: (_) => focusNode.unfocus(), + textInputAction: TextInputAction.search, + style: const TextStyle(color: AppColors.onSurface, fontSize: 13), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: AppColors.input, + hintText: AppLocalizations.of(context)!.searchDiscover, + hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.subtle, + size: 17, + ), + suffixIcon: loading + ? const Padding( + padding: EdgeInsets.all(11), + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: const BorderSide(color: AppColors.blue, width: 1), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/discover/widgets/discover_search_results.dart b/lib/pages/discover/widgets/discover_search_results.dart new file mode 100644 index 0000000..dfaa0d5 --- /dev/null +++ b/lib/pages/discover/widgets/discover_search_results.dart @@ -0,0 +1,65 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/error_text.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../l10n/app_localizations.dart'; +import 'drama_grid.dart'; + +/// 剧场搜索结果:加载、失败与空结果都留在同一页内。 +class DiscoverSearchResults extends StatelessWidget { + const DiscoverSearchResults({ + super.key, + required this.loading, + required this.error, + required this.results, + required this.onRetry, + required this.onOpen, + }); + + final bool loading; + final String? error; + final List results; + final VoidCallback onRetry; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.only(top: 12), + child: _content(l10n), + ); + } + + Widget _content(AppLocalizations l10n) { + if (loading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 80), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + final message = error; + if (message != null) { + return EmptyState( + key: const Key('discover-search-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, message, l10n.searchFailed), + actionLabel: l10n.retry, + actionKey: const Key('discover-search-retry'), + onAction: onRetry, + ); + } + if (results.isEmpty) { + return EmptyState( + key: const Key('discover-search-empty'), + icon: Icons.search_off_rounded, + title: l10n.noSearchResults, + subtitle: l10n.emptyHint, + ); + } + return DramaGrid(l10n: l10n, dramas: results, onOpen: onOpen); + } +} diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart new file mode 100644 index 0000000..7974b5d --- /dev/null +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -0,0 +1,187 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/config/app_config.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const int _columns = 2; +const double _columnGap = 10; +const double _cardGap = 10; + +/// 剧场/分类页共用的双列剧目网格。 +class DramaGrid extends StatelessWidget { + const DramaGrid({ + super.key, + required this.l10n, + required this.dramas, + required this.onOpen, + }); + + final AppLocalizations l10n; + final List dramas; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + return Padding( + key: const Key('home-all-dramas-section'), + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + key: const Key('home-all-dramas-grid'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _column(0)), + const SizedBox(width: _columnGap), + Expanded(child: _column(1)), + ], + ), + ); + } + + Widget _column(int start) { + final children = []; + for (var index = start; index < dramas.length; index += _columns) { + final drama = dramas[index]; + children.add( + DramaGridCard(l10n: l10n, drama: drama, onTap: () => onOpen(drama)), + ); + if (index + _columns < dramas.length) { + children.add(const SizedBox(height: _cardGap)); + } + } + return Column(children: children); + } +} + +/// 双列网格中的剧目卡片。 +class DramaGridCard extends StatelessWidget { + const DramaGridCard({ + super.key, + required this.l10n, + required this.drama, + required this.onTap, + }); + + final AppLocalizations l10n; + final Drama drama; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + key: Key('home-drama-card-${drama.id}'), + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _cover(), + const SizedBox(height: 5), + _title(), + const SizedBox(height: 2), + _genre(), + ], + ), + ); + } + + Widget _cover() { + final free = + drama.freeEpisodes > 0 || + drama.episodes.any((episode) => episode.isFree); + return ClipRRect( + key: Key('home-drama-cover-${drama.id}'), + borderRadius: BorderRadius.circular(10), + child: Stack( + children: [ + _DiscoverCoverImage(path: drama.posterUrl), + if (free) Positioned(top: 6, left: 6, child: _freeBadge()), + ], + ), + ); + } + + Widget _freeBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + l10n.free, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 8, + fontWeight: FontWeight.w800, + ), + ), + ); + } + + Widget _title() { + return Text( + drama.title, + key: Key('home-drama-title-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ); + } + + Widget _genre() { + final genre = drama.genre.isEmpty ? drama.category : drama.genre; + return Text( + genre.isEmpty ? l10n.videoPending : genre, + key: Key('home-drama-genre-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ); + } +} + +class _DiscoverCoverImage extends StatelessWidget { + const _DiscoverCoverImage({required this.path}); + + final String path; + + @override + Widget build(BuildContext context) { + final url = AppConfig.mediaUrl(path); + if (url.isEmpty) return _placeholder(); + return Image.network( + url, + width: double.infinity, + fit: BoxFit.fitWidth, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return _placeholder(); + }, + errorBuilder: (_, _, _) => _placeholder(), + ); + } + + Widget _placeholder() { + return AspectRatio( + aspectRatio: 3 / 4, + child: ColoredBox( + color: AppColors.card, + child: const Center( + child: Icon( + Icons.image_not_supported_outlined, + color: AppColors.muted, + size: 32, + ), + ), + ), + ); + } +} diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart deleted file mode 100644 index f1ca78a..0000000 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ /dev/null @@ -1,258 +0,0 @@ -// Author: fruit.com.cn - -part of '../discover_view.dart'; - -class _HomeDramaGrid extends StatelessWidget { - const _HomeDramaGrid({ - required this.l10n, - required this.categories, - required this.dramas, - required this.searching, - required this.searchLoading, - required this.searchError, - required this.onRetrySearch, - required this.onOpen, - }); - - final AppLocalizations l10n; - final CategoryController categories; - final List dramas; - final bool searching; - final bool searchLoading; - final String? searchError; - final VoidCallback onRetrySearch; - final ValueChanged onOpen; - - @override - Widget build(BuildContext context) { - final loading = _loadingState(); - if (loading != null) return loading; - final search = _searchState(); - if (search != null) return search; - if (dramas.isEmpty) return _emptyState(); - return SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter(child: _masonry(dramas)), - const SliverToBoxAdapter(child: SizedBox(height: 24)), - ], - ); - } - - Widget? _loadingState() { - if (categories.loading && categories.feed.sections.isEmpty) { - return const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: CircularProgressIndicator(strokeWidth: 2)), - ); - } - if (categories.error == null) return null; - return _fill( - EmptyState( - key: const Key('home-categories-error'), - icon: Icons.cloud_off_rounded, - title: l10n.categoriesError, - subtitle: l10n.emptyHint, - actionLabel: l10n.retry, - onAction: categories.load, - ), - ); - } - - Widget? _searchState() { - if (searching && searchLoading) { - return const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: CircularProgressIndicator( - key: Key('discover-search-loading'), - strokeWidth: 2, - ), - ), - ); - } - if (!searching || searchError == null) return null; - return _fill( - EmptyState( - key: const Key('discover-search-error'), - icon: Icons.cloud_off_rounded, - title: errorText(l10n, searchError, l10n.searchFailed), - actionLabel: l10n.retry, - actionKey: const Key('discover-search-retry'), - onAction: onRetrySearch, - ), - ); - } - - Widget _emptyState() { - return _fill( - EmptyState( - key: const Key('home-all-dramas-empty'), - icon: searching - ? Icons.search_off_rounded - : Icons.movie_filter_outlined, - title: searching - ? l10n.noSearchResults - : categories.selectedSectionMissing - ? l10n.noCategoryDramas - : l10n.noSearchResults, - subtitle: l10n.emptyHint, - ), - ); - } - - Widget _masonry(List items) { - return Padding( - key: const Key('home-all-dramas-section'), - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - key: const Key('home-all-dramas-grid'), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: _column(items, 0)), - const SizedBox(width: 10), - Expanded(child: _column(items, 1)), - ], - ), - ); - } - - Widget _column(List items, int start) { - final children = []; - for (var index = start; index < items.length; index += 2) { - children.add( - _HomeDramaCard( - l10n: l10n, - drama: items[index], - onTap: () => onOpen(items[index]), - ), - ); - if (index + 2 < items.length) { - children.add(const SizedBox(height: 10)); - } - } - return Column(children: children); - } - - Widget _fill(Widget child) => - SliverFillRemaining(hasScrollBody: false, child: child); -} - -class _HomeDramaCard extends StatelessWidget { - const _HomeDramaCard({ - required this.l10n, - required this.drama, - required this.onTap, - }); - - final AppLocalizations l10n; - final Drama drama; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final genre = drama.genre.isEmpty ? drama.category : drama.genre; - final free = - drama.freeEpisodes > 0 || - drama.episodes.any((episode) => episode.isFree); - return InkWell( - key: Key('home-drama-card-${drama.id}'), - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - key: Key('home-drama-cover-${drama.id}'), - borderRadius: BorderRadius.circular(10), - child: Stack( - children: [ - _DiscoverCoverImage(path: drama.posterUrl), - if (free) - Positioned( - top: 6, - left: 6, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - l10n.free, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 8, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 5), - Text( - drama.title, - key: Key('home-drama-title-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 11, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - genre.isEmpty ? l10n.videoPending : genre, - key: Key('home-drama-genre-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), - ), - ], - ), - ); - } -} - -class _DiscoverCoverImage extends StatelessWidget { - const _DiscoverCoverImage({required this.path}); - - final String path; - - @override - Widget build(BuildContext context) { - final url = AppConfig.mediaUrl(path); - if (url.isEmpty) return _placeholder(); - return Image.network( - url, - width: double.infinity, - fit: BoxFit.fitWidth, - loadingBuilder: (context, child, progress) { - if (progress == null) return child; - return _placeholder(); - }, - errorBuilder: (_, _, _) => _placeholder(), - ); - } - - Widget _placeholder() { - return AspectRatio( - aspectRatio: 3 / 4, - child: ColoredBox( - color: AppColors.card, - child: const Center( - child: Icon( - Icons.image_not_supported_outlined, - color: AppColors.muted, - size: 32, - ), - ), - ), - ); - } -} diff --git a/lib/pages/discover/widgets/ranking_section.dart b/lib/pages/discover/widgets/ranking_section.dart new file mode 100644 index 0000000..9dbe2ef --- /dev/null +++ b/lib/pages/discover/widgets/ranking_section.dart @@ -0,0 +1,268 @@ +// 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/widgets/empty_state.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; +import '../ranking_logic.dart'; + +const int _topLimit = 5; +const double _rowHeight = 68; +const double _posterWidth = 40; +const double _posterHeight = 54; +const double _badgeSize = 24; + +/// 发现页内嵌榜单区块:热播/新剧切换 + 前 5 名。 +class RankingSection extends StatefulWidget { + const RankingSection({ + super.key, + required this.sections, + required this.fallback, + required this.onOpen, + }); + + final List sections; + final List fallback; + final ValueChanged onOpen; + + @override + State createState() => _RankingSectionState(); +} + +class _RankingSectionState extends State { + late final RankingLogic logic = RankingLogic( + sections: widget.sections, + fallback: widget.fallback, + ); + + @override + void didUpdateWidget(covariant RankingSection 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, _) => _content(context), + ); + + Widget _content(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final entries = logic.entries.take(_topLimit).toList(); + return Container( + key: const Key('discover-ranking-section'), + margin: const EdgeInsets.fromLTRB(14, 16, 14, 8), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + ), + child: Column( + children: [ + _header(l10n), + const SizedBox(height: 8), + if (entries.isEmpty) + EmptyState( + key: const Key('ranking-empty'), + icon: Icons.leaderboard_outlined, + title: l10n.rankingEmptyTitle, + subtitle: l10n.rankingEmptyBody, + compact: true, + ) + else + for (final entry in entries) _row(entry, l10n), + ], + ), + ); + } + + Widget _header(AppLocalizations l10n) { + return Row( + children: [ + const Icon( + Icons.local_fire_department_rounded, + color: AppColors.blue, + size: 18, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + l10n.rankingTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ), + _boardChip( + key: const Key('ranking-tab-hot'), + label: l10n.rankingHot, + board: RankingBoard.hot, + ), + const SizedBox(width: 6), + _boardChip( + key: const Key('ranking-tab-new'), + label: l10n.rankingNew, + board: RankingBoard.newReleases, + ), + ], + ); + } + + Widget _boardChip({ + required Key key, + required String label, + required RankingBoard board, + }) { + final selected = logic.board == board; + return Material( + key: key, + color: selected ? AppColors.blue : AppColors.raised, + borderRadius: BorderRadius.circular(999), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => logic.select(board), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + child: Text( + label, + style: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: 9, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } + + Widget _row(RankingEntry entry, AppLocalizations l10n) { + return _RankRow( + entry: entry, + subtitle: _subtitle(entry.drama, l10n), + onTap: () => widget.onOpen(entry.drama), + ); + } + + 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'; + } +} + +class _RankRow extends StatelessWidget { + const _RankRow({ + required this.entry, + required this.subtitle, + required this.onTap, + }); + + final RankingEntry entry; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final drama = entry.drama; + return Material( + key: Key('ranking-entry-${drama.id}'), + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: SizedBox(height: _rowHeight, child: _content(drama)), + ), + ); + } + + Widget _content(Drama drama) { + return Row( + children: [ + _rankBadge(entry.rank), + const SizedBox(width: 8), + ClipRRect( + borderRadius: BorderRadius.circular(7), + child: RemoteImage( + drama.posterUrl, + width: _posterWidth, + height: _posterHeight, + ), + ), + const SizedBox(width: 10), + Expanded(child: _text(drama)), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 18, + ), + ], + ); + } + + Widget _text(Drama drama) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.subtle, fontSize: 9), + ), + ], + ); + } + + Widget _rankBadge(int rank) { + final top = rank <= 3; + return Container( + width: _badgeSize, + height: _badgeSize, + alignment: Alignment.center, + decoration: BoxDecoration( + color: top ? AppColors.gold.withValues(alpha: 0.16) : AppColors.raised, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + '$rank', + style: TextStyle( + color: top ? AppColors.gold : AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w900, + ), + ), + ); + } +} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index b3b5781..8834669 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -7,12 +7,12 @@ 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 '../discover/discover_types.dart'; import '../drama_detail/drama_detail_view.dart'; -import '../mine/mine_logic.dart'; +import '../favorites/favorites_page.dart'; +import '../mine/mine_types.dart'; import '../player/player_view.dart'; +import '../watch_records/watch_records_page.dart'; class HomeLogic extends ChangeNotifier { HomeLogic(List dramas) : currentDrama = dramas.first; @@ -21,34 +21,21 @@ class HomeLogic extends ChangeNotifier { Drama currentDrama; int tab = 0; int searchRequest = 0; - int sectionRequest = 0; int resumeRequest = 0; int backRequest = 0; - int mySection = 0; bool drawerOpen = false; bool detailOpen = false; bool videoImmersive = false; - bool profile = false; bool _disposed = false; void notify() { if (!_disposed) notifyListeners(); } - void select( - int nextTab, { - bool search = false, - bool watchlist = false, - bool resume = false, - int? mySection, - }) { + void select(int nextTab, {bool search = false, bool resume = false}) { FocusManager.instance.primaryFocus?.unfocus(); tab = nextTab; if (search) searchRequest++; - if (watchlist || mySection != null) { - this.mySection = mySection ?? 1; - sectionRequest++; - } if (resume) resumeRequest++; notify(); } @@ -59,11 +46,6 @@ class HomeLogic extends ChangeNotifier { notify(); } - void selectFromMenu(int nextTab, {int? mySection}) { - drawerOpen = false; - select(nextTab, mySection: mySection); - } - Future openCurrentSeries(BuildContext context) async { detailOpen = true; notify(); @@ -87,20 +69,6 @@ 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; @@ -122,31 +90,29 @@ class HomeLogic extends ChangeNotifier { ); } - void openProfile() { - profile = true; - select(2, mySection: 0); - } + void openProfile() => select(2); - void openLibrary({int section = 1}) { - profile = false; - select(2, mySection: section); - } - - void discoverAction(BuildContext context, DiscoverPageAction action) { + void discoverAction(DiscoverPageAction action) { switch (action) { case DiscoverPageAction.home: select(0); case DiscoverPageAction.my: select(2); - case DiscoverPageAction.watchlist: - openLibrary(); - case DiscoverPageAction.resume: - select(0, resume: true); - case DiscoverPageAction.detail: - openDrama(context, currentDrama); } } + void openFavorites(BuildContext context) { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const FavoritesPage())); + } + + void openHistory(BuildContext context) { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const WatchRecordsPage())); + } + void myAction(MyPageAction action) { switch (action) { case MyPageAction.home: diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 52542df..5fadb0b 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -6,8 +6,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.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'; import '../../core/data/services/wallet_controller.dart'; @@ -15,10 +13,11 @@ import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; 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 '../../l10n/app_localizations.dart'; +import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; +import '../mine/mine_types.dart'; import '../splash/splash_page.dart'; import '../mine/mine_view.dart'; import '../player/player_view.dart'; @@ -74,7 +73,6 @@ class HomePage extends StatelessWidget { return _HomeTabs( key: ValueKey(catalog.dramas.first.id), dramas: catalog.dramas, - banners: catalog.banners, ); } } @@ -99,14 +97,9 @@ 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.banners, - }); + const _HomeTabs({super.key, required this.dramas}); final List dramas; - final List banners; @override State<_HomeTabs> createState() => _HomeTabsState(); @@ -137,8 +130,6 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _scaffold(BuildContext context) { final l10n = AppLocalizations.of(context)!; - // 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关) - final signedIn = context.watch().isSignedIn; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { @@ -152,111 +143,113 @@ class _HomeTabsState extends State<_HomeTabs> { child: Scaffold( key: logic.scaffoldKey, backgroundColor: AppColors.ink, - body: IndexedStack( - index: logic.tab, - children: [ - PlayerPage( - drama: widget.dramas.first, - feedDramas: widget.dramas, - onDramaChanged: logic.onDramaChanged, - initialIndex: 0, - homeMode: true, - embedded: true, - active: logic.tab == 0 && !logic.drawerOpen && !logic.detailOpen, - resumeRequest: logic.resumeRequest, - backRequest: logic.backRequest, - onDiscover: () => logic.select(1), - onMy: logic.openProfile, - onSearch: () => logic.select(1, search: true), - onMenu: () => _openMenu(context), - onImmersiveChanged: logic.onImmersiveChanged, - wallet: context.read(), - downloads: context.read(), - ), - DiscoverPage( - drama: logic.currentDrama, - dramas: widget.dramas, - onDramaSelected: (drama) => logic.playDrama(context, drama), - duration: Duration.zero, - embedded: true, - active: logic.tab == 1, - searchRequest: logic.searchRequest, - signedIn: signedIn, - 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( - drama: logic.currentDrama, - dramas: widget.dramas, - onDramaSelected: (drama) => logic.openDrama(context, drama), - duration: Duration.zero, - initialSection: logic.mySection, - profile: logic.profile, - sectionRequest: logic.sectionRequest, - embedded: true, - active: logic.tab == 2, - onAction: logic.myAction, - signedIn: signedIn, - onSignIn: () => openAuthPage(context), - onSignOut: () => context.read().signOut(), - wallet: context.read(), - ), - ], - ), - bottomNavigationBar: logic.videoImmersive - ? null - : AppBottomNav( - barKey: const Key('home-tabs-bar'), - items: [ - AppBottomNavItem( - tabKey: const Key('tab-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, - label: l10n.homeTab, - selected: logic.tab == 0, - onTap: () => _selectTab(context, 0), - ), - AppBottomNavItem( - tabKey: const Key('tab-discover'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, - selected: logic.tab == 1, - onTap: () => _selectTab(context, 1), - ), - AppBottomNavItem( - tabKey: const Key('tab-my'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, - label: l10n.myTab, - selected: logic.tab == 2, - onTap: () => _selectTab(context, 2, myProfile: true), - ), - ], - ), + body: _body(context), + bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n), ), ); } - void _selectTab(BuildContext context, int tab, {bool myProfile = false}) { + Widget _body(BuildContext context) { + return IndexedStack( + index: logic.tab, + children: [_player(context), _discover(context), _my(context)], + ); + } + + Widget _player(BuildContext context) { + return PlayerPage( + drama: widget.dramas.first, + feedDramas: widget.dramas, + onDramaChanged: logic.onDramaChanged, + initialIndex: 0, + homeMode: true, + embedded: true, + active: logic.tab == 0 && !logic.drawerOpen && !logic.detailOpen, + resumeRequest: logic.resumeRequest, + backRequest: logic.backRequest, + onDiscover: () => logic.select(1), + onMy: logic.openProfile, + onSearch: () => logic.select(1, search: true), + onMenu: () => _openMenu(context), + onImmersiveChanged: logic.onImmersiveChanged, + wallet: context.read(), + downloads: context.read(), + ); + } + + Widget _discover(BuildContext context) { + return DiscoverPage( + active: logic.tab == 1, + searchRequest: logic.searchRequest, + callbacks: DiscoverPageCallbacks( + onAction: logic.discoverAction, + onDramaSelected: (drama) => logic.playDrama(context, drama), + onProfile: logic.openProfile, + ), + ); + } + + Widget _my(BuildContext context) { + return MyPage( + active: logic.tab == 2, + callbacks: MyPageCallbacks( + onAction: logic.myAction, + onDramaSelected: (drama) => logic.openDrama(context, drama), + ), + ); + } + + Widget _nav(BuildContext context, AppLocalizations l10n) { + return AppBottomNav( + barKey: const Key('home-tabs-bar'), + items: [ + _navItem(context, l10n, 0), + _navItem(context, l10n, 1), + _navItem(context, l10n, 2), + ], + ); + } + + AppBottomNavItem _navItem( + BuildContext context, + AppLocalizations l10n, + int index, + ) { + final selected = logic.tab == index; + return switch (index) { + 0 => AppBottomNavItem( + tabKey: const Key('tab-home'), + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, + label: l10n.homeTab, + selected: selected, + onTap: () => _selectTab(context, index), + ), + 1 => AppBottomNavItem( + tabKey: const Key('tab-discover'), + icon: Icons.explore_outlined, + selectedIcon: Icons.explore_rounded, + label: l10n.discoverTab, + selected: selected, + onTap: () => _selectTab(context, index), + ), + _ => AppBottomNavItem( + tabKey: const Key('tab-my'), + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, + label: l10n.myTab, + selected: selected, + onTap: () => _selectTab(context, index), + ), + }; + } + + void _selectTab(BuildContext context, int tab) { final same = logic.tab == tab; if (!same) { unawaited(context.read().selectionClick()); } - if (tab == 2) { - myProfile ? logic.openProfile() : logic.openLibrary(); - } else { - logic.select(tab); - } + logic.select(tab); } Future _openMenu(BuildContext context) async { @@ -269,10 +262,9 @@ class _HomeTabsState extends State<_HomeTabs> { builder: (_) => HomeMenuSheet( drama: logic.currentDrama, onOpenSeries: () => logic.openCurrentSeries(context), - onDiscover: () => logic.selectFromMenu(1), - onHistory: () => logic.openLibrary(section: 0), - onWatchlist: logic.openLibrary, - onLiked: () => logic.selectFromMenu(2, mySection: 2), + onDiscover: () => logic.select(1), + onHistory: () => logic.openHistory(context), + onWatchlist: () => logic.openFavorites(context), ), ); logic.setMenuOpen(false); diff --git a/lib/pages/home/widgets/home_menu_sheet.dart b/lib/pages/home/widgets/home_menu_sheet.dart index e870bd4..0a255b3 100644 --- a/lib/pages/home/widgets/home_menu_sheet.dart +++ b/lib/pages/home/widgets/home_menu_sheet.dart @@ -1,16 +1,14 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../../../core/config/locale_controller.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -/// Compact navigation menu opened from the preview home header. +/// 播放页快捷菜单:当前剧、发现、追剧与历史。 class HomeMenuSheet extends StatelessWidget { const HomeMenuSheet({ super.key, @@ -19,7 +17,6 @@ class HomeMenuSheet extends StatelessWidget { required this.onDiscover, required this.onHistory, required this.onWatchlist, - required this.onLiked, }); final Drama drama; @@ -27,12 +24,10 @@ class HomeMenuSheet extends StatelessWidget { final VoidCallback onDiscover; final VoidCallback onHistory; final VoidCallback onWatchlist; - final VoidCallback onLiked; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final languageCode = Localizations.localeOf(context).languageCode; void closeThen(VoidCallback action) { Navigator.pop(context); action(); @@ -45,160 +40,147 @@ class HomeMenuSheet extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.cardAlt, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: RemoteImage(drama.posterUrl, width: 64, height: 86), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 4), - Text( - l10n.episodeCount(drama.episodeCount), - style: const TextStyle( - color: AppColors.muted, - fontSize: 9, - ), - ), - const SizedBox(height: 8), - SizedBox( - height: 34, - child: FilledButton.icon( - key: const Key('drawer-open-series'), - onPressed: () => closeThen(onOpenSeries), - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(9), - ), - textStyle: const TextStyle(fontSize: 11), - ), - icon: const Icon( - Icons.play_arrow_rounded, - size: 15, - ), - label: Text(l10n.exploreSeries), - ), - ), - ], - ), - ), - ], - ), + _DramaSummary( + drama: drama, + l10n: l10n, + onOpenSeries: () => closeThen(onOpenSeries), ), const SizedBox(height: 14), - Text( - l10n.quickAccess, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w800, - ), + _quickAccess(l10n, closeThen), + ], + ), + ), + ); + } + + Widget _quickAccess( + AppLocalizations l10n, + void Function(VoidCallback) closeThen, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.quickAccess, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + _item( + key: const Key('drawer-discover'), + icon: Icons.explore_outlined, + label: l10n.discoverTab, + onTap: () => closeThen(onDiscover), ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _MenuTile( - key: const Key('drawer-discover'), - icon: Icons.explore_outlined, - label: l10n.discoverTab, - onTap: () => closeThen(onDiscover), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _MenuTile( - key: const Key('drawer-history'), - icon: Icons.history_rounded, - label: l10n.history, - onTap: () => closeThen(onHistory), - ), - ), - ], + _item( + key: const Key('drawer-watchlist'), + icon: Icons.bookmark_border_rounded, + label: l10n.watchlist, + onTap: () => closeThen(onWatchlist), ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _MenuTile( - key: const Key('drawer-watchlist'), - icon: Icons.bookmark_border_rounded, - label: l10n.watchlist, - onTap: () => closeThen(onWatchlist), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _MenuTile( - key: const Key('drawer-liked'), - icon: Icons.favorite_border_rounded, - label: l10n.likedTab, - onTap: () => closeThen(onLiked), - ), - ), - ], - ), - const SizedBox(height: 14), - Text( - l10n.appLanguage, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final (code, label) in [ - ('en', l10n.english), - ('zh', l10n.simplifiedChinese), - ('id', l10n.bahasaIndonesia), - ]) - ChoiceChip( - label: Text(label), - selected: languageCode == code, - onSelected: (_) => - context.read().select(code), - backgroundColor: AppColors.input, - selectedColor: AppColors.blue, - labelStyle: TextStyle( - color: languageCode == code - ? AppColors.onPrimary - : AppColors.muted, - fontSize: 10, - ), - side: BorderSide.none, - showCheckmark: false, - ), - ], + _item( + key: const Key('drawer-history'), + icon: Icons.history_rounded, + label: l10n.history, + onTap: () => closeThen(onHistory), ), ], ), + ], + ); + } + + Widget _item({ + required Key key, + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: _MenuTile(key: key, icon: icon, label: label, onTap: onTap), + ), + ); + } +} + +class _DramaSummary extends StatelessWidget { + const _DramaSummary({ + required this.drama, + required this.l10n, + required this.onOpenSeries, + }); + + final Drama drama; + final AppLocalizations l10n; + final VoidCallback onOpenSeries; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.cardAlt, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: RemoteImage(drama.posterUrl, width: 64, height: 86), + ), + const SizedBox(width: 10), + Expanded(child: _details()), + ], + ), + ); + } + + Widget _details() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + l10n.episodeCount(drama.episodeCount), + style: const TextStyle(color: AppColors.muted, fontSize: 9), + ), + const SizedBox(height: 8), + _button(), + ], + ); + } + + Widget _button() { + return SizedBox( + height: 34, + child: FilledButton.icon( + key: const Key('drawer-open-series'), + onPressed: onOpenSeries, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(9)), + textStyle: const TextStyle(fontSize: 11), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 15), + label: Text(l10n.exploreSeries), ), ); } diff --git a/lib/pages/mine/mine_binding.dart b/lib/pages/mine/mine_binding.dart index 6c684ec..83017a5 100644 --- a/lib/pages/mine/mine_binding.dart +++ b/lib/pages/mine/mine_binding.dart @@ -1,17 +1,10 @@ // Author: fruit.com.cn -import '../../core/data/models/drama.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import 'mine_logic.dart'; +/// 组装「我的」页状态。 class MineBinding { - MineLogic create({ - required Drama drama, - required int initialSection, - required WatchHistoryRepository history, - }) => MineLogic( - drama: drama, - initialSection: initialSection, - history: history, - ); + MineLogic create({required WatchHistoryRepository history}) => + MineLogic(history: history); } diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index f6e17fd..b60be77 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -1,59 +1,28 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; -import 'package:provider/provider.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 '../../l10n/app_localizations.dart'; - -enum MyPageAction { home, discover, resume } +import 'mine_types.dart'; +/// 「我的」页状态:最近观看与根导航动作。 class MineLogic extends ChangeNotifier { - MineLogic({ - required this.history, - required this.drama, - required int initialSection, - }) : section = initialSection { - refreshPosition(); + MineLogic({required this.history}) { + refreshRecent(); } final WatchHistoryRepository history; - Drama drama; - late Future position; List recent = const []; bool recentLoading = false; String? recentError; - - /// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始) - bool positionFailed = false; - int section; - int filter = 0; bool _disposed = false; void notify() { if (!_disposed) notifyListeners(); } - void refreshPosition() { - position = history - .resumePointFor(drama.id) - .then((point) { - positionFailed = false; - return point?.position; - }) - .catchError((Object _) { - positionFailed = true; - notify(); - return null; - }); - refreshRecent(); - } - - /// 最近观看:服务端只给 dramaId/位置,剧名封面用已加载的目录卡片按 id 映射。 + /// 最近观看:服务端只给 dramaId/位置,剧名封面用目录卡片按 id 映射。 Future refreshRecent() async { recentLoading = true; recentError = null; @@ -68,34 +37,9 @@ class MineLogic extends ChangeNotifier { } } - void selectSection(int index) { - section = index; - notify(); - } - - void selectFilter(int index) { - filter = index; - notify(); - } - - void syncPage({ - required bool active, - required bool wasActive, - required Drama nextDrama, - required bool dramaChanged, - required int sectionRequest, - required int previousSectionRequest, - required int initialSection, - }) { - if (active && (!wasActive || dramaChanged)) { - drama = nextDrama; - refreshPosition(); - notify(); - } - if (sectionRequest != previousSectionRequest) { - section = initialSection; - notify(); - } + /// 页面重新可见时刷新最近观看,避免停留在旧进度。 + void syncPage({required bool active, required bool wasActive}) { + if (active && !wasActive) refreshRecent(); } void act( @@ -111,44 +55,6 @@ class MineLogic extends ChangeNotifier { } } - Future showLanguage(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.ink, - showDragHandle: true, - isScrollControlled: true, - builder: (sheetContext) => SafeArea( - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile(title: Text(l10n.language)), - for (final (code, label) in [ - ('en', l10n.english), - ('zh', l10n.simplifiedChinese), - ('id', l10n.bahasaIndonesia), - ]) - ListTile( - title: Text(label), - onTap: () { - context.read().select(code); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ), - ), - ); - } - @override void dispose() { _disposed = true; diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart new file mode 100644 index 0000000..192bd47 --- /dev/null +++ b/lib/pages/mine/mine_types.dart @@ -0,0 +1,18 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../../core/data/models/drama.dart'; + +/// 「我的」页与根导航之间的返回动作。 +enum MyPageAction { home, discover, resume } + +/// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。 +class MyPageCallbacks { + const MyPageCallbacks({this.onAction, this.onDramaSelected}); + + final ValueChanged? onAction; + final ValueChanged? onDramaSelected; + + bool get embedded => onAction != null; +} diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 0e2d6cf..92e41bb 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -5,67 +5,42 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.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/data/services/auth_controller.dart'; -import '../../core/data/services/favorite_controller.dart'; -import '../../core/data/services/interaction_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/formatting/error_text.dart'; -import '../../core/formatting/localized_content.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; -import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/remote_image.dart'; +import '../../core/widgets/auth_gate.dart'; import '../../l10n/app_localizations.dart'; -import '../account/account_entry_card.dart'; import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; -import '../notices/notices_page.dart'; +import '../player/player_view.dart'; +import '../settings/settings_page.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; +import 'mine_types.dart'; +import 'widgets/continue_watching_card.dart'; +import 'widgets/mine_header.dart'; +import 'widgets/mine_menu.dart'; +/// 极简「我的」页:续看 + 追剧 / 历史 / 下载 / 钱包 / 会员 / 设置。 class MyPage extends StatefulWidget { const MyPage({ super.key, - required this.drama, - required this.duration, - this.initialSection = 0, - this.profile = false, - this.history, - this.embedded = false, this.active = true, - this.sectionRequest = 0, - this.onAction, - this.signedIn = false, - this.onSignIn, - this.onSignOut, - this.dramas, - this.onDramaSelected, - this.wallet, + this.callbacks = const MyPageCallbacks(), }); - final Drama drama; - final Duration duration; - final int initialSection; - final bool profile; - final WatchHistoryRepository? history; - final bool embedded; final bool active; - final int sectionRequest; - final ValueChanged? onAction; - final bool signedIn; - final VoidCallback? onSignIn; - final VoidCallback? onSignOut; - final List? dramas; - final ValueChanged? onDramaSelected; - final WalletController? wallet; + final MyPageCallbacks callbacks; @override State createState() => _MyPageState(); @@ -74,28 +49,20 @@ class MyPage extends StatefulWidget { class _MyPageState extends State { late final MineLogic logic; + bool get embedded => widget.callbacks.embedded; + @override void initState() { super.initState(); logic = MineBinding().create( - drama: widget.drama, - initialSection: widget.initialSection, - history: widget.history ?? context.read(), + history: context.read(), ); } @override void didUpdateWidget(covariant MyPage oldWidget) { super.didUpdateWidget(oldWidget); - logic.syncPage( - active: widget.active, - wasActive: oldWidget.active, - nextDrama: widget.drama, - dramaChanged: oldWidget.drama.id != widget.drama.id, - sectionRequest: widget.sectionRequest, - previousSectionRequest: oldWidget.sectionRequest, - initialSection: widget.initialSection, - ); + logic.syncPage(active: widget.active, wasActive: oldWidget.active); } @override @@ -115,258 +82,53 @@ class _MyPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final content = SafeArea( - bottom: !widget.embedded, + bottom: !embedded, child: Column( children: [ _topBar(l10n), - Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(14, 2, 14, 18), - children: [ - _userRow(l10n), - const SizedBox(height: 12), - _wallet(l10n), - if (_continueDrama != null) ...[ - const SizedBox(height: 12), - _continueCard(l10n, _continueDrama!, _continueRecord!), - ], - const SizedBox(height: 14), - _library(l10n), - const SizedBox(height: 14), - _languageRow(l10n), - if (widget.signedIn) ...[ - const SizedBox(height: 12), - const AccountEntryCard(), - ], - const SizedBox(height: 12), - _menuCard(l10n), - ], - ), - ), - if (!widget.embedded) _bottomNav(l10n), + Expanded(child: _body(context, l10n)), + if (!embedded) _bottomNav(l10n), ], ), ); return Theme( data: AppTheme.dark(), - child: widget.embedded + child: embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), ); } - Widget _topBar(AppLocalizations l10n) => SizedBox( - height: 46, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - children: [ - Container( - width: 24, - height: 24, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(7), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 16, - ), - ), - const SizedBox(width: 8), - Text( - l10n.myTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - const Spacer(), - _RoundHeaderButton( - key: const Key('mine-search-button'), - tooltip: l10n.search, - icon: Icons.search_rounded, - onTap: () => logic.act( - context, - MyPageAction.discover, - embedded: widget.embedded, - onAction: widget.onAction, - ), - ), - const SizedBox(width: 8), - Container( - key: const Key('mine-avatar'), - width: 36, - height: 36, - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF6A5CFF), AppColors.blue], - ), - ), - child: const Icon( - Icons.person_rounded, - color: AppColors.onSurface, - size: 18, - ), - ), - ], - ), - ), - ); - - Widget _userRow(AppLocalizations l10n) { - final user = widget.signedIn ? context.watch().user : null; - final name = widget.signedIn - ? ((user?.nickname.isNotEmpty ?? false) - ? user!.nickname - : l10n.localViewer) - : l10n.localViewer; - final subtitle = widget.signedIn - ? ((user?.uid.isNotEmpty ?? false) - ? 'ID: ${user!.uid}' - : l10n.myAccount) - : l10n.logInHint; - return Row( - key: const Key('mine-user-row'), - children: [ - Container( - width: 48, - height: 48, - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF6A5CFF), AppColors.blue], - ), - ), - child: const Icon( - Icons.person_rounded, - color: AppColors.onSurface, - size: 22, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10), - ), - ], - ), - ), - if (widget.signedIn) - TextButton( - onPressed: widget.onSignOut, - style: TextButton.styleFrom( - foregroundColor: AppColors.muted, - backgroundColor: const Color(0xFF24252A), - minimumSize: const Size(52, 30), - padding: const EdgeInsets.symmetric(horizontal: 10), - shape: const StadiumBorder(), - textStyle: const TextStyle(fontSize: 10), - ), - child: Text(l10n.signOut), - ) - else - TextButton( - key: const Key('mine-sign-in'), - onPressed: widget.onSignIn, - style: TextButton.styleFrom( - foregroundColor: AppColors.cream, - backgroundColor: const Color(0xFF24252A), - minimumSize: const Size(64, 30), - padding: const EdgeInsets.symmetric(horizontal: 10), - shape: const StadiumBorder(), - textStyle: const TextStyle(fontSize: 10), - ), - child: Text('${l10n.logIn}/${l10n.signUp}'), - ), - ], - ); - } - - 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: () => _openWallet(context), - borderRadius: BorderRadius.circular(13), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.goldCard, - borderRadius: BorderRadius.circular(13), - ), + Widget _topBar(AppLocalizations l10n) { + return SizedBox( + height: 46, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ - const Icon( - Icons.monetization_on_rounded, - color: AppColors.gold, - size: 20, - ), - const SizedBox(width: 9), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.coins, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 12, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - widget.signedIn ? value : l10n.logInHint, - style: const TextStyle( - color: AppColors.accent, - fontSize: 9, - ), - ), - ], + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, ), ), - const Icon( - Icons.chevron_right_rounded, - color: Color(0xFF666666), - size: 16, + const SizedBox(width: 8), + Text( + l10n.myTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), ), ], ), @@ -374,29 +136,41 @@ class _MyPageState extends State { ); } - void _openVip() { - if (!widget.signedIn) { - widget.onSignIn?.call(); - return; - } - final wallet = widget.wallet; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => VipPage( - onPurchased: wallet == null ? null : () => unawaited(wallet.load()), + Widget _body(BuildContext context, AppLocalizations l10n) { + final auth = context.watch(); + final wallet = context.watch(); + final record = _continueRecord; + final drama = _dramaFor(record); + return ListView( + padding: const EdgeInsets.only(bottom: 18), + children: [ + const MineHeader(), + if (drama != null && record != null) + ContinueWatchingCard( + drama: drama, + record: record, + onResume: () => _resume(drama), + ), + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: MineMenu( + walletValue: _walletValue(l10n, auth, wallet), + onOpen: _openDestination, + ), ), - ), + ], ); } - void _openWallet(BuildContext context) { - if (!widget.signedIn) { - widget.onSignIn?.call(); - return; - } - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const WalletPage())); + String _walletValue( + AppLocalizations l10n, + AuthController auth, + WalletController wallet, + ) { + if (!auth.isSignedIn) return l10n.logInHint; + if (wallet.snapshot == null) return l10n.coins; + return l10n.walletBalanceShort(wallet.balance); } WatchProgress? get _continueRecord { @@ -406,617 +180,101 @@ class _MyPageState extends State { return null; } - Drama? get _continueDrama { - final record = _continueRecord; + Drama? _dramaFor(WatchProgress? record) { if (record == null) return null; - for (final drama in [...?widget.dramas, widget.drama]) { + for (final drama in context.read().dramas) { if (drama.id == record.dramaId) return drama; } return null; } - Widget _continueCard( - AppLocalizations l10n, - Drama drama, - WatchProgress record, - ) { - return Container( - key: const Key('mine-continue-watching'), - padding: const EdgeInsets.all(9), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - _poster(drama, width: 50, height: 64, radius: 7), - const SizedBox(width: 9), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.continueWatching, - style: const TextStyle( - color: AppColors.accent, - fontSize: 9, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - localizedDramaTitle(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 12, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - '${_episodeText(drama, record)} · ' - '${_clock(record.position)} / ${_clock(record.duration)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), - ), - ], - ), - ), - const SizedBox(width: 8), - SizedBox( - width: 60, - height: 28, - child: FilledButton( - onPressed: () => _resume(drama), - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - padding: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(9), - ), - textStyle: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - ), - ), - child: Text(l10n.resume), - ), - ), - ], - ), - ); - } - - Widget _library(AppLocalizations l10n) { - final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < tabs.length; index++) - Padding( - padding: EdgeInsets.only( - right: index == tabs.length - 1 ? 0 : 20, - ), - child: Semantics( - selected: logic.section == index, - button: true, - child: InkWell( - key: Key('mine-library-tab-$index'), - onTap: () => logic.selectSection(index), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - tabs[index], - maxLines: 1, - style: TextStyle( - color: logic.section == index - ? AppColors.blue - : const Color(0xFFE4DBDD), - fontSize: 14, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 4), - AnimatedContainer( - duration: const Duration(milliseconds: 160), - width: 18, - height: 2, - decoration: BoxDecoration( - color: logic.section == index - ? AppColors.blue - : Colors.transparent, - borderRadius: BorderRadius.circular(1), - ), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 12), - if (logic.section == 1 && context.watch().loading) - const Center( - child: Padding( - padding: EdgeInsets.all(12), - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - else if (logic.section == 1) - ..._favoriteCards(l10n) - else if (logic.section == 2) - ..._likedCards(l10n) - else if (logic.recentLoading) - const Center( - child: Padding( - padding: EdgeInsets.all(12), - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - else if (_recentDramas.isEmpty) - EmptyState( - key: const Key('mine-recent-empty'), - icon: Icons.history_rounded, - title: l10n.noStories, - subtitle: l10n.emptyHint, - compact: true, - ) - else - for (final drama in _recentDramas) ...[ - _historyCard(l10n, drama), - const SizedBox(height: 8), - ], - ], - ); - } - - List _favoriteCards(AppLocalizations l10n) { - final favorites = context.watch(); - if (favorites.error != null && favorites.cards.isEmpty) { - return [ - EmptyState( - icon: Icons.cloud_off_rounded, - title: errorText(l10n, favorites.error, l10n.favoritesError), - actionLabel: l10n.retry, - onAction: () => unawaited(favorites.load()), - compact: true, - ), - ]; - } - if (favorites.cards.isEmpty) { - return [ - EmptyState( - key: const Key('mine-saved-empty'), - icon: Icons.bookmark_border_rounded, - title: l10n.noSavedShows, - subtitle: l10n.emptyHint, - compact: true, - ), - ]; - } - return [ - for (final drama in favorites.cards) ...[ - _smallDramaCard(l10n, drama), - const SizedBox(height: 8), - ], - ]; - } - - List _likedCards(AppLocalizations l10n) { - final interactions = context.watch(); - final dramas = [ - ...?widget.dramas, - widget.drama, - ].where((drama) => interactions.isLiked(drama.id)).toList(); - if (dramas.isEmpty) { - return [ - EmptyState( - key: const Key('mine-saved-empty'), - icon: Icons.favorite_border_rounded, - title: l10n.noSavedShows, - subtitle: l10n.emptyHint, - compact: true, - ), - ]; - } - return [ - for (final drama in dramas) ...[ - _smallDramaCard(l10n, drama), - const SizedBox(height: 8), - ], - ]; - } - - Widget _historyCard(AppLocalizations l10n, Drama drama) { - final record = _recordFor(drama.id); - return InkWell( - key: Key('mine-history-card-${drama.id}'), - onTap: () => _resume(drama), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - _poster(drama, width: 48, height: 60, radius: 7), - const SizedBox(width: 9), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - record == null - ? l10n.episodeCount(drama.episodeCount) - : '${_episodeText(drama, record)} · ' - '${_clock(record.position)} / ${_clock(record.duration)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xFFBBBBBB), - fontSize: 9, - ), - ), - ], - ), - ), - Container( - width: 26, - height: 26, - decoration: const BoxDecoration( - color: Color(0xFF313137), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.blue, - size: 15, - ), - ), - ], - ), - ), - ); - } - - Widget _smallDramaCard(AppLocalizations l10n, Drama drama) { - return InkWell( - onTap: () { - if (widget.onDramaSelected != null) { - widget.onDramaSelected!(drama); - } else { - _resume(drama); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - _poster(drama, width: 48, height: 60, radius: 7), - const SizedBox(width: 9), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - l10n.episodeCount(drama.episodeCount), - style: const TextStyle( - color: Color(0xFFBBBBBB), - fontSize: 9, - ), - ), - ], - ), - ), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.muted, - size: 16, - ), - ], - ), - ), - ); - } - - Widget _languageRow(AppLocalizations l10n) { - final locale = context.watch(); - final code = locale.locale?.languageCode ?? 'id'; - final value = switch (code) { - 'zh' => l10n.simplifiedChinese, - 'en' => l10n.english, - _ => l10n.bahasaIndonesia, - }; - return InkWell( - key: const Key('mine-language'), - onTap: () => logic.showLanguage(context), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - const Icon( - Icons.language_rounded, - color: AppColors.muted, - size: 14, - ), - const SizedBox(width: 9), - Expanded( - child: Text( - l10n.appLanguage, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 11, - ), - ), - ), - Text( - value, - style: const TextStyle(color: AppColors.accent, fontSize: 10), - ), - const SizedBox(width: 3), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.accent, - size: 16, - ), - ], - ), - ), - ); - } - - Widget _menuCard(AppLocalizations l10n) { - return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(12), - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - _menuTile( - icon: Icons.notifications_none_rounded, - title: l10n.notices, - key: const Key('mine-notices'), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const NoticesPage()), - ), - ), - _menuTile( - icon: Icons.history_rounded, - title: l10n.watchHistory, - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const WatchRecordsPage()), - ), - ), - _menuTile( - icon: Icons.favorite_outline_rounded, - title: l10n.myFavorites, - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const FavoritesPage()), - ), - ), - _menuTile( - icon: Icons.download_outlined, - title: l10n.downloads, - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const DownloadsPage()), - ), - ), - _menuTile( - key: const Key('mine-vip'), - icon: Icons.workspace_premium_outlined, - title: l10n.vipMembership, - onTap: _openVip, - ), - _menuTile( - icon: Icons.settings_rounded, - title: l10n.settings, - onTap: () => logic.showLanguage(context), - ), - ], - ), - ); - } - - Widget _menuTile({ - required IconData icon, - required String title, - required VoidCallback onTap, - Key? key, - }) { - return ListTile( - key: key, - dense: true, - minTileHeight: 44, - leading: Icon(icon, color: AppColors.secondary, size: 17), - title: Text( - title, - style: const TextStyle(color: AppColors.onSurface, fontSize: 11), - ), - trailing: const Icon( - Icons.chevron_right_rounded, - color: AppColors.muted, - size: 16, - ), - onTap: onTap, - ); - } - - Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( - items: [ - AppBottomNavItem( - tabKey: const Key('mine-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, - label: l10n.homeTab, - onTap: () => logic.act( - context, - MyPageAction.home, - embedded: widget.embedded, - onAction: widget.onAction, - ), - ), - AppBottomNavItem( - tabKey: const Key('mine-nav-discover'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, - onTap: () => logic.act( - context, - MyPageAction.discover, - embedded: widget.embedded, - onAction: widget.onAction, - ), - ), - AppBottomNavItem( - tabKey: const Key('mine-nav-current'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, - label: l10n.myTab, - selected: true, - onTap: () {}, - ), - ], - ); - - List get _recentDramas { - final pool = [...?widget.dramas, widget.drama]; - final result = []; - for (final item in logic.recent) { - for (final drama in pool) { - if (drama.id == item.dramaId && - !result.any((picked) => picked.id == drama.id)) { - result.add(drama); - } - } - } - return result; - } - - WatchProgress? _recordFor(String dramaId) { - for (final record in logic.recent) { - if (record.dramaId == dramaId) return record; - } - return null; - } - - String _episodeText(Drama drama, WatchProgress record) { - for (final episode in drama.episodes) { - if (episode.id == record.episodeId) { - return AppLocalizations.of( - context, - )!.episodeOfTotal(episode.number, drama.episodeCount); - } - } - return AppLocalizations.of(context)!.episodeCount(drama.episodeCount); - } - void _resume(Drama drama) { - if (widget.onDramaSelected != null) { - widget.onDramaSelected!(drama); - } else { - logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ); + final callback = widget.callbacks.onDramaSelected; + if (callback != null) { + callback(drama); + return; } - } - - Widget _poster( - Drama drama, { - required double width, - required double height, - required double radius, - }) => ClipRRect( - borderRadius: BorderRadius.circular(radius), - child: RemoteImage(drama.posterUrl, width: width, height: height), - ); - - String _clock(Duration duration) { - final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; - } -} - -class _RoundHeaderButton extends StatelessWidget { - const _RoundHeaderButton({ - super.key, - required this.tooltip, - required this.icon, - required this.onTap, - }); - - final String tooltip; - final IconData icon; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 36, - height: 36, - child: IconButton( - tooltip: tooltip, - onPressed: onTap, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: const Color(0xFF2C2D33), - foregroundColor: AppColors.onSurface, + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage( + drama: drama, + initialIndex: 0, + wallet: context.read(), + downloads: context.read(), ), - icon: Icon(icon, size: 18), ), ); } + + void _openDestination(MineDestination destination) { + switch (destination) { + case MineDestination.watchlist: + _push(const FavoritesPage()); + case MineDestination.history: + _push(const WatchRecordsPage()); + case MineDestination.downloads: + _push(const DownloadsPage()); + case MineDestination.wallet: + _openProtected(const WalletPage()); + case MineDestination.vip: + _openVip(); + case MineDestination.settings: + _push(const SettingsPage()); + } + } + + void _push(Widget page) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + } + + Future _openProtected(Widget page) async { + if (!context.read().isSignedIn) { + await ensureSignedIn(context); + return; + } + if (mounted) _push(page); + } + + void _openVip() { + final wallet = context.read(); + _openProtected(VipPage(onPurchased: () => unawaited(wallet.load()))); + } + + Widget _bottomNav(AppLocalizations l10n) { + return AppBottomNav( + items: [ + AppBottomNavItem( + tabKey: const Key('mine-nav-home'), + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, + label: l10n.homeTab, + onTap: () => _act(MyPageAction.home), + ), + AppBottomNavItem( + tabKey: const Key('mine-nav-discover'), + icon: Icons.explore_outlined, + selectedIcon: Icons.explore_rounded, + label: l10n.discoverTab, + onTap: () => _act(MyPageAction.discover), + ), + AppBottomNavItem( + tabKey: const Key('mine-nav-current'), + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, + label: l10n.myTab, + selected: true, + onTap: () {}, + ), + ], + ); + } + + void _act(MyPageAction action) { + logic.act( + context, + action, + embedded: embedded, + onAction: widget.callbacks.onAction, + ); + } } diff --git a/lib/pages/mine/widgets/continue_watching_card.dart b/lib/pages/mine/widgets/continue_watching_card.dart new file mode 100644 index 0000000..357f595 --- /dev/null +++ b/lib/pages/mine/widgets/continue_watching_card.dart @@ -0,0 +1,114 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/data/models/watch_progress.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 「我的」页唯一保留的续看入口。 +class ContinueWatchingCard extends StatelessWidget { + const ContinueWatchingCard({ + super.key, + required this.drama, + required this.record, + required this.onResume, + }); + + final Drama drama; + final WatchProgress record; + final VoidCallback onResume; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('mine-continue-watching'), + margin: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(7), + child: RemoteImage(drama.posterUrl, width: 50, height: 64), + ), + const SizedBox(width: 9), + Expanded(child: _info(l10n)), + const SizedBox(width: 8), + SizedBox( + width: 60, + height: 28, + child: FilledButton( + key: const Key('mine-continue-play'), + onPressed: onResume, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + textStyle: const TextStyle(fontSize: 10), + ), + child: Text(l10n.resume), + ), + ), + ], + ), + ); + } + + Widget _info(AppLocalizations l10n) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.continueWatching, + style: const TextStyle( + color: AppColors.accent, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + _progressText(l10n), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ), + ], + ); + } + + String _progressText(AppLocalizations l10n) { + final episode = drama.episodes.where((item) => item.id == record.episodeId); + final label = episode.isEmpty + ? l10n.episodeCount(drama.episodeCount) + : l10n.episodeOfTotal(episode.first.number, drama.episodeCount); + return '$label · ${_clock(record.position)} / ${_clock(record.duration)}'; + } +} + +String _clock(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; +} diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart new file mode 100644 index 0000000..f2559c2 --- /dev/null +++ b/lib/pages/mine/widgets/mine_header.dart @@ -0,0 +1,84 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/services/auth_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 「我的」页顶部身份区,只保留头像、昵称和账号提示。 +class MineHeader extends StatelessWidget { + const MineHeader({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final auth = context.watch(); + final user = auth.user; + final signedIn = auth.isSignedIn; + final name = signedIn && (user?.nickname.isNotEmpty ?? false) + ? user!.nickname + : l10n.localViewer; + final subtitle = signedIn && (user?.uid.isNotEmpty ?? false) + ? 'ID: ${user!.uid}' + : l10n.logInHint; + return Padding( + key: const Key('mine-user-row'), + padding: const EdgeInsets.fromLTRB(14, 14, 14, 6), + child: Row( + children: [ + _avatar(), + const SizedBox(width: 10), + Expanded(child: _labels(name, subtitle)), + ], + ), + ); + } + + Widget _avatar() { + return Container( + key: const Key('mine-avatar'), + width: 48, + height: 48, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 22, + ), + ); + } + + Widget _labels(String name, String subtitle) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10), + ), + ], + ); + } +} diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart new file mode 100644 index 0000000..125e8ad --- /dev/null +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -0,0 +1,113 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 「我的」页六个核心入口。 +enum MineDestination { watchlist, history, downloads, wallet, vip, settings } + +/// 极简功能清单,避免在主页堆叠内容列表。 +class MineMenu extends StatelessWidget { + const MineMenu({super.key, required this.walletValue, required this.onOpen}); + + final String walletValue; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('mine-menu'), + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + ), + child: Column(children: [..._libraryTiles(l10n), ..._accountTiles(l10n)]), + ); + } + + List _libraryTiles(AppLocalizations l10n) { + return [ + _tile( + key: const Key('mine-watchlist'), + icon: Icons.bookmark_border_rounded, + title: l10n.watchlist, + onTap: () => onOpen(MineDestination.watchlist), + ), + _tile( + key: const Key('mine-history'), + icon: Icons.history_rounded, + title: l10n.watchHistory, + onTap: () => onOpen(MineDestination.history), + ), + _tile( + key: const Key('mine-downloads'), + icon: Icons.download_outlined, + title: l10n.downloads, + onTap: () => onOpen(MineDestination.downloads), + ), + ]; + } + + List _accountTiles(AppLocalizations l10n) { + return [ + _tile( + key: const Key('mine-wallet'), + icon: Icons.monetization_on_outlined, + title: l10n.wallet, + subtitle: walletValue, + onTap: () => onOpen(MineDestination.wallet), + ), + _tile( + key: const Key('mine-vip'), + icon: Icons.workspace_premium_outlined, + title: l10n.vipMembership, + onTap: () => onOpen(MineDestination.vip), + ), + _tile( + key: const Key('mine-settings'), + icon: Icons.settings_outlined, + title: l10n.settings, + onTap: () => onOpen(MineDestination.settings), + ), + ]; + } + + Widget _tile({ + required Key key, + required IconData icon, + required String title, + required VoidCallback onTap, + String? subtitle, + }) { + return Material( + color: Colors.transparent, + child: ListTile( + key: key, + dense: true, + minTileHeight: 48, + contentPadding: const EdgeInsets.symmetric(horizontal: 14), + leading: Icon(icon, color: AppColors.blueSoft, size: 19), + title: Text( + title, + style: const TextStyle(color: AppColors.onSurface, fontSize: 12), + ), + subtitle: subtitle == null + ? null + : Text( + subtitle, + style: const TextStyle(color: AppColors.muted, fontSize: 9), + ), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 18, + ), + onTap: onTap, + ), + ); + } +} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index f7f8ed9..5d47ab1 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -23,10 +23,10 @@ import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; -import '../discover/discover_logic.dart'; +import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; import '../drama_detail/drama_detail_view.dart'; -import '../mine/mine_logic.dart'; +import '../mine/mine_types.dart'; import '../mine/mine_view.dart'; import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; @@ -758,7 +758,7 @@ class PlayerLogic extends ChangeNotifier { ); } - Future showMy(BuildContext context, {int initialSection = 0}) async { + Future showMy(BuildContext context) async { if (embedded) { onMy?.call(); return; @@ -767,20 +767,9 @@ class PlayerLogic extends ChangeNotifier { await session.playbackSettled; await saveCurrentProgress(); if (!context.mounted) return; - final port = session.active; - final drama = queue.entryAt(session.currentIndex).drama; - final duration = port is VideoPlayerPort - ? port.controller.value.duration - : Duration.zero; - final action = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MyPage( - drama: drama, - duration: duration, - initialSection: initialSection, - ), - ), - ); + final action = await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const MyPage())); if (!context.mounted) return; if (action == MyPageAction.discover) { await showDiscover(context); @@ -810,33 +799,14 @@ class PlayerLogic extends ChangeNotifier { await session.playbackSettled; await saveCurrentProgress(); if (!context.mounted) return; - final port = session.active; - final duration = port is VideoPlayerPort - ? port.controller.value.duration - : Duration.zero; final action = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DiscoverPage( - drama: pageDrama, - duration: duration, - focusSearch: focusSearch, - ), - ), + MaterialPageRoute(builder: (_) => DiscoverPage(focusSearch: focusSearch)), ); if (!context.mounted) return; - if (action == DiscoverPageAction.detail) { - await openDetail(context); - } else if (action == DiscoverPageAction.my || - action == DiscoverPageAction.watchlist) { - await showMy( - context, - initialSection: action == DiscoverPageAction.watchlist ? 1 : 0, - ); + if (action == DiscoverPageAction.my) { + await showMy(context); } else { session.setRouteVisible(true); - if (action == DiscoverPageAction.resume) { - session.setUserWantsPlay(true); - } } } diff --git a/lib/pages/ranking/ranking_view.dart b/lib/pages/ranking/ranking_view.dart deleted file mode 100644 index 25fcf0d..0000000 --- a/lib/pages/ranking/ranking_view.dart +++ /dev/null @@ -1,443 +0,0 @@ -// 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/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart new file mode 100644 index 0000000..b1f1ec8 --- /dev/null +++ b/lib/pages/settings/settings_page.dart @@ -0,0 +1,91 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/services/auth_controller.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../l10n/app_localizations.dart'; +import '../account/account_entry_card.dart'; +import '../notices/notices_page.dart'; + +/// 设置页:收纳账号、通知与登录态,不提供手动语言切换。 +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final auth = context.watch(); + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('settings-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onSurface, + title: Text(l10n.settings), + ), + body: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 28), + children: [ + if (auth.isSignedIn) ...[ + const AccountEntryCard(), + const SizedBox(height: 12), + ], + _noticesTile(context, l10n), + const SizedBox(height: 24), + _authAction(context, l10n, auth), + ], + ), + ), + ), + ); + } + + Widget _noticesTile(BuildContext context, AppLocalizations l10n) { + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: ListTile( + key: const Key('settings-notices'), + leading: const Icon( + Icons.notifications_none_rounded, + color: AppColors.blueSoft, + ), + title: Text(l10n.notices), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const NoticesPage())), + ), + ); + } + + Widget _authAction( + BuildContext context, + AppLocalizations l10n, + AuthController auth, + ) { + if (auth.isSignedIn) { + return OutlinedButton( + key: const Key('settings-sign-out'), + onPressed: () => unawaited(auth.signOut()), + child: Text(l10n.signOut), + ); + } + return FilledButton( + key: const Key('settings-sign-in'), + onPressed: () => unawaited(openAuthPage(context)), + child: Text(l10n.signIn), + ); + } +} diff --git a/test/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart index d0612ca..8cfad42 100644 --- a/test/account/mine_account_entry_test.dart +++ b/test/account/mine_account_entry_test.dart @@ -26,8 +26,11 @@ void main() { await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-settings'))); + await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-account-card')), findsNothing); + expect(find.byKey(const Key('settings-sign-in')), findsOneWidget); }); testWidgets('should_show_four_account_entries_when_signed_in', ( @@ -46,7 +49,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-account-card')), findsOneWidget); diff --git a/test/category/category_controller_test.dart b/test/category/category_controller_test.dart index 6ade3eb..b67a7eb 100644 --- a/test/category/category_controller_test.dart +++ b/test/category/category_controller_test.dart @@ -49,44 +49,34 @@ void main() { }, ); - test( - 'should_map_category_id_to_discover_section_when_category_is_selected', - () async { - final repository = ScriptedCategoryRepository( - categories: const [ - ContentCategory(id: '7', name: 'Romance'), - ContentCategory(id: '8', name: 'Action'), + test('should_return_category_dramas_when_category_id_exists', () async { + final repository = ScriptedCategoryRepository( + categories: const [ + ContentCategory(id: '7', name: 'Romance'), + ContentCategory(id: '8', name: 'Action'), + ], + feed: HomeFeed( + sections: [ + FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot-1')]), + FeedSection(code: 'new', title: 'New', dramas: [_drama('new-1')]), + FeedSection( + code: 'category-7', + title: 'Romance', + dramas: [_drama('romance-1')], + ), ], - feed: HomeFeed( - sections: [ - FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot-1')]), - FeedSection(code: 'new', title: 'New', dramas: [_drama('new-1')]), - FeedSection( - code: 'category-7', - title: 'Romance', - dramas: [_drama('romance-1')], - ), - ], - ), - ); - final controller = CategoryController(repository); + ), + ); + final controller = CategoryController(repository); - await controller.load(); - controller.selectCategory('7'); + await controller.load(); - expect(controller.selectedCode, 'category-7'); - expect(controller.visibleDramas.map((drama) => drama.id), ['romance-1']); - expect(controller.selectedSectionMissing, isFalse); - - controller.selectCategory('8'); - expect(controller.selectedCode, 'category-8'); - expect(controller.visibleDramas, isEmpty); - expect(controller.selectedSectionMissing, isTrue); - }, - ); + expect(controller.dramasOf('7').map((drama) => drama.id), ['romance-1']); + expect(controller.dramasOf('8'), isEmpty); + }); test( - 'should_keep_hot_and_new_entries_when_discover_feed_provides_them', + 'should_keep_hot_and_new_sections_when_discover_feed_provides_them', () async { final repository = ScriptedCategoryRepository( feed: HomeFeed( @@ -104,15 +94,12 @@ void main() { final controller = CategoryController(repository); await controller.load(); - controller.selectCode(CategoryController.hotCode); - expect(controller.visibleDramas.map((drama) => drama.id), ['hot-1']); - - controller.selectCode(CategoryController.newCode); - expect(controller.visibleDramas.map((drama) => drama.id), ['new-1']); + expect(controller.feed.sections.first.code, 'hot'); + expect(controller.feed.sections[1].dramas.single.id, 'new-1'); }, ); - test('should_dedupe_dramas_when_all_categories_are_selected', () async { + test('should_dedupe_dramas_when_feed_sections_overlap', () async { final repository = ScriptedCategoryRepository( feed: HomeFeed( sections: [ @@ -129,11 +116,7 @@ void main() { await controller.load(); - expect(controller.selectedCode, CategoryController.allCode); - expect(controller.visibleDramas.map((drama) => drama.id), [ - 'same', - 'other', - ]); + expect(controller.feed.dramas.map((drama) => drama.id), ['same', 'other']); }); test('should_clear_error_and_retry_when_initial_load_fails', () async { diff --git a/test/config/app_locale_test.dart b/test/config/app_locale_test.dart new file mode 100644 index 0000000..a73bfb2 --- /dev/null +++ b/test/config/app_locale_test.dart @@ -0,0 +1,19 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/config/app_locale.dart'; + +void main() { + test('should_resolve_supported_device_languages', () { + expect(resolveAppLocale(const Locale('en')), const Locale('en')); + expect(resolveAppLocale(const Locale('zh')), const Locale('zh')); + expect(resolveAppLocale(const Locale('id')), const Locale('id')); + expect(resolveAppLocale(const Locale('in')), const Locale('id')); + }); + + test('should_fallback_to_indonesian_when_device_language_is_unsupported', () { + expect(resolveAppLocale(const Locale('fr')), const Locale('id')); + expect(resolveAppLocale(null), const Locale('id')); + }); +} diff --git a/test/config/locale_controller_test.dart b/test/config/locale_controller_test.dart deleted file mode 100644 index 925dc07..0000000 --- a/test/config/locale_controller_test.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -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(() { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - }); - - test('should_keep_device_locale_when_no_choice_is_stored', () async { - final controller = LocaleController(); - var notifications = 0; - controller.addListener(() => notifications++); - - await controller.load(); - - expect(controller.locale, isNull); - expect(notifications, 1); - }); - - test('should_restore_chosen_language_when_app_restarts', () async { - final first = LocaleController(); - await first.select('zh'); - expect(first.locale, const Locale('zh')); - - final reopened = LocaleController(); - await reopened.load(); - expect(reopened.locale, const Locale('zh')); - }); - - test('should_restore_indonesian_when_selected_and_app_restarts', () async { - final first = LocaleController(); - await first.select('id'); - expect(first.locale, const Locale('id')); - - final reopened = LocaleController(); - await reopened.load(); - expect(reopened.locale, const Locale('id')); - }); - - test('should_fallback_to_indonesian_when_system_language_is_unsupported', () { - expect(resolveAppLocale(null, const Locale('en')), const Locale('en')); - expect(resolveAppLocale(null, const Locale('id')), const Locale('id')); - expect(resolveAppLocale(null, const Locale('fr')), const Locale('id')); - expect(resolveAppLocale(null, const Locale('in')), const Locale('id')); - expect( - resolveAppLocale(const Locale('zh'), const Locale('fr')), - const Locale('zh'), - ); - }); - - test( - 'should_clear_stored_chinese_preference_when_english_is_selected', - () async { - await LocaleController().select('zh'); - - final reopened = LocaleController(); - await reopened.load(); - await reopened.select('en'); - - final third = LocaleController(); - await third.load(); - expect(third.locale, const Locale('en')); - }, - ); - - test('should_clear_locale_when_code_is_unsupported', () async { - final controller = LocaleController(); - await controller.select('zh'); - expect(controller.locale, const Locale('zh')); - - await controller.select('tlh'); - - expect(controller.locale, isNull); - }); - - test( - 'should_resolve_to_device_locale_when_stored_code_is_unsupported', - () async { - await SharedPreferencesAsync().setString('ui.locale', 'fr'); - - final controller = LocaleController(); - await controller.load(); - - expect(controller.locale, isNull); - }, - ); - - test( - 'should_survive_load_and_select_when_preference_store_is_broken', - () async { - SharedPreferencesAsyncPlatform.instance = _BrokenPreferences(); - final controller = LocaleController(); - - await controller.load(); - expect(controller.locale, isNull); - - await controller.select('zh'); - expect(controller.locale, const Locale('zh')); - }, - ); -} - -/// Stands in for a platform whose preference store is unavailable. -base class _BrokenPreferences extends InMemorySharedPreferencesAsync { - _BrokenPreferences() : super.empty(); - - @override - Future getString(String key, SharedPreferencesOptions options) => - Future.error(StateError('preferences unavailable')); - - @override - Future setString( - String key, - String value, - SharedPreferencesOptions options, - ) => Future.error(StateError('preferences unavailable')); -} diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart index 08c02fa..bd84546 100644 --- a/test/discover/discover_logic_test.dart +++ b/test/discover/discover_logic_test.dart @@ -1,255 +1,32 @@ +// Author: fruit.com.cn + import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:fruitshorts/core/data/models/drama.dart'; -import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/discover/discover_logic.dart'; - -import '../support/fake_watch_history.dart'; - -Drama _drama( - String id, { - List numbers = const [1, 2, 3], - String language = '', -}) => Drama( - language: language, - id: id, - title: id, - synopsis: 'synopsis', - posterUrl: '/files/poster.png', - episodes: [ - for (final number in numbers) - Episode( - id: '$id-$number', - number: number, - title: 'Episode $number', - videoUrl: 'https://example.test/clip.mp4', - isFree: true, - ), - ], -); +import 'package:fruitshorts/pages/discover/discover_types.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test( - 'should_resolve_resume_position_of_episode_one_when_constructed', - () async { - final history = FakeWatchHistory()..saved = const Duration(seconds: 4); - final logic = DiscoverLogic(history: history, drama: _drama('signal')); + test('should_report_query_only_for_non_whitespace_text', () { + final logic = DiscoverLogic(); - expect(await logic.position, const Duration(seconds: 4)); - expect(history.reads, ['signal']); - expect(logic.searchOpen, isFalse); - logic.dispose(); - }, - ); + expect(logic.hasSearchQuery, isFalse); - test('should_reread_resume_position_when_drama_is_swapped', () async { - final history = FakeWatchHistory(); - final logic = DiscoverLogic(history: history, drama: _drama('signal')); + logic.search.text = ' '; + expect(logic.hasSearchQuery, isFalse); - logic.drama = _drama('city'); - logic.refreshPosition(); - await logic.position; - - expect(history.reads, ['signal', 'city']); + logic.search.text = 'remote'; + expect(logic.hasSearchQuery, isTrue); logic.dispose(); }); - test( - 'should_resume_from_last_watch_when_list_card_has_no_episodes', - () async { - final history = FakeWatchHistory() - ..saved = const Duration(seconds: 9) - ..lastEpisode = 'signal-2'; - final logic = DiscoverLogic( - history: history, - drama: _drama('signal', numbers: const []), - ); - - expect(await logic.position, const Duration(seconds: 9)); - expect(history.reads, ['signal']); - logic.dispose(); - }, - ); - - test('should_filter_visible_dramas_when_language_is_selected', () { - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - ); - final dramas = [ - _drama('zh', language: 'zh-CN'), - _drama('id', language: 'id'), - _drama('unknown'), - ]; - - expect(logic.visibleDramas(dramas), hasLength(3)); - - logic.selectLanguage('id'); - expect(logic.visibleDramas(dramas).map((item) => item.id), ['id']); - - logic.selectLanguage(null); - expect(logic.visibleDramas(dramas), hasLength(3)); - logic.dispose(); - }); - - test('should_list_distinct_languages_when_feed_has_cards', () { - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - ); - final dramas = [ - _drama('zh', language: 'zh-CN'), - _drama('id', language: 'id'), - _drama('id2', language: 'id'), - _drama('unknown'), - ]; - - expect(logic.languagesOf(dramas), ['zh-CN', 'id']); - logic.dispose(); - }); - test('should_have_no_resume_position_when_server_has_no_record', () async { - final history = FakeWatchHistory(); - final logic = DiscoverLogic( - history: history, - drama: _drama('signal', numbers: const []), - ); - - expect(await logic.position, isNull); - // 续看位置改为按剧问服务端:即使卡片没有剧集也会读一次 - expect(history.reads, ['signal']); - logic.dispose(); - }); - - test('should_show_search_when_flag_is_on_or_text_is_typed', () { - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - ); - - expect(logic.showSearch, isFalse); - - logic.search.text = 'vengeance'; - logic.onSearchChanged(); - expect(logic.showSearch, isTrue); - - logic.search.clear(); - expect(logic.showSearch, isFalse); - - logic.searchOpen = true; - expect(logic.showSearch, isTrue); - logic.dispose(); - }); - - test('should_match_by_id_and_tolerate_missing_id_when_looking_up_drama', () { - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - ); - final catalog = [_drama('signal'), _drama('city')]; - - expect(logic.dramaFor('city', catalog)?.id, 'city'); - expect(logic.dramaFor('missing', catalog), isNull); - expect(logic.dramaFor(null, catalog), isNull); - expect(logic.dramaFor('city', const []), isNull); - logic.dispose(); - }); - - test('should_adopt_drama_only_when_page_is_active', () { - final history = FakeWatchHistory(); - final logic = DiscoverLogic(history: history, drama: _drama('signal')); - - void sync( - Drama next, { - required bool active, - required bool wasActive, - required bool dramaChanged, - int searchRequest = 0, - int previousSearchRequest = 0, - }) => logic.syncDrama( - active: active, - wasActive: wasActive, - nextDrama: next, - dramaChanged: dramaChanged, - searchRequest: searchRequest, - previousSearchRequest: previousSearchRequest, - isActive: () => active, - ); - - // An inactive page keeps showing its own drama. - sync(_drama('city'), active: false, wasActive: true, dramaChanged: true); - expect(logic.drama.id, 'signal'); - - // Becoming active adopts the incoming drama. - sync(_drama('city'), active: true, wasActive: false, dramaChanged: false); - expect(logic.drama.id, 'city'); - - // Staying active with a changed drama re-reads history. - history.reads.clear(); - sync( - _drama('vengeance'), - active: true, - wasActive: true, - dramaChanged: true, - ); - expect(logic.drama.id, 'vengeance'); - expect(history.reads, ['vengeance']); - - // Staying active with no change leaves both drama and history alone. - history.reads.clear(); - sync(_drama('other'), active: true, wasActive: true, dramaChanged: false); - expect(logic.drama.id, 'vengeance'); - expect(history.reads, isEmpty); - logic.dispose(); - }); - - testWidgets( - 'should_open_and_focus_field_when_search_is_requested_and_page_is_active', - (tester) async { - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - ); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: TextField( - controller: logic.search, - focusNode: logic.searchFocus, - ), - ), - ), - ); - - logic.syncDrama( - active: true, - wasActive: true, - nextDrama: logic.drama, - dramaChanged: false, - searchRequest: 1, - previousSearchRequest: 0, - isActive: () => true, - ); - - expect(logic.searchOpen, isTrue); - await tester.pumpAndSettle(); - expect(logic.searchFocus.hasFocus, isTrue); - - await tester.pumpWidget(const SizedBox.shrink()); - logic.dispose(); - }, - ); - - testWidgets('should_keep_search_field_closed_when_page_is_hidden', ( + testWidgets('should_focus_search_only_for_new_request_on_active_page', ( tester, ) async { - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - ); + final logic = DiscoverLogic(); await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -261,33 +38,33 @@ void main() { ), ); - // The search request branch is gated on `active`, so a page that is not on - // screen must neither reveal the field nor pull focus away from the visible tab. - logic.syncDrama( + logic.syncSearch( active: false, - wasActive: false, - nextDrama: logic.drama, - dramaChanged: false, - searchRequest: 1, - previousSearchRequest: 0, + request: 1, + previousRequest: 0, isActive: () => false, ); - - expect(logic.searchOpen, isFalse); - await tester.pumpAndSettle(); + await tester.pump(); expect(logic.searchFocus.hasFocus, isFalse); + logic.syncSearch( + active: true, + request: 2, + previousRequest: 1, + isActive: () => true, + ); + await tester.pump(); + await tester.pump(); + expect(logic.searchFocus.hasFocus, isTrue); + await tester.pumpWidget(const SizedBox.shrink()); logic.dispose(); }); - testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', ( + testWidgets('should_forward_action_when_embedded_or_pop_when_standalone', ( tester, ) async { - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - ); + final logic = DiscoverLogic(); final forwarded = []; final popped = Completer(); late BuildContext pageContext; @@ -311,7 +88,6 @@ void main() { ), ), ); - await tester.tap(find.text('open')); await tester.pumpAndSettle(); @@ -322,83 +98,15 @@ void main() { onAction: forwarded.add, ); expect(forwarded, [DiscoverPageAction.home]); - expect(Navigator.of(pageContext).canPop(), isTrue); - logic.act(pageContext, DiscoverPageAction.watchlist, embedded: false); + logic.act(pageContext, DiscoverPageAction.my, embedded: false); await tester.pumpAndSettle(); - - await expectLater(popped.future, completion(DiscoverPageAction.watchlist)); - logic.dispose(); - }); - - testWidgets('should_show_artwork_notice_when_card_has_no_playable_media', ( - tester, - ) async { - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - ); - late BuildContext context; - - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Builder( - builder: (inner) { - context = inner; - return const Scaffold(body: SizedBox.shrink()); - }, - ), - ), - ); - - logic.openCard(context, null, embedded: false); - await tester.pumpAndSettle(); - - final l10n = AppLocalizations.of(context)!; - expect(find.text(l10n.artworkOnlyNotice), findsOneWidget); - logic.dispose(); - }); - - testWidgets('should_forward_chosen_drama_when_card_is_embedded', ( - tester, - ) async { - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - ); - late BuildContext context; - Drama? selected; - - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (inner) { - context = inner; - return const SizedBox.shrink(); - }, - ), - ), - ); - - logic.openCard( - context, - _drama('city'), - embedded: true, - onDramaSelected: (drama) => selected = drama, - ); - - expect(selected?.id, 'city'); + await expectLater(popped.future, completion(DiscoverPageAction.my)); logic.dispose(); }); test('should_do_nothing_when_notifying_after_dispose', () { - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - ); + final logic = DiscoverLogic(); var notifications = 0; logic.addListener(() => notifications++); diff --git a/test/discover/discover_search_test.dart b/test/discover/discover_search_test.dart index 0ddd7e1..cf8cfae 100644 --- a/test/discover/discover_search_test.dart +++ b/test/discover/discover_search_test.dart @@ -7,8 +7,6 @@ import 'package:fruitshorts/core/data/remote/api_exception.dart'; import 'package:fruitshorts/core/data/repositories/search_repository.dart'; import 'package:fruitshorts/pages/discover/discover_logic.dart'; -import '../support/fake_watch_history.dart'; - Drama _drama(String id, {String language = 'id'}) => Drama( id: id, title: id, @@ -25,11 +23,8 @@ void main() { 'should_query_backend_and_render_results_when_search_text_changes', () async { final repository = _FakeSearchRepository(results: [_drama('velvet')]); - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('signal'), - searchRepository: repository, - )..searchDebounce = Duration.zero; + final logic = DiscoverLogic(searchRepository: repository) + ..searchDebounce = Duration.zero; logic.search.text = 'velvet'; logic.onSearchChanged(); @@ -39,18 +34,15 @@ void main() { (keyword: 'velvet', pageIndex: 1, pageSize: 20), ]); expect(logic.searchLoading, isFalse); - expect(logic.visibleDramas(const []).single.id, 'velvet'); + expect(logic.searchResults.single.id, 'velvet'); logic.dispose(); }, ); test('should_keep_server_results_when_text_only_loses_whitespace', () async { final repository = _FakeSearchRepository(results: [_drama('velvet')]); - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('signal'), - searchRepository: repository, - )..searchDebounce = Duration.zero; + final logic = DiscoverLogic(searchRepository: repository) + ..searchDebounce = Duration.zero; logic.search.text = 'velvet '; logic.onSearchChanged(); @@ -60,14 +52,12 @@ void main() { await pumpEventQueue(); expect(repository.calls, hasLength(1)); - expect(logic.visibleDramas(const []).single.id, 'velvet'); + expect(logic.searchResults.single.id, 'velvet'); logic.dispose(); }); test('should_expose_error_message_when_search_fails', () async { final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('signal'), searchRepository: _FakeSearchRepository( error: const ApiException(400, 'Keyword is invalid'), ), @@ -84,11 +74,8 @@ void main() { test('should_clear_server_results_when_search_text_is_empty', () async { final repository = _FakeSearchRepository(results: [_drama('velvet')]); - final logic = DiscoverLogic( - history: FakeWatchHistory(), - drama: _drama('signal'), - searchRepository: repository, - )..searchDebounce = Duration.zero; + final logic = DiscoverLogic(searchRepository: repository) + ..searchDebounce = Duration.zero; logic.search.text = 'velvet'; logic.onSearchChanged(); @@ -96,7 +83,8 @@ void main() { logic.search.clear(); logic.onSearchChanged(); - expect(logic.visibleDramas([_drama('local')]).single.id, 'local'); + expect(logic.hasSearchQuery, isFalse); + expect(logic.searchResults, isEmpty); logic.dispose(); }); } diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index e515fc5..8b5c736 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -1,17 +1,17 @@ +// Author: fruit.com.cn + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; - import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/content_category.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; -import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/category_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import '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_interaction_repositories.dart'; @@ -24,173 +24,65 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets( - 'should_show_all_dramas_in_two_column_masonry_without_continue_watching', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + testWidgets('should_show_search_categories_ranking_and_all_dramas', ( + tester, + ) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); + expect(find.text('Continue Watching'), findsNothing); + expect(find.byKey(const Key('discover-search')), findsOneWidget); + expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('ranking-tab-hot')), findsOneWidget); + expect(find.byKey(const Key('ranking-tab-new')), findsOneWidget); - expect(find.text('Continue Watching'), findsNothing); - expect(find.text('Recommended'), findsOneWidget); - for (final key in const [ - Key('discover-search-button'), - Key('discover-profile'), - ]) { - expect(tester.getSize(find.byKey(key)), const Size(36, 36)); - expect( - tester - .widget( - find.descendant( - of: find.byKey(key), - matching: find.byType(Icon), - ), - ) - .size, - 18, - ); - } - expect(find.text('For You'), findsNothing); - final masonry = tester.widget( - find.byKey(const Key('home-all-dramas-grid')), - ); - expect(masonry.children, hasLength(3)); - expect((masonry.children[1] as SizedBox).width, 10); - expect( - tester - .widget(find.byKey(const Key('home-all-dramas-section'))) - .padding, - const EdgeInsets.symmetric(horizontal: 14), - ); - expect(find.byType(GridView), findsNothing); - expect(find.byType(CustomScrollView), findsOneWidget); - final signalCard = find.byKey( - const Key('home-drama-card-the-signal-demo'), - ); - final cityCard = find.byKey( - const Key('home-drama-card-city-lights-demo'), - ); - final vengeanceCard = find.byKey( - const Key('home-drama-card-vengeance-demo'), - ); - expect(tester.getTopLeft(signalCard).dy, tester.getTopLeft(cityCard).dy); - expect( - tester.getTopLeft(vengeanceCard).dx, - tester.getTopLeft(signalCard).dx, - ); - expect( - tester.getTopLeft(vengeanceCard).dy - - tester.getBottomLeft(signalCard).dy, - closeTo(10, 1), - ); - - final cover = find.byKey(const Key('home-drama-cover-city-lights-demo')); - expect(tester.getSize(cover).height, greaterThan(120)); - expect( - tester - .widget( - find.descendant(of: cover, matching: find.byType(Image)), - ) - .fit, - BoxFit.fitWidth, - ); - expect( - find.descendant( - of: cover, - matching: find.text('City Lights & Velvet Nights'), - ), - findsNothing, - ); - expect( - find.byKey(const Key('home-drama-title-city-lights-demo')), - findsOneWidget, - ); - expect( - tester - .getTopLeft( - find.byKey(const Key('home-drama-title-city-lights-demo')), - ) - .dy, - greaterThan(tester.getBottomLeft(cover).dy), - ); - expect( - tester - .widget( - find.byKey(const Key('home-drama-title-city-lights-demo')), - ) - .maxLines, - 1, - ); - expect( - tester - .widget( - find.byKey(const Key('home-drama-genre-city-lights-demo')), - ) - .maxLines, - 1, - ); - expect(tester.takeException(), isNull); - }, - ); - - testWidgets('should_update_grid_when_category_is_selected', (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - - expect(find.text('The Signal'), findsWidgets); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); - - await tester.tap(find.text('Romance')); - await tester.pumpAndSettle(); - - expect(find.text('The Signal'), findsNothing); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsNothing, + final grid = tester.widget( + find.byKey(const Key('discover-category-grid')), ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 3); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_open_category_dramas_page_when_tile_is_tapped', ( + tester, + ) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + + await tester.tap(find.byKey(const Key('discover-category-7'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('category-dramas-page')), findsOneWidget); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, ); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); }); testWidgets('should_keep_discover_header_fixed_when_content_scrolls', ( tester, ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await _pumpApp( + await _pumpTheater( tester, categoryRepository: _GridCategoryRepository( discoverFeed: _tallDiscoverFeed, ), ); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); final headerBefore = tester.getTopLeft( find.byKey(const Key('discover-search-button')), ); final contentBefore = tester.getTopLeft( - find.byKey(const Key('home-category-chips')), + find.byKey(const Key('discover-category-grid')), ); - await tester.drag(find.byType(CustomScrollView), const Offset(0, -40)); + await tester.drag(find.byType(ListView), const Offset(0, -140)); await tester.pumpAndSettle(); expect( @@ -198,158 +90,37 @@ void main() { headerBefore, ); expect( - tester.getTopLeft(find.byKey(const Key('home-category-chips'))).dy, + tester.getTopLeft(find.byKey(const Key('discover-category-grid'))).dy, lessThan(contentBefore.dy), ); }); - testWidgets('should_keep_empty_messages_when_feed_has_no_dramas', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await _pumpApp( + testWidgets('should_show_empty_and_retry_states', (tester) async { + await _pumpTheater( tester, categoryRepository: _GridCategoryRepository( discoverFeed: const HomeFeed(), ), ); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); + expect(find.byKey(const Key('discover-empty')), findsOneWidget); - expect(find.text('No matching dramas.'), findsOneWidget); - expect(find.byKey(const Key('empty-state-content')), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + await _pumpTheater( + tester, + categoryRepository: _FailingCategoryRepository(), + ); - await tester.tap(find.text('Romance')); - await tester.pumpAndSettle(); - - expect(find.text('No dramas in this category yet.'), findsOneWidget); - }); - - testWidgets('should_show_retry_when_category_feed_fails', (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await _pumpApp(tester, categoryRepository: _FailingCategoryRepository()); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - - expect(find.text('Categories could not be loaded.'), findsOneWidget); + expect(find.byKey(const Key('home-categories-error')), findsOneWidget); expect(find.text('Retry'), findsOneWidget); - expect(find.byKey(const Key('empty-state-icon')), findsOneWidget); - final icon = tester.getCenter(find.byKey(const Key('empty-state-icon'))); - final feed = tester.getRect(find.byType(CustomScrollView)); - expect(icon.dx, closeTo(feed.center.dx, 1)); - expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing); }); - testWidgets('should_fit_two_column_grid_on_compact_screen', (tester) async { - tester.view.physicalSize = const Size(320, 568); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - await tester.scrollUntilVisible( - find.byKey(const Key('home-drama-card-city-lights-demo')), - 180, - scrollable: find.byType(Scrollable).last, - ); - await tester.pumpAndSettle(); - - expect(find.text('City Lights & Velvet Nights'), findsWidgets); - expect(tester.takeException(), isNull); - }); - - testWidgets('should_filter_trending_grid_when_search_text_changes', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsOneWidget, - ); - expect( - find.byKey(const Key('home-drama-card-city-lights-demo')), - findsOneWidget, - ); + testWidgets('should_show_search_results_when_text_changes', (tester) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); + await tester.pump(const Duration(milliseconds: 400)); await tester.pumpAndSettle(); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsNothing, - ); - expect( - find.byKey(const Key('home-drama-card-city-lights-demo')), - findsOneWidget, - ); - - await tester.enterText(find.byKey(const Key('discover-search')), ''); - await tester.pumpAndSettle(); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsOneWidget, - ); - }); - - testWidgets('should_unfocus_search_without_losing_input_or_filter', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('discover-search'))); - await tester.pump(); - expect( - tester - .widget(find.byKey(const Key('discover-search'))) - .focusNode! - .hasFocus, - isTrue, - ); - - await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); - await tester.pumpAndSettle(); - expect( - find.byKey(const Key('home-drama-card-city-lights-demo')), - findsOneWidget, - ); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsNothing, - ); - - await tester.tapAt(const Offset(70, 23)); - await tester.pump(); - - final field = tester.widget( - find.byKey(const Key('discover-search')), - ); - expect(field.focusNode!.hasFocus, isFalse); - expect(field.controller!.text, 'velvet'); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, @@ -361,20 +132,13 @@ void main() { }); testWidgets('should_refresh_feed_when_pulled_down', (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final counter = _CountingCategoryRepository( _GridCategoryRepository(discoverFeed: _tallDiscoverFeed), ); - await _pumpApp(tester, categoryRepository: counter); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); + await _pumpTheater(tester, categoryRepository: counter); final before = counter.categoryLoads + counter.feedLoads; - await tester.drag(find.byType(CustomScrollView), const Offset(0, 320)); + await tester.drag(find.byType(ListView), const Offset(0, 320)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); await tester.pumpAndSettle(); @@ -382,47 +146,16 @@ void main() { expect(counter.categoryLoads + counter.feedLoads, greaterThan(before)); expect(find.byType(RefreshProgressIndicator), findsNothing); }); - - testWidgets('should_show_continue_watching_only_when_a_record_exists', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await _pumpApp( - tester, - categoryRepository: _GridCategoryRepository(), - history: InMemoryWatchHistoryRepository( - seed: const [ - WatchProgress( - dramaId: 'city-lights-demo', - episodeId: 'city-lights-demo-1', - position: Duration(seconds: 42), - duration: Duration(minutes: 2), - ), - ], - ), - ); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('discover-continue-watching')), findsOneWidget); - expect(find.byKey(const Key('discover-continue-play')), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); - expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget); - expect(find.byKey(const Key('discover-quick-charts')), findsOneWidget); - expect(find.byKey(const Key('discover-quick-new')), findsOneWidget); - expect(find.byKey(const Key('discover-quick-watchlist')), findsOneWidget); - }); } -Future _pumpApp( +Future _pumpTheater( WidgetTester tester, { required CategoryRepository categoryRepository, - WatchHistoryRepository? history, }) 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( @@ -430,7 +163,7 @@ Future _pumpApp( catalogRepository: catalog, searchRepository: catalog, categoryRepository: categoryRepository, - watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), + watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), @@ -439,6 +172,8 @@ Future _pumpApp( ), ); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); } class _GridCategoryRepository implements CategoryRepository { diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 85c609c..54ff50f 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -1,9 +1,10 @@ -import 'package:flutter/material.dart'; +// Author: fruit.com.cn + import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; -import 'package:fruitshorts/pages/discover/discover_logic.dart'; +import 'package:fruitshorts/pages/discover/discover_types.dart'; import 'package:fruitshorts/pages/home/home_logic.dart'; -import 'package:fruitshorts/pages/mine/mine_logic.dart'; +import 'package:fruitshorts/pages/mine/mine_types.dart'; Drama _drama(String id) => Drama( id: id, @@ -18,20 +19,15 @@ void main() { final dramas = [_drama('signal'), _drama('city')]; - test( - 'should_make_first_catalog_entry_the_current_drama_when_catalog_loads', - () { - final logic = HomeLogic(dramas); + test('should_make_first_catalog_entry_current_when_catalog_loads', () { + final logic = HomeLogic(dramas); - expect(logic.currentDrama.id, 'signal'); - expect(logic.tab, 0); - expect(logic.mySection, 0); - expect(logic.drawerOpen, isFalse); - expect(logic.profile, isFalse); - expect(logic.videoImmersive, isFalse); - logic.dispose(); - }, - ); + expect(logic.currentDrama.id, 'signal'); + expect(logic.tab, 0); + expect(logic.drawerOpen, isFalse); + expect(logic.videoImmersive, isFalse); + logic.dispose(); + }); test('should_not_build_shell_when_catalog_is_empty', () { expect(() => HomeLogic(const []), throwsStateError); @@ -46,7 +42,6 @@ void main() { expect(logic.tab, 1); expect(logic.searchRequest, 0); - expect(logic.sectionRequest, 0); expect(logic.resumeRequest, 0); expect(logic.backRequest, 0); expect(notifications, 1); @@ -57,62 +52,27 @@ void main() { final logic = HomeLogic(dramas); logic.select(1, search: true); - expect(logic.tab, 1); - expect(logic.searchRequest, 1); - expect(logic.sectionRequest, 0); - - logic.select(2, mySection: 0); - expect(logic.mySection, 0); - expect(logic.sectionRequest, 1); expect(logic.searchRequest, 1); + expect(logic.resumeRequest, 0); logic.select(0, resume: true); expect(logic.resumeRequest, 1); - expect(logic.sectionRequest, 1); - - expect(logic.backRequest, 0); + expect(logic.searchRequest, 1); logic.dispose(); }); - test('should_default_to_second_section_when_watchlist_shortcut_is_used', () { + test('should_open_profile_tab_when_profile_action_is_used', () { final logic = HomeLogic(dramas); - logic.select(2, watchlist: true); + logic.openProfile(); expect(logic.tab, 2); - expect(logic.mySection, 1); - expect(logic.sectionRequest, 1); logic.dispose(); }); - test( - 'should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', - () { - final logic = HomeLogic(dramas); - - logic.openProfile(); - expect(logic.profile, isTrue); - expect(logic.tab, 2); - expect(logic.mySection, 0); - expect(logic.sectionRequest, 1); - - logic.openLibrary(section: 1); - expect(logic.profile, isFalse); - expect(logic.mySection, 1); - expect(logic.sectionRequest, 2); - - logic.openLibrary(); - expect(logic.profile, isFalse); - expect(logic.mySection, 1); - expect(logic.sectionRequest, 3); - logic.dispose(); - }, - ); - test('should_return_to_discover_and_handle_immersive_back', () { final logic = HomeLogic(dramas); - // Home is the launch tab; the first back press lands on Discover. logic.onSystemBack(); expect(logic.tab, 1); logic.onSystemBack(); @@ -122,7 +82,6 @@ void main() { logic.select(0); logic.onSystemBack(); expect(logic.tab, 1); - expect(logic.backRequest, 0); logic.select(0); logic.onImmersiveChanged(true); @@ -181,6 +140,17 @@ void main() { logic.dispose(); }); + test('should_map_onto_root_tabs_when_discover_actions_fire', () { + final logic = HomeLogic(dramas); + + logic.discoverAction(DiscoverPageAction.my); + expect(logic.tab, 2); + + logic.discoverAction(DiscoverPageAction.home); + expect(logic.tab, 0); + logic.dispose(); + }); + test('should_do_nothing_when_notifying_after_dispose', () { final logic = HomeLogic(dramas); var notifications = 0; @@ -191,41 +161,4 @@ void main() { expect(notifications, 0); }); - - testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', ( - tester, - ) async { - final logic = HomeLogic(dramas); - late BuildContext context; - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (inner) { - context = inner; - return const SizedBox.shrink(); - }, - ), - ), - ); - - logic.discoverAction(context, DiscoverPageAction.watchlist); - expect(logic.tab, 2); - expect(logic.profile, isFalse); - expect(logic.mySection, 1); - expect(logic.sectionRequest, 1); - - logic.discoverAction(context, DiscoverPageAction.my); - expect(logic.tab, 2); - expect(logic.profile, isFalse); - - logic.discoverAction(context, DiscoverPageAction.resume); - expect(logic.tab, 0); - expect(logic.resumeRequest, 1); - - logic.discoverAction(context, DiscoverPageAction.home); - expect(logic.tab, 0); - - expect(tester.takeException(), isNull); - logic.dispose(); - }); } diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 5075153..65db607 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -95,9 +95,7 @@ void main() { ); }); - testWidgets('should_map_three_tabs_to_home_discover_and_me', ( - tester, - ) async { + testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async { await _pumpApp(tester); expect(find.byKey(const Key('tab-home')), findsOneWidget); @@ -112,10 +110,9 @@ void main() { await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); expect(find.text('Viewer'), findsOneWidget); - expect( - find.byKey(const Key('mine-library-tab-0'), skipOffstage: false), - findsOneWidget, - ); + expect(find.byKey(const Key('mine-menu')), findsOneWidget); + expect(find.byKey(const Key('mine-watchlist')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); @@ -131,11 +128,11 @@ void main() { await tester.pumpAndSettle(); haptics.reset(); - await tester.drag(find.byType(ListView), const Offset(0, -700)); - await tester.pumpAndSettle(); - await tester.tap(find.text('App Language')); + await tester.ensureVisible(find.byKey(const Key('mine-settings'))); + await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); + expect(find.byKey(const Key('settings-page')), findsOneWidget); expect(haptics.calls, isEmpty); }); } diff --git a/test/mine/mine_logic_test.dart b/test/mine/mine_logic_test.dart index 5aed167..30cb8d5 100644 --- a/test/mine/mine_logic_test.dart +++ b/test/mine/mine_logic_test.dart @@ -1,173 +1,68 @@ +// Author: fruit.com.cn + import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:fruitshorts/core/config/locale_controller.dart'; -import 'package:fruitshorts/core/data/models/drama.dart'; -import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/pages/mine/mine_logic.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 'package:fruitshorts/pages/mine/mine_types.dart'; import '../support/fake_watch_history.dart'; -Drama _drama(String id, {List numbers = const [1, 2, 3]}) => Drama( - id: id, - title: id, - synopsis: 'synopsis', - posterUrl: '/files/poster.png', - episodes: [ - for (final number in numbers) - Episode( - id: '$id-$number', - number: number, - title: 'Episode $number', - videoUrl: 'https://example.test/clip.mp4', - isFree: true, - ), - ], +const _record = WatchProgress( + dramaId: 'signal', + episodeId: 'signal-1', + position: Duration(seconds: 7), + duration: Duration(minutes: 2), ); void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test( - 'should_keep_requested_section_and_read_episode_one_when_constructed', - () async { - final history = FakeWatchHistory()..saved = const Duration(seconds: 7); - final logic = MineLogic( - history: history, - drama: _drama('signal'), - initialSection: 1, - ); + test('should_load_recent_watches_when_constructed', () async { + final history = FakeWatchHistory()..recentRecords = const [_record]; + final logic = MineLogic(history: history); - expect(logic.section, 1); - expect(logic.filter, 0); - expect(await logic.position, const Duration(seconds: 7)); - expect(history.reads, ['signal']); - logic.dispose(); - }, - ); + await pumpEventQueue(); - test( - 'should_resume_from_last_watch_when_list_card_has_no_episodes', - () async { - final history = FakeWatchHistory() - ..saved = const Duration(seconds: 7) - ..lastEpisode = 'signal-3'; - final logic = MineLogic( - history: history, - drama: _drama('signal', numbers: const []), - initialSection: 1, - ); - - expect(await logic.position, const Duration(seconds: 7)); - expect(history.reads, ['signal']); - logic.dispose(); - }, - ); - - test('should_notify_when_section_and_filter_are_selected', () { - final logic = MineLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - initialSection: 0, - ); - var notifications = 0; - logic.addListener(() => notifications++); - - logic.selectSection(1); - expect(logic.section, 1); - expect(notifications, 1); - - logic.selectFilter(2); - expect(logic.filter, 2); - expect(notifications, 2); + expect(logic.recent.single.dramaId, 'signal'); + expect(logic.recentLoading, isFalse); + expect(logic.recentError, isNull); logic.dispose(); }); - test('should_adopt_drama_only_when_page_is_active', () { - final history = FakeWatchHistory(); - final logic = MineLogic( - history: history, - drama: _drama('signal'), - initialSection: 0, - ); + test('should_expose_error_when_recent_request_fails', () async { + final history = FakeWatchHistory()..failReads = true; + final logic = MineLogic(history: history); - void sync( - Drama next, { - required bool active, - required bool wasActive, - required bool dramaChanged, - int sectionRequest = 0, - int previousSectionRequest = 0, - int initialSection = 0, - }) => logic.syncPage( - active: active, - wasActive: wasActive, - nextDrama: next, - dramaChanged: dramaChanged, - sectionRequest: sectionRequest, - previousSectionRequest: previousSectionRequest, - initialSection: initialSection, - ); + await pumpEventQueue(); - sync(_drama('city'), active: false, wasActive: true, dramaChanged: true); - expect(logic.drama.id, 'signal'); - - sync(_drama('city'), active: true, wasActive: false, dramaChanged: false); - expect(logic.drama.id, 'city'); - - history.reads.clear(); - sync( - _drama('vengeance'), - active: true, - wasActive: true, - dramaChanged: true, - ); - expect(logic.drama.id, 'vengeance'); - expect(history.reads, ['vengeance']); - - history.reads.clear(); - sync(_drama('other'), active: true, wasActive: true, dramaChanged: false); - expect(logic.drama.id, 'vengeance'); - expect(history.reads, isEmpty); + expect(logic.recent, isEmpty); + expect(logic.recentError, 'progressError'); logic.dispose(); }); - test( - 'should_reset_section_when_section_request_arrives_while_page_is_inactive', - () { - final logic = MineLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - initialSection: 0, - ); + test('should_refresh_recent_only_when_page_becomes_active', () async { + final history = FakeWatchHistory()..recentRecords = const [_record]; + final logic = MineLogic(history: history); + await pumpEventQueue(); - logic.syncPage( - active: false, - wasActive: false, - nextDrama: logic.drama, - dramaChanged: false, - sectionRequest: 1, - previousSectionRequest: 0, - initialSection: 1, - ); + history.recentRecords = const []; + logic.syncPage(active: true, wasActive: true); + await pumpEventQueue(); + expect(logic.recent, hasLength(1)); - expect(logic.section, 1); - logic.dispose(); - }, - ); + logic.syncPage(active: true, wasActive: false); + await pumpEventQueue(); + expect(logic.recent, isEmpty); + logic.dispose(); + }); - testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', ( + testWidgets('should_forward_action_when_embedded_or_pop_when_standalone', ( tester, ) async { - final logic = MineLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - initialSection: 0, - ); + final logic = MineLogic(history: FakeWatchHistory()); final forwarded = []; final popped = Completer(); late BuildContext pageContext; @@ -191,7 +86,6 @@ void main() { ), ), ); - await tester.tap(find.text('open')); await tester.pumpAndSettle(); @@ -202,72 +96,15 @@ void main() { onAction: forwarded.add, ); expect(forwarded, [MyPageAction.discover]); - expect(Navigator.of(pageContext).canPop(), isTrue); logic.act(pageContext, MyPageAction.resume, embedded: false); await tester.pumpAndSettle(); - await expectLater(popped.future, completion(MyPageAction.resume)); logic.dispose(); }); - testWidgets('should_switch_app_locale_when_language_sheet_is_used', ( - tester, - ) async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - final locale = LocaleController(); - final logic = MineLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - initialSection: 0, - ); - late BuildContext context; - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: locale, - child: MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Builder( - builder: (inner) { - context = inner; - return const Scaffold(body: SizedBox.shrink()); - }, - ), - ), - ), - ); - - unawaited(logic.showLanguage(context)); - await tester.pumpAndSettle(); - - final l10n = AppLocalizations.of(context)!; - expect(find.text(l10n.english), findsOneWidget); - expect(find.text(l10n.simplifiedChinese), findsOneWidget); - expect(find.text('Bahasa Indonesia'), findsOneWidget); - - await tester.tap(find.text('Bahasa Indonesia')); - await tester.pumpAndSettle(); - expect(locale.locale, const Locale('id')); - - unawaited(logic.showLanguage(context)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.simplifiedChinese)); - await tester.pumpAndSettle(); - - expect(locale.locale, const Locale('zh')); - logic.dispose(); - }); - test('should_do_nothing_when_notifying_after_dispose', () { - final logic = MineLogic( - history: FakeWatchHistory(), - drama: _drama('s'), - initialSection: 0, - ); + final logic = MineLogic(history: FakeWatchHistory()); var notifications = 0; logic.addListener(() => notifications++); diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index d9c9946..9edf04e 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -2,15 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; - import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/watch_progress.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/core/theme/app_colors.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'; @@ -20,7 +18,7 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_me_user_wallet_continue_and_language_rows', ( + testWidgets('should_render_only_continue_and_six_core_entries', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -59,58 +57,22 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-user-row')), findsOneWidget); - expect(find.byKey(const Key('mine-wallet')), findsOneWidget); - for (final key in const [Key('mine-search-button'), Key('mine-avatar')]) { - expect(tester.getSize(find.byKey(key)), const Size(36, 36)); - expect( - tester - .widget( - find.descendant(of: find.byKey(key), matching: find.byType(Icon)), - ) - .size, - 18, - ); - } expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); - expect(find.byKey(const Key('mine-language')), findsOneWidget); - expect(find.byKey(const Key('mine-library-tab-0')), findsOneWidget); - expect(find.byKey(const Key('mine-library-tab-1')), findsOneWidget); - expect(find.byKey(const Key('mine-library-tab-2')), findsOneWidget); - final tab0 = find.byKey(const Key('mine-library-tab-0')); - final tab1 = find.byKey(const Key('mine-library-tab-1')); - final tab2 = find.byKey(const Key('mine-library-tab-2')); - expect(tester.getTopLeft(tab0).dx, lessThan(tester.getTopLeft(tab1).dx)); - expect(tester.getTopLeft(tab1).dx, lessThan(tester.getTopLeft(tab2).dx)); - expect( - tester.getTopLeft(tab1).dx - tester.getTopRight(tab0).dx, - closeTo(20, 1), - ); - expect( - (tester - .widget( - find.descendant( - of: tab0, - matching: find.byType(AnimatedContainer), - ), - ) - .decoration - as BoxDecoration) - .color, - AppColors.blue, - ); - expect( - (tester - .widget( - find.descendant( - of: tab1, - matching: find.byType(AnimatedContainer), - ), - ) - .decoration - as BoxDecoration) - .color, - Colors.transparent, - ); + expect(find.byKey(const Key('mine-continue-play')), findsOneWidget); + for (final key in const [ + Key('mine-watchlist'), + Key('mine-history'), + Key('mine-downloads'), + Key('mine-wallet'), + Key('mine-vip'), + Key('mine-settings'), + ]) { + expect(find.byKey(key), findsOneWidget); + } + expect(find.byKey(const Key('mine-language')), findsNothing); + expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); + expect(find.byKey(const Key('mine-library-tab-1')), findsNothing); + expect(find.byKey(const Key('mine-library-tab-2')), findsNothing); expect(tester.takeException(), isNull); }); } diff --git a/test/mine/server_endpoint_setting_test.dart b/test/mine/server_endpoint_setting_test.dart deleted file mode 100644 index 2d468fb..0000000 --- a/test/mine/server_endpoint_setting_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.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 'package:fruitshorts/core/config/locale_controller.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'; -import 'package:fruitshorts/pages/mine/mine_logic.dart'; - -const _drama = Drama( - id: 'drama-1', - title: 'The Signal', - synopsis: 'synopsis', - posterUrl: '/files/1.jpg', -); - -void main() { - setUp(() { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - }); - - testWidgets('should_not_show_server_address_in_settings_sheet', ( - tester, - ) async { - await _pumpSettings(tester); - expect(find.byKey(const Key('server-endpoint-setting')), findsNothing); - expect(find.text('Server address'), findsNothing); - }); - - testWidgets('should_keep_all_three_languages_in_settings_sheet', ( - tester, - ) async { - await _pumpSettings(tester); - - expect(find.text('English'), findsOneWidget); - expect(find.text('简体中文'), findsOneWidget); - expect(find.text('Bahasa Indonesia'), findsOneWidget); - }); -} - -Future _pumpSettings(WidgetTester tester) async { - final logic = MineLogic( - history: InMemoryWatchHistoryRepository(), - drama: _drama, - initialSection: 0, - ); - addTearDown(logic.dispose); - await tester.pumpWidget( - ChangeNotifierProvider( - create: (_) => LocaleController(), - child: MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Builder( - builder: (context) => Scaffold( - body: TextButton( - key: const Key('open-settings'), - onPressed: () => logic.showLanguage(context), - child: const Text('Open settings'), - ), - ), - ), - ), - ), - ); - await tester.tap(find.byKey(const Key('open-settings'))); - await tester.pumpAndSettle(); -} diff --git a/test/discover/discover_language_filter_test.dart b/test/mine/settings_page_test.dart similarity index 52% rename from test/discover/discover_language_filter_test.dart rename to test/mine/settings_page_test.dart index 003d3db..0a8e1a8 100644 --- a/test/discover/discover_language_filter_test.dart +++ b/test/mine/settings_page_test.dart @@ -2,19 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; - import 'package:fruitshorts/app.dart'; -import 'package:fruitshorts/core/data/models/content_category.dart'; -import 'package:fruitshorts/core/data/models/drama.dart'; -import 'package:fruitshorts/core/data/models/home_feed.dart'; -import 'package:fruitshorts/core/data/repositories/category_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import '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_interaction_repositories.dart'; import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; @@ -25,19 +21,20 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_filter_theater_grid_when_language_chip_is_tapped', ( + testWidgets('should_open_minimal_settings_without_language_switches', ( 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: FakeCatalogRepository(), - categoryRepository: _MixedLanguageCategoryRepository(), + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(), playbackRepository: FakePlaybackRepository(), @@ -47,44 +44,17 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget); - expect(find.byKey(const Key('home-drama-card-id-demo')), findsOneWidget); - - await tester.tap(find.byKey(const Key('discover-language-zh-CN'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget); - expect(find.byKey(const Key('home-drama-card-id-demo')), findsNothing); + expect(find.byKey(const Key('settings-page')), findsOneWidget); + expect(find.byKey(const Key('settings-notices')), findsOneWidget); + expect(find.byKey(const Key('server-endpoint-setting')), findsNothing); + expect(find.text('Server address'), findsNothing); + expect(find.text('English'), findsNothing); + expect(find.text('简体中文'), findsNothing); + expect(find.text('Bahasa Indonesia'), findsNothing); }); } - -class _MixedLanguageCategoryRepository implements CategoryRepository { - @override - Future> fetchCategories() async => const []; - - @override - Future fetchDiscoverFeed() async => HomeFeed( - sections: [ - FeedSection( - code: 'hot', - title: 'Hot', - dramas: [ - _drama('zh-demo', 'Chinese Demo', 'zh-CN'), - _drama('id-demo', 'Indonesian Demo', 'id'), - ], - ), - ], - ); -} - -Drama _drama(String id, String title, String language) => Drama( - id: id, - title: title, - synopsis: '$title synopsis', - posterUrl: '/files/$id.png', - language: language, - detailLoaded: true, -); diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index 1767c7f..221d024 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -46,11 +46,11 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('mine-notices')), findsOneWidget); - await tester.tap(find.byKey(const Key('mine-notices'))); + expect(find.byKey(const Key('settings-notices')), findsOneWidget); + await tester.tap(find.byKey(const Key('settings-notices'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('notices-page')), findsOneWidget); diff --git a/test/ranking/ranking_logic_test.dart b/test/ranking/ranking_logic_test.dart index 8d1f935..bc6897e 100644 --- a/test/ranking/ranking_logic_test.dart +++ b/test/ranking/ranking_logic_test.dart @@ -3,7 +3,7 @@ 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'; +import 'package:fruitshorts/pages/discover/ranking_logic.dart'; void main() { test('should_use_backend_hot_order_when_hot_board_is_selected', () { diff --git a/test/widget_test.dart b/test/widget_test.dart index 430b533..1fce792 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -150,7 +150,6 @@ void main() { isFalse, ); expect(find.text('Quick access'), findsOneWidget); - expect(find.text('Bahasa Indonesia'), findsOneWidget); expect(find.text('7 EPISODES'), findsOneWidget); expect(tester.takeException(), isNull); @@ -181,7 +180,7 @@ void main() { }, ); - testWidgets('should_change_interface_language_when_locale_is_picked', ( + testWidgets('should_follow_system_locale_without_language_switch', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); @@ -196,20 +195,13 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Vicky sheng'), findsOneWidget); expect(find.text('Coins'), findsOneWidget); - await tester.drag(find.byType(ListView), const Offset(0, -700)); - await tester.pumpAndSettle(); - await tester.tap(find.text('App Language')); - await tester.pumpAndSettle(); - await tester.tap(find.text('简体中文').last); - await tester.pumpAndSettle(); - expect(find.text('我的'), findsWidgets); - await tester.drag(find.byType(ListView), const Offset(0, 700)); - await tester.pumpAndSettle(); + expect(find.text('App Language'), findsNothing); + expect(find.text('简体中文'), findsNothing); expect(find.byKey(const Key('mine-wallet')), findsOneWidget); - expect(find.text('首页'), findsOneWidget); - await tester.tap(find.text('发现')); + expect(find.text('Home'), findsWidgets); + await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.text('推荐'), findsOneWidget); + expect(find.text('Discover'), findsWidgets); }); testWidgets( @@ -297,7 +289,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.text('Recommended'), findsOneWidget); + expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect( @@ -319,11 +312,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -320)); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mine-library-tab-1'))); - await tester.pumpAndSettle(); - expect(find.text('No shows here yet.'), findsOneWidget); + expect(find.byKey(const Key('mine-menu')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); @@ -353,16 +342,11 @@ void main() { await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -700)); - await tester.pumpAndSettle(); - expect(find.text('History'), findsWidgets); + expect(find.byKey(const Key('mine-history')), findsOneWidget); expect( - Navigator.of(tester.element(find.text('History').first)).canPop(), + Navigator.of(tester.element(find.byKey(const Key('mine-menu')))).canPop(), isFalse, ); - await tester.tap(find.byKey(const Key('mine-library-tab-1'))); - await tester.pumpAndSettle(); - expect(find.text('No shows here yet.'), findsOneWidget); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); @@ -376,11 +360,7 @@ void main() { ); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -400)); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mine-library-tab-1'))); - await tester.pumpAndSettle(); - expect(find.text('No shows here yet.'), findsOneWidget); + expect(find.byKey(const Key('mine-menu')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); @@ -625,9 +605,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -320)); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mine-library-tab-1'))); + await tester.tap(find.byKey(const Key('mine-watchlist'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsWidgets); From 12e57de3b32cd4395b59a9fd2e2baebe2de06a08 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 05:29:21 +0800 Subject: [PATCH 015/124] =?UTF-8?q?chore(l10n):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=B7=B2=E7=A7=BB=E9=99=A4=E8=AF=AD=E8=A8=80=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 7 ----- lib/l10n/app_id.arb | 7 ----- lib/l10n/app_localizations.dart | 42 ------------------------------ lib/l10n/app_localizations_en.dart | 21 --------------- lib/l10n/app_localizations_id.dart | 21 --------------- lib/l10n/app_localizations_zh.dart | 21 --------------- lib/l10n/app_zh.arb | 7 ----- 7 files changed, 126 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 94333ca..95aa570 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -83,7 +83,6 @@ "completed": "Completed", "noSavedShows": "No shows here yet.", "preferences": "Preferences", - "appLanguage": "App Language", "followingTab": "Following", "animatedTab": "Animated", "recommendedTab": "Recommended", @@ -245,8 +244,6 @@ "captionsOff": "Captions off", "englishCaptions": "English captions", "chineseCaptions": "Chinese captions", - "language": "Language", - "systemLanguage": "System", "english": "English", "simplifiedChinese": "简体中文", "bahasaIndonesia": "Bahasa Indonesia", @@ -534,10 +531,6 @@ "qualityAuto": "Auto", "qualityAutoHint": "HLS adjusts the bitrate automatically for your connection.", "searchFailed": "Search failed. Please try again.", - "languageFilter": "Language", - "languageChinese": "Chinese", - "languageIndonesian": "Indonesian", - "languageEnglish": "English", "searchResultCount": "{count} results", "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index c8b540d..b30bb89 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -83,7 +83,6 @@ "completed": "Selesai", "noSavedShows": "Belum ada tontonan di sini.", "preferences": "Preferensi", - "appLanguage": "Bahasa Aplikasi", "followingTab": "Mengikuti", "animatedTab": "Animasi", "recommendedTab": "Rekomendasi", @@ -245,8 +244,6 @@ "captionsOff": "Subtitle nonaktif", "englishCaptions": "Subtitle bahasa Inggris", "chineseCaptions": "Subtitle bahasa Mandarin", - "language": "Bahasa", - "systemLanguage": "Sistem", "english": "English", "simplifiedChinese": "简体中文", "bahasaIndonesia": "Bahasa Indonesia", @@ -532,10 +529,6 @@ "qualityAuto": "Otomatis", "qualityAutoHint": "HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.", "searchFailed": "Pencarian gagal. Coba lagi.", - "languageFilter": "Bahasa", - "languageChinese": "Mandarin", - "languageIndonesian": "Indonesia", - "languageEnglish": "Inggris", "searchResultCount": "{count} hasil", "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0a9e9d1..c814895 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -490,12 +490,6 @@ abstract class AppLocalizations { /// **'Preferences'** String get preferences; - /// No description provided for @appLanguage. - /// - /// In en, this message translates to: - /// **'App Language'** - String get appLanguage; - /// No description provided for @followingTab. /// /// In en, this message translates to: @@ -1060,18 +1054,6 @@ abstract class AppLocalizations { /// **'Chinese captions'** String get chineseCaptions; - /// No description provided for @language. - /// - /// In en, this message translates to: - /// **'Language'** - String get language; - - /// No description provided for @systemLanguage. - /// - /// In en, this message translates to: - /// **'System'** - String get systemLanguage; - /// No description provided for @english. /// /// In en, this message translates to: @@ -2434,30 +2416,6 @@ abstract class AppLocalizations { /// **'Search failed. Please try again.'** String get searchFailed; - /// No description provided for @languageFilter. - /// - /// In en, this message translates to: - /// **'Language'** - String get languageFilter; - - /// No description provided for @languageChinese. - /// - /// In en, this message translates to: - /// **'Chinese'** - String get languageChinese; - - /// No description provided for @languageIndonesian. - /// - /// In en, this message translates to: - /// **'Indonesian'** - String get languageIndonesian; - - /// No description provided for @languageEnglish. - /// - /// In en, this message translates to: - /// **'English'** - String get languageEnglish; - /// No description provided for @searchResultCount. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b45cf0c..b33f730 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -214,9 +214,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get preferences => 'Preferences'; - @override - String get appLanguage => 'App Language'; - @override String get followingTab => 'Following'; @@ -520,12 +517,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chineseCaptions => 'Chinese captions'; - @override - String get language => 'Language'; - - @override - String get systemLanguage => 'System'; - @override String get english => 'English'; @@ -1252,18 +1243,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get searchFailed => 'Search failed. Please try again.'; - @override - String get languageFilter => 'Language'; - - @override - String get languageChinese => 'Chinese'; - - @override - String get languageIndonesian => 'Indonesian'; - - @override - String get languageEnglish => 'English'; - @override String searchResultCount(int count) { return '$count results'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 6004c6a..88bef94 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -214,9 +214,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get preferences => 'Preferensi'; - @override - String get appLanguage => 'Bahasa Aplikasi'; - @override String get followingTab => 'Mengikuti'; @@ -522,12 +519,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get chineseCaptions => 'Subtitle bahasa Mandarin'; - @override - String get language => 'Bahasa'; - - @override - String get systemLanguage => 'Sistem'; - @override String get english => 'English'; @@ -1250,18 +1241,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get searchFailed => 'Pencarian gagal. Coba lagi.'; - @override - String get languageFilter => 'Bahasa'; - - @override - String get languageChinese => 'Mandarin'; - - @override - String get languageIndonesian => 'Indonesia'; - - @override - String get languageEnglish => 'Inggris'; - @override String searchResultCount(int count) { return '$count hasil'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 53e7a74..6a31d17 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -209,9 +209,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get preferences => '偏好设置'; - @override - String get appLanguage => '应用语言'; - @override String get followingTab => '关注'; @@ -512,12 +509,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get chineseCaptions => '简体中文字幕'; - @override - String get language => '界面语言'; - - @override - String get systemLanguage => '跟随系统'; - @override String get english => 'English'; @@ -1228,18 +1219,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get searchFailed => '搜索失败,请重试。'; - @override - String get languageFilter => '语言'; - - @override - String get languageChinese => '中文'; - - @override - String get languageIndonesian => '印尼语'; - - @override - String get languageEnglish => '英语'; - @override String searchResultCount(int count) { return '$count 个结果'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f637bac..f63ec02 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -83,7 +83,6 @@ "completed": "已看完", "noSavedShows": "这里还没有剧集。", "preferences": "偏好设置", - "appLanguage": "应用语言", "followingTab": "关注", "animatedTab": "漫剧", "recommendedTab": "推荐", @@ -207,8 +206,6 @@ "captionsOff": "关闭字幕", "englishCaptions": "英文字幕", "chineseCaptions": "简体中文字幕", - "language": "界面语言", - "systemLanguage": "跟随系统", "english": "English", "simplifiedChinese": "简体中文", "bahasaIndonesia": "印尼语", @@ -494,10 +491,6 @@ "qualityAuto": "自动", "qualityAutoHint": "HLS 会根据当前网络自动切换码率。", "searchFailed": "搜索失败,请重试。", - "languageFilter": "语言", - "languageChinese": "中文", - "languageIndonesian": "印尼语", - "languageEnglish": "英语", "searchResultCount": "{count} 个结果", "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } } From 6022d789ede8c1072645cd0573ec2a77417ea75b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 09:11:45 +0800 Subject: [PATCH 016/124] =?UTF-8?q?chore(tools):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=9F=AD=E5=89=A7=E8=B5=84=E6=BA=90=E6=89=B9=E9=87=8F=E4=B8=8A?= =?UTF-8?q?=E7=BA=BF=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/publish-drama-assets.ps1 | 578 +++++++++++++++++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 tools/publish-drama-assets.ps1 diff --git a/tools/publish-drama-assets.ps1 b/tools/publish-drama-assets.ps1 new file mode 100644 index 0000000..7324eb2 --- /dev/null +++ b/tools/publish-drama-assets.ps1 @@ -0,0 +1,578 @@ +param( + [string]$BaseUrl = 'http://127.0.0.1:8080', + [string]$AppBaseUrl = 'http://127.0.0.1:8081', + [string]$AssetRoot = 'C:\work\drama_assets', + [string]$AdminUser = 'admin', + [string]$AdminPassword = 'admin123', + [string]$ProgressPath = 'C:/Users/Administrator/Desktop/ReelShorts/FruitServer/docs/plans/PROGRESS.md', + [string]$StatePath = 'C:/work/ReelShort/docs/plans/fruitshorts-publish-state.json', + [string]$ReportPath = 'C:/work/ReelShort/docs/plans/fruitshorts-publish-report.json', + [string]$DbContainer = 'fruitshorts-db', + [string]$DbUser = 'fruitshorts', + [string]$DbName = 'fruitshorts', + [string]$WorkerImage = 'fruitshorts/transcode-worker:latest', + [int]$TranscodeTimeoutMinutes = 120 +) + +$ErrorActionPreference = 'Stop' +$script:Utf8 = [System.Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = $script:Utf8 +$OutputEncoding = $script:Utf8 +Add-Type -AssemblyName System.Net.Http + +$script:BaseUrl = $BaseUrl.TrimEnd('/') +$script:AppBaseUrl = $AppBaseUrl.TrimEnd('/') +$script:AssetRoot = $AssetRoot.TrimEnd('\') +$script:ProgressPath = [System.IO.Path]::GetFullPath($ProgressPath) +$script:StatePath = [System.IO.Path]::GetFullPath($StatePath) +$script:ReportPath = [System.IO.Path]::GetFullPath($ReportPath) +$script:DbContainer = $DbContainer +$script:DbUser = $DbUser +$script:DbName = $DbName +$script:WorkerImage = $WorkerImage + +$script:Handler = [System.Net.Http.HttpClientHandler]::new() +$script:Http = [System.Net.Http.HttpClient]::new($script:Handler) +$script:Http.Timeout = [TimeSpan]::FromMinutes(15) + +function Ensure-ParentDirectory { + param([string]$Path) + $parent = Split-Path -Parent $Path + if ($parent -and -not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Force -Path $parent | Out-Null + } +} + +function Protect-Token { + param([string]$Raw) + if ([string]::IsNullOrEmpty($Raw)) { return $Raw } + return [regex]::Replace($Raw, '("token"\s*:\s*")[^"]+(")', '$1***$2') +} + +function Write-ProgressLog { + param([string]$Step, [string]$CommandText, [string]$Raw, [string]$Interpretation) + $safeRaw = Protect-Token $Raw + $text = [System.Text.StringBuilder]::new() + [void]$text.AppendLine("") + [void]$text.AppendLine("### $([DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss')) - $Step") + [void]$text.AppendLine("Command:") + [void]$text.AppendLine('```powershell') + [void]$text.AppendLine($CommandText) + [void]$text.AppendLine('```') + [void]$text.AppendLine("Raw output:") + [void]$text.AppendLine('```json') + [void]$text.AppendLine($safeRaw) + [void]$text.AppendLine('```') + [void]$text.AppendLine("Interpretation: $Interpretation") + Ensure-ParentDirectory $script:ProgressPath + [System.IO.File]::AppendAllText($script:ProgressPath, $text.ToString(), $script:Utf8) +} + +function Invoke-ApiRaw { + param([string]$Method, [string]$Url, [string]$Token, $Body = $null, $Content = $null, [hashtable]$Headers = @{}) + $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method), $Url) + if ($Token) { + $request.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token) + } + foreach ($key in $Headers.Keys) { + [void]$request.Headers.TryAddWithoutValidation($key, [string]$Headers[$key]) + } + if ($null -ne $Content) { + $request.Content = $Content + } elseif ($null -ne $Body) { + $json = $Body | ConvertTo-Json -Depth 20 -Compress + $request.Content = [System.Net.Http.StringContent]::new($json, $script:Utf8, 'application/json') + } + try { + $response = $script:Http.SendAsync($request).GetAwaiter().GetResult() + $raw = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + if (-not $response.IsSuccessStatusCode) { + throw "HTTP $([int]$response.StatusCode) $($response.ReasonPhrase): $raw" + } + return $raw + } finally { + $request.Dispose() + } +} + +function Invoke-Api { + param([string]$Method, [string]$Path, [string]$Token, $Body = $null, [string]$Step, [string]$CommandText, [string]$Interpretation, [string]$BaseOverride) + $base = if ($BaseOverride) { $BaseOverride } else { $script:BaseUrl } + $url = $base + $Path + if (-not $CommandText) { + $CommandText = "$Method $url" + if ($null -ne $Body) { + $CommandText += "`nBODY " + ($Body | ConvertTo-Json -Depth 20 -Compress) + } + } + $raw = Invoke-ApiRaw -Method $Method -Url $url -Token $Token -Body $Body + Write-ProgressLog -Step $Step -CommandText $CommandText -Raw $raw -Interpretation $Interpretation + $obj = $raw | ConvertFrom-Json + if ([int]$obj.code -ne 0) { + throw "API failed at '$Step': code=$($obj.code) message=$($obj.message)" + } + return $obj.data +} +function ConvertTo-HashtableDeep { + param($Value) + if ($null -eq $Value) { return $null } + if ($Value -is [System.Collections.IDictionary]) { + $result = @{} + foreach ($key in $Value.Keys) { $result[[string]$key] = ConvertTo-HashtableDeep $Value[$key] } + return $result + } + if ($Value -is [System.Management.Automation.PSCustomObject]) { + $result = @{} + foreach ($property in $Value.PSObject.Properties) { $result[$property.Name] = ConvertTo-HashtableDeep $property.Value } + return $result + } + return $Value +} + +function Load-State { + if (Test-Path -LiteralPath $script:StatePath) { + $raw = [System.IO.File]::ReadAllText($script:StatePath, $script:Utf8) + if (-not [string]::IsNullOrWhiteSpace($raw)) { + return ConvertTo-HashtableDeep ($raw | ConvertFrom-Json) + } + } + return @{ runStartedAt = [DateTime]::Now.ToString('o'); dramas = @{} } +} + +function Save-State { + Ensure-ParentDirectory $script:StatePath + $json = $script:State | ConvertTo-Json -Depth 30 + [System.IO.File]::WriteAllText($script:StatePath, $json, $script:Utf8) +} + +function Invoke-Psql { + param([string]$Sql, [string]$Step, [string]$Interpretation, [switch]$Log) + $commandText = "docker exec $script:DbContainer psql -U $script:DbUser -d $script:DbName -A -t -F `"`t`" -c `"$Sql`"" + $output = @(& docker exec $script:DbContainer psql -U $script:DbUser -d $script:DbName -A -t -F "`t" -c $Sql) + $exitCode = $LASTEXITCODE + $raw = ($output | Out-String).TrimEnd() + if ($Log) { + Write-ProgressLog -Step $Step -CommandText $commandText -Raw $raw -Interpretation $Interpretation + } + if ($exitCode -ne 0) { throw "psql failed at '$Step': $raw" } + return [pscustomobject]@{ Raw = $raw; Lines = $output } +} + +function Get-VideoDirectory { + $candidates = @(Get-ChildItem -LiteralPath $script:AssetRoot -Directory | Where-Object { $_.Name -notmatch '^\d{2}_' }) + foreach ($candidate in $candidates) { + $firstMp4 = Get-ChildItem -LiteralPath $candidate.FullName -File -Filter '*.mp4' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($firstMp4) { return $candidate } + } + throw "Video directory with mp4 files was not found under $script:AssetRoot" +} + +function Get-DramaDirectories { + return @(Get-ChildItem -LiteralPath $script:AssetRoot -Directory | Where-Object { $_.Name -match '^\d{2}_' } | Sort-Object Name) +} + +function Parse-DramaName { + param([string]$DirectoryName) + $match = [regex]::Match($DirectoryName, '^\d{2}_(?.+)_(?[^_]+)$') + if (-not $match.Success) { throw "Cannot parse drama directory name: $DirectoryName" } + $slug = $match.Groups['slug'].Value + $zh = $match.Groups['zh'].Value + $words = @($slug -split '_' | Where-Object { $_ }) + $titleWords = @() + foreach ($word in $words) { + if ($word -cmatch '^[A-Z]{2,}$' -or $word -match '^(ceo|cto|cfo|coo|cio|ai|tv|vip|mvp)$') { $titleWords += $word.ToUpperInvariant() } + elseif ($word.Length -gt 0) { $titleWords += $word.Substring(0, 1).ToUpperInvariant() + $word.Substring(1) } + } + return [pscustomobject]@{ Title = ($titleWords -join ' '); Chinese = $zh } +} + +function Get-VideoDurationMap { + param([System.IO.DirectoryInfo]$VideoDirectory) + $map = @{} + $shell = New-Object -ComObject Shell.Application + $folder = $shell.Namespace($VideoDirectory.FullName) + foreach ($file in Get-ChildItem -LiteralPath $VideoDirectory.FullName -File -Filter '*.mp4') { + $seconds = 0 + try { + $item = $folder.ParseName($file.Name) + $durationText = [string]$folder.GetDetailsOf($item, 27) + $span = [TimeSpan]::Zero + if ([TimeSpan]::TryParse($durationText, [ref]$span)) { $seconds = [int][Math]::Round($span.TotalSeconds) } + } catch { $seconds = 0 } + $map[$file.Name] = $seconds + } + $raw = (($map.GetEnumerator() | Sort-Object Name | ForEach-Object { "$($_.Name)`t$($_.Value)" }) -join "`n") + Write-ProgressLog -Step 'probe video durations' -CommandText 'Windows Shell.Application media metadata (duration column)' -Raw $raw -Interpretation "Read durations for $($map.Count) mp4 files from Windows media metadata." + return $map +} + +function Send-Chunk { + param([string]$UploadId, [int]$Index, [byte[]]$Bytes, [string]$Token, [string]$FileName) + $form = [System.Net.Http.MultipartFormDataContent]::new() + try { + $content = [System.Net.Http.ByteArrayContent]::new($Bytes) + $content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/octet-stream') + $form.Add($content, 'file', $FileName) + $url = "$script:BaseUrl/admin/media/upload/chunk?uploadId=$([Uri]::EscapeDataString($UploadId))&index=$Index" + $raw = Invoke-ApiRaw -Method 'POST' -Url $url -Token $Token -Content $form + Write-ProgressLog -Step "upload chunk $($Index + 1)" -CommandText "POST $url (multipart file=$FileName, bytes=$($Bytes.Length))" -Raw $raw -Interpretation 'Chunk accepted by the upload session.' + $obj = $raw | ConvertFrom-Json + if ([int]$obj.code -ne 0) { throw "Chunk upload failed: $($obj.message)" } + } finally { + $form.Dispose() + } +} + +function Upload-Media { + param([string]$FilePath, [string]$BizType, [string]$Token, [int]$DurationSec = 0) + $file = Get-Item -LiteralPath $FilePath + $initBody = @{ fileName = $file.Name; fileSize = $file.Length; bizType = $BizType; chunkSize = 5242880 } + $init = Invoke-Api -Method 'POST' -Path '/admin/media/upload/init' -Token $Token -Body $initBody -Step "init upload $($file.Name)" -Interpretation "Created upload session for $BizType file $($file.Name)." + $uploadId = [string]$init.uploadId + $chunkSize = [int]$init.chunkSize + $totalChunks = [int]$init.totalChunks + $stream = [System.IO.File]::OpenRead($FilePath) + try { + for ($index = 0; $index -lt $totalChunks; $index++) { + $remaining = [int64]$file.Length - [int64]$stream.Position + $length = [int][Math]::Min([int64]$chunkSize, $remaining) + if ($length -le 0) { throw "Unexpected empty chunk at index $index for $FilePath" } + $buffer = New-Object byte[] $length + $read = 0 + while ($read -lt $length) { + $count = $stream.Read($buffer, $read, $length - $read) + if ($count -le 0) { break } + $read += $count + } + if ($read -ne $length) { throw "Short read at chunk $index for $FilePath ($read/$length)" } + Send-Chunk -UploadId $uploadId -Index $index -Bytes $buffer -Token $Token -FileName ("chunk_{0}.bin" -f $index) + } + } finally { + $stream.Dispose() + } + $completeBody = @{ uploadId = $uploadId; durationSec = $DurationSec } + $asset = Invoke-Api -Method 'POST' -Path '/admin/media/upload/complete' -Token $Token -Body $completeBody -Step "complete upload $($file.Name)" -Interpretation "Merged uploaded chunks into media asset $($file.Name)." + return $asset +} + +function Find-ExistingDramas { + param([string]$Token) + $page = Invoke-Api -Method 'GET' -Path '/admin/dramas/page?pageIndex=1&pageSize=200' -Token $Token -Step 'list existing dramas' -Interpretation 'Loaded existing dramas for idempotent title matching.' + $map = @{} + foreach ($drama in @($page.list)) { + $title = [string]$drama.title + if (-not $map.ContainsKey($title)) { $map[$title] = $drama } + } + return $map +} + +function Get-ExistingEpisodes { + param([string]$Token, [string]$DramaId) + $episodes = Invoke-Api -Method 'GET' -Path "/admin/episodes?dramaId=$DramaId" -Token $Token -Step "list episodes drama $DramaId" -Interpretation 'Loaded existing episodes for resume and duplicate prevention.' + $map = @{} + foreach ($episode in @($episodes)) { $map[[string]$episode.number] = $episode } + return $map +} + +function Ensure-Published { + param([string]$Token, [string]$DramaId) + $drama = Invoke-Api -Method 'GET' -Path "/admin/dramas/$DramaId" -Token $Token -Step "read drama $DramaId status" -Interpretation 'Read current drama status before review transitions.' + $status = [string]$drama.status + if ($status -eq 'draft' -or $status -eq 'offline') { + Invoke-Api -Method 'POST' -Path "/admin/dramas/$DramaId/submit" -Token $Token -Body $null -Step "submit drama $DramaId" -Interpretation 'Drama submitted for review.' | Out-Null + $status = 'reviewing' + } + if ($status -eq 'reviewing') { + Invoke-Api -Method 'POST' -Path "/admin/dramas/$DramaId/approve" -Token $Token -Body $null -Step "approve drama $DramaId" -Interpretation 'Drama review approved.' | Out-Null + $status = 'approved' + } + if ($status -eq 'approved') { + Invoke-Api -Method 'POST' -Path "/admin/dramas/$DramaId/publish" -Token $Token -Body $null -Step "publish drama $DramaId" -Interpretation 'Drama published; draft episodes are promoted to published.' | Out-Null + $status = 'published' + } + if ($status -ne 'published') { throw "Drama $DramaId ended in unexpected status: $status" } + return $status +} +function Get-TranscodeSnapshot { + param([string[]]$AssetIds) + $inList = ($AssetIds | ForEach-Object { "'$_'" }) -join ',' + $sql = "SELECT media_asset_id,status,retry_count,coalesce(error_msg,'') FROM transcode_task WHERE media_asset_id IN ($inList) ORDER BY media_asset_id;" + $result = Invoke-Psql -Sql $sql -Step 'transcode status snapshot' -Interpretation 'Read transcode_task status for all uploaded videos.' -Log + $rows = @() + foreach ($line in $result.Lines) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $parts = $line -split "`t", 4 + $rows += [pscustomobject]@{ + AssetId = [string]$parts[0] + Status = [string]$parts[1] + RetryCount = if ($parts.Count -gt 2) { [string]$parts[2] } else { '' } + Error = if ($parts.Count -gt 3) { [string]$parts[3] } else { '' } + } + } + return [pscustomobject]@{ Rows = $rows; Raw = $result.Raw; Command = $sql } +} + +function Wait-Transcode { + param([string[]]$AssetIds) + $deadline = [DateTime]::Now.AddMinutes($TranscodeTimeoutMinutes) + $lastKey = '' + $lastLogged = [DateTime]::MinValue + $retried = $false + while ($true) { + $snapshot = Get-TranscodeSnapshot -AssetIds $AssetIds + $rows = @($snapshot.Rows) + $queued = @($rows | Where-Object { $_.Status -eq 'QUEUED' }).Count + $running = @($rows | Where-Object { $_.Status -eq 'RUNNING' }).Count + $success = @($rows | Where-Object { $_.Status -eq 'SUCCESS' }).Count + $failedRows = @($rows | Where-Object { $_.Status -eq 'FAILED' }) + $failed = $failedRows.Count + $foundIds = @($rows | ForEach-Object { $_.AssetId }) + $missing = @($AssetIds | Where-Object { $_ -notin $foundIds }) + $key = "queued=$queued;running=$running;success=$success;failed=$failed;missing=$($missing.Count)" + Write-Host "TRANSCODE $key" + if ($key -ne $lastKey -or ([DateTime]::Now - $lastLogged).TotalSeconds -ge 60) { + $interpretation = "Transcode progress: $success/$($AssetIds.Count) success, $running running, $queued queued, $failed failed, $($missing.Count) missing task rows." + Write-ProgressLog -Step 'wait for transcode' -CommandText "docker exec $script:DbContainer psql -U $script:DbUser -d $script:DbName -A -t -F `"`t`" -c `"$($snapshot.Command)`"" -Raw $snapshot.Raw -Interpretation $interpretation + $lastKey = $key + $lastLogged = [DateTime]::Now + } + if ($queued -eq 0 -and $running -eq 0) { + if ($failed -eq 0 -and $missing.Count -eq 0) { break } + if (-not $retried -and $failed -gt 0) { + $failedIds = @($failedRows | ForEach-Object { $_.AssetId }) + $inList = ($failedIds | ForEach-Object { "'$_'" }) -join ',' + $sql = "UPDATE transcode_task SET status='QUEUED', retry_count=0, error_msg='', started_at=NULL, finished_at=NULL WHERE media_asset_id IN ($inList);" + Invoke-Psql -Sql $sql -Step 'requeue failed transcode tasks' -Interpretation 'Requeued failed transcode tasks once for automatic recovery.' -Log | Out-Null + $retried = $true + Start-Sleep -Seconds 5 + continue + } + break + } + if ([DateTime]::Now -gt $deadline) { throw "Transcode did not finish before timeout: $key" } + Start-Sleep -Seconds 10 + } +} + +function Backfill-Links { + param([object[]]$Mappings) + $builder = [System.Text.StringBuilder]::new() + foreach ($mapping in @($Mappings)) { + foreach ($value in @($mapping.AssetId, $mapping.DramaId, $mapping.EpisodeId)) { + if ([string]$value -notmatch '^\d+$') { throw "Unsafe numeric id: $value" } + } + [void]$builder.AppendLine("UPDATE transcode_task SET drama_id=$($mapping.DramaId), episode_id=$($mapping.EpisodeId) WHERE media_asset_id=$($mapping.AssetId);") + [void]$builder.AppendLine("UPDATE media_rendition SET drama_id=$($mapping.DramaId), episode_id=$($mapping.EpisodeId) WHERE media_asset_id=$($mapping.AssetId);") + [void]$builder.AppendLine("UPDATE drama_episode SET video_asset_id=$($mapping.AssetId), hls_url=(SELECT hls_url FROM media_asset WHERE id=$($mapping.AssetId)), duration_sec=(SELECT duration_sec FROM media_asset WHERE id=$($mapping.AssetId)) WHERE id=$($mapping.EpisodeId);") + } + if ($builder.Length -eq 0) { + Write-ProgressLog -Step 'backfill episode media links' -CommandText '(skipped)' -Raw '' -Interpretation 'No successful transcode mapping to backfill.' + return + } + Invoke-Psql -Sql $builder.ToString() -Step 'backfill episode media links' -Interpretation 'Linked successful HLS renditions and transcode tasks back to drama episodes for client playback.' -Log | Out-Null +} + +function Get-HttpResource { + param([string]$Url, [hashtable]$Headers = @{}) + $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $Url) + foreach ($key in $Headers.Keys) { [void]$request.Headers.TryAddWithoutValidation($key, [string]$Headers[$key]) } + try { + $response = $script:Http.SendAsync($request).GetAwaiter().GetResult() + $bytes = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() + $contentType = if ($response.Content.Headers.ContentType) { $response.Content.Headers.ContentType.ToString() } else { '' } + return [pscustomobject]@{ Status = [int]$response.StatusCode; ContentType = $contentType; Bytes = $bytes; Text = [System.Text.Encoding]::UTF8.GetString($bytes) } + } finally { + $request.Dispose() + } +} +function Verify-Client { + param([object[]]$DramaRecords, [string[]]$ExpectedTitles) + $feedRaw = Invoke-ApiRaw -Method 'GET' -Url "$script:AppBaseUrl/app/home/feed" + $feed = $feedRaw | ConvertFrom-Json + $feedTitles = @() + foreach ($section in @($feed.data.sections)) { + foreach ($drama in @($section.dramas)) { $feedTitles += [string]$drama.title } + } + $feedTitles = @($feedTitles | Select-Object -Unique) + $missing = @($ExpectedTitles | Where-Object { $_ -notin $feedTitles }) + Write-ProgressLog -Step 'client feed verification' -CommandText "GET $script:AppBaseUrl/app/home/feed" -Raw $feedRaw -Interpretation "Home feed contains $($feedTitles.Count) unique titles; missing expected titles: $(@($missing) -join ', ')." + if ($missing.Count -gt 0) { throw "Home feed missing dramas: $($missing -join ', ')" } + + $sampleDrama = @($DramaRecords | Where-Object { $_.Episodes.Count -gt 0 })[0] + $sampleEpisode = @($sampleDrama.Episodes | Sort-Object Number)[0] + $playRaw = Invoke-ApiRaw -Method 'GET' -Url "$script:AppBaseUrl/app/episodes/$($sampleEpisode.Id)/play" -Headers @{ 'X-Device-Id' = 'codex-batch-verify' } + $play = $playRaw | ConvertFrom-Json + Write-ProgressLog -Step 'client play verification' -CommandText "GET $script:AppBaseUrl/app/episodes/$($sampleEpisode.Id)/play (X-Device-Id: codex-batch-verify)" -Raw $playRaw -Interpretation "Playback ticket for episode $($sampleEpisode.Id) of drama $($sampleDrama.Title)." + if ($play.data.accessible -ne $true) { throw "Episode $($sampleEpisode.Id) is not accessible." } + if ([string]::IsNullOrWhiteSpace([string]$play.data.playUrl)) { throw "Episode $($sampleEpisode.Id) returned an empty playUrl." } + + $masterUrl = [string]$play.data.playUrl + if (-not $masterUrl.StartsWith('http')) { $masterUrl = $script:AppBaseUrl + $masterUrl } + $master = Get-HttpResource -Url $masterUrl + Write-ProgressLog -Step 'fetch HLS master playlist' -CommandText "GET $masterUrl" -Raw ("HTTP $($master.Status) $($master.ContentType)`n" + $master.Text) -Interpretation 'Fetched the signed HLS master playlist returned by the play API.' + if ($master.Status -ne 200) { throw "HLS master returned HTTP $($master.Status)." } + + $variantPath = @($master.Text -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })[0] + $variantUrl = [Uri]::new([Uri]$masterUrl, $variantPath).AbsoluteUri + $variant = Get-HttpResource -Url $variantUrl + Write-ProgressLog -Step 'fetch HLS variant playlist' -CommandText "GET $variantUrl" -Raw ("HTTP $($variant.Status) $($variant.ContentType)`n" + $variant.Text) -Interpretation 'Fetched the first variant playlist referenced by the master playlist.' + if ($variant.Status -ne 200) { throw "HLS variant returned HTTP $($variant.Status)." } + + $segmentPath = @($variant.Text -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })[0] + $segmentUrl = [Uri]::new([Uri]$variantUrl, $segmentPath).AbsoluteUri + $segment = Get-HttpResource -Url $segmentUrl -Headers @{ Range = 'bytes=0-1023' } + Write-ProgressLog -Step 'fetch HLS media segment' -CommandText "GET $segmentUrl (Range: bytes=0-1023)" -Raw ("HTTP $($segment.Status) $($segment.ContentType) bytes=$($segment.Bytes.Length)") -Interpretation 'Fetched the first media segment bytes, proving the HLS path is playable.' + if (($segment.Status -ne 200 -and $segment.Status -ne 206) -or $segment.Bytes.Length -eq 0) { throw "HLS segment returned HTTP $($segment.Status) with $($segment.Bytes.Length) bytes." } + return [pscustomobject]@{ EpisodeId = $sampleEpisode.Id; MasterUrl = $masterUrl; FeedTitleCount = $feedTitles.Count } +} +Ensure-ParentDirectory $script:ProgressPath +Ensure-ParentDirectory $script:StatePath +Ensure-ParentDirectory $script:ReportPath + +Write-ProgressLog -Step 'batch publish started' -CommandText "powershell -File $PSCommandPath" -Raw ("AssetRoot=$script:AssetRoot`nBaseUrl=$script:BaseUrl`nAppBaseUrl=$script:AppBaseUrl`nStatePath=$script:StatePath") -Interpretation 'Starting the full batch publish workflow.' + +$loginCommand = "POST $script:BaseUrl/admin/auth/login`nBODY {username:$AdminUser,password:***}" +$loginRaw = Invoke-ApiRaw -Method 'POST' -Url "$script:BaseUrl/admin/auth/login" -Body @{ username = $AdminUser; password = $AdminPassword } +Write-ProgressLog -Step 'admin login' -CommandText $loginCommand -Raw $loginRaw -Interpretation 'Authenticated as admin and received a bearer token.' +$login = $loginRaw | ConvertFrom-Json +if ([int]$login.code -ne 0) { throw "Login failed: $($login.message)" } +$token = [string]$login.data.token +if ([string]::IsNullOrWhiteSpace($token)) { throw 'Login returned an empty token.' } + +$script:State = Load-State +$existingDramas = Find-ExistingDramas -Token $token +$videoDirectory = Get-VideoDirectory +$videoFiles = @(Get-ChildItem -LiteralPath $videoDirectory.FullName -File -Filter '*.mp4' | Sort-Object { [int]$_.BaseName }) +if ($videoFiles.Count -ne 45) { throw "Expected 45 mp4 files, found $($videoFiles.Count)." } +$durationMap = Get-VideoDurationMap -VideoDirectory $videoDirectory +$dramaDirectories = Get-DramaDirectories +if ($dramaDirectories.Count -ne 10) { throw "Expected 10 drama directories, found $($dramaDirectories.Count)." } + +$episodeCounts = @(5, 4, 5, 4, 5, 4, 5, 4, 5, 4) +$categoryPlan = @( + @{ CategoryId = 1; Genre = 'Urban Romance'; IsHot = $true }, + @{ CategoryId = 4; Genre = 'Revenge Suspense'; IsHot = $true }, + @{ CategoryId = 2; Genre = 'Rebirth Comeback'; IsHot = $true }, + @{ CategoryId = 3; Genre = 'Sweet Romance'; IsHot = $false }, + @{ CategoryId = 3; Genre = 'Sweet Romance'; IsHot = $false }, + @{ CategoryId = 1; Genre = 'Urban Romance'; IsHot = $false }, + @{ CategoryId = 2; Genre = 'Rebirth Comeback'; IsHot = $false }, + @{ CategoryId = 1; Genre = 'Urban Romance'; IsHot = $false }, + @{ CategoryId = 4; Genre = 'Revenge Suspense'; IsHot = $false }, + @{ CategoryId = 3; Genre = 'Sweet Romance'; IsHot = $false } +) + +$videoOffset = 0 +$dramaRecords = @() +$successMappings = @() +for ($dramaIndex = 0; $dramaIndex -lt $dramaDirectories.Count; $dramaIndex++) { + $directory = $dramaDirectories[$dramaIndex] + $parsed = Parse-DramaName -DirectoryName $directory.Name + $title = $parsed.Title + $chinese = $parsed.Chinese + $count = $episodeCounts[$dramaIndex] + $dramaVideos = @($videoFiles[$videoOffset..($videoOffset + $count - 1)]) + $videoOffset += $count + Write-Host "DRAMA $($dramaIndex + 1)/10 $title ($count episodes)" + + if (-not $script:State.dramas.ContainsKey($directory.Name)) { + $script:State.dramas[$directory.Name] = @{ title = $title; chinese = $chinese; coverUrl = ''; coverAssetId = ''; dramaId = ''; status = ''; episodes = @{} } + } + $record = $script:State.dramas[$directory.Name] + + if ([string]::IsNullOrWhiteSpace([string]$record.coverUrl)) { + $coverFile = Get-ChildItem -LiteralPath $directory.FullName -File | Where-Object { $_.Name -like '*9x16*' } | Select-Object -First 1 + if (-not $coverFile) { throw "Vertical 9x16 cover not found in $($directory.FullName)" } + $coverAsset = Upload-Media -FilePath $coverFile.FullName -BizType 'DRAMA_COVER' -Token $token + $record.coverUrl = [string]$coverAsset.url + $record.coverAssetId = [string]$coverAsset.id + Save-State + } + + $episodeAssets = @() + for ($localIndex = 0; $localIndex -lt $dramaVideos.Count; $localIndex++) { + $number = $localIndex + 1 + $videoFile = $dramaVideos[$localIndex] + $durationSec = 0 + if ($durationMap.ContainsKey($videoFile.Name)) { $durationSec = [int]$durationMap[$videoFile.Name] } + $key = [string]$number + if (-not $record.episodes.ContainsKey($key) -or [string]::IsNullOrWhiteSpace([string]$record.episodes[$key].videoUrl)) { + Write-Host " upload episode $number $($videoFile.Name)" + $asset = Upload-Media -FilePath $videoFile.FullName -BizType 'EPISODE' -Token $token -DurationSec $durationSec + $record.episodes[$key] = @{ number = $number; assetId = [string]$asset.id; videoUrl = [string]$asset.url; objectKey = [string]$asset.objectKey; durationSec = $durationSec; id = '' } + Save-State + } + $episodeAssets += [pscustomobject]@{ Number = $number; AssetId = [string]$record.episodes[$key].assetId; VideoUrl = [string]$record.episodes[$key].videoUrl; DurationSec = $durationSec; ObjectKey = [string]$record.episodes[$key].objectKey } + } + $existingDrama = $null + if ($existingDramas.ContainsKey($title)) { $existingDrama = $existingDramas[$title] } + if (-not [string]::IsNullOrWhiteSpace([string]$record.dramaId)) { + $dramaId = [string]$record.dramaId + } elseif ($existingDrama) { + $dramaId = [string]$existingDrama.id + $record.dramaId = $dramaId + $record.status = [string]$existingDrama.status + Save-State + } else { + $plan = $categoryPlan[$dramaIndex] + $createBody = @{ title = $title; subtitle = $chinese; categoryId = $plan.CategoryId; genre = $plan.Genre; description = $chinese; cover = [string]$record.coverUrl; language = 'id-ID'; freeEpisodes = $count; unlockCoin = 10; isHot = [bool]$plan.IsHot; sort = 100 - $dramaIndex } + $dramaId = [string](Invoke-Api -Method 'POST' -Path '/admin/dramas' -Token $token -Body $createBody -Step "create drama $title" -Interpretation "Created drama '$title' with Chinese synopsis '$chinese'.") + $record.dramaId = $dramaId + $record.status = 'draft' + $existingDramas[$title] = [pscustomobject]@{ id = $dramaId; title = $title; status = 'draft' } + Save-State + } + + $currentDrama = Invoke-Api -Method 'GET' -Path "/admin/dramas/$dramaId" -Token $token -Step "read drama $dramaId before episodes" -Interpretation 'Read drama metadata before creating or updating episodes.' + $currentStatus = [string]$currentDrama.status + $existingEpisodes = Get-ExistingEpisodes -Token $token -DramaId $dramaId + + foreach ($asset in $episodeAssets) { + $key = [string]$asset.Number + $episodeBody = @{ dramaId = $dramaId; number = $asset.Number; title = "Episode $($asset.Number)"; videoUrl = $asset.VideoUrl; durationSec = $asset.DurationSec; isFree = $true; unlockCoin = 10; sort = $asset.Number } + if ($currentStatus -eq 'published') { $episodeBody.status = 'published' } + if ($existingEpisodes.ContainsKey($key)) { + $episodeId = [string]$existingEpisodes[$key].id + Invoke-Api -Method 'PUT' -Path "/admin/episodes/$episodeId" -Token $token -Body $episodeBody -Step "update episode $episodeId" -Interpretation "Updated episode $($asset.Number) to free playback with unlockCoin=10." | Out-Null + } else { + $episodeId = [string](Invoke-Api -Method 'POST' -Path '/admin/episodes' -Token $token -Body $episodeBody -Step "create episode $($asset.Number) for $title" -Interpretation "Created episode $($asset.Number) for drama $title.") + } + $record.episodes[$key].id = $episodeId + $record.episodes[$key].durationSec = $asset.DurationSec + Save-State + } + + $finalStatus = Ensure-Published -Token $token -DramaId $dramaId + $record.status = $finalStatus + Save-State + + $episodeRecords = @() + foreach ($key in $record.episodes.Keys) { + $episode = $record.episodes[$key] + $episodeRecords += [pscustomobject]@{ Number = [int]$episode.number; Id = [string]$episode.id; AssetId = [string]$episode.assetId; VideoUrl = [string]$episode.videoUrl; DurationSec = [int]$episode.durationSec } + } + $episodeRecords = @($episodeRecords | Sort-Object Number) + $dramaRecords += [pscustomobject]@{ Directory = $directory.Name; Title = $title; Chinese = $chinese; DramaId = $dramaId; CoverUrl = [string]$record.coverUrl; Status = $finalStatus; Episodes = $episodeRecords } + foreach ($episode in $episodeRecords) { + $successMappings += [pscustomobject]@{ AssetId = $episode.AssetId; DramaId = $dramaId; EpisodeId = $episode.Id } + } +} + +Save-State +Write-Host "All 10 dramas published. Waiting for transcode..." +$assetIds = @($successMappings | ForEach-Object { $_.AssetId }) +Wait-Transcode -AssetIds $assetIds +Backfill-Links -Mappings $successMappings +$verification = Verify-Client -DramaRecords $dramaRecords -ExpectedTitles @($dramaRecords | ForEach-Object { $_.Title }) + +$idList = ($dramaRecords | ForEach-Object { "'$($_.DramaId)'" }) -join ',' +$summarySql = "SELECT d.id,d.title,d.status,count(e.id),count(*) FILTER (WHERE e.status='published'),count(*) FILTER (WHERE coalesce(e.hls_url,'')<>'') FROM drama d LEFT JOIN drama_episode e ON e.drama_id=d.id WHERE d.id IN ($idList) GROUP BY d.id,d.title,d.status ORDER BY d.title;" +$summary = Invoke-Psql -Sql $summarySql -Step 'final database summary' -Interpretation 'Final counts of episodes, published episodes and HLS-linked episodes for the 10 imported dramas.' -Log +Write-ProgressLog -Step 'final drama summary' -CommandText '(derived from state)' -Raw (($dramaRecords | ConvertTo-Json -Depth 20)) -Interpretation 'Final imported drama records including ids, covers, statuses and episode mappings.' + +$report = @{ finishedAt = [DateTime]::Now.ToString('o'); dramas = $dramaRecords; verification = $verification; databaseSummary = $summary.Raw; statePath = $script:StatePath } +Ensure-ParentDirectory $script:ReportPath +[System.IO.File]::WriteAllText($script:ReportPath, ($report | ConvertTo-Json -Depth 30), $script:Utf8) + +Write-ProgressLog -Step 'batch publish completed' -CommandText 'script end' -Raw ("dramas=$($dramaRecords.Count)`nepisodes=$($assetIds.Count)`nsampleEpisode=$($verification.EpisodeId)`nfeedTitleCount=$($verification.FeedTitleCount)") -Interpretation "All import steps completed; client feed and HLS playback verified. Report: $script:ReportPath" +Write-Host "BATCH_PUBLISH_OK dramas=$($dramaRecords.Count) episodes=$($assetIds.Count) sampleEpisode=$($verification.EpisodeId)" From 99c72e2c32e00666c9df979c51398cd1c7f1be25 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 09:26:12 +0800 Subject: [PATCH 017/124] =?UTF-8?q?chore(config):=20=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E6=94=B9=E4=B8=BA=E6=9C=AC=E6=9C=BA=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=20127.0.0.1:8081?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_config.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index c377453..486d36c 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -15,8 +15,9 @@ class AppConfig { /// `ipconfig getifaddr en0` 重新确认。默认值只改这一行。 static const String devHostBaseUrl = 'http://192.168.31.187:8081'; - /// 办公室内网(Wi-Fi)直连地址,双网络自动切换时的首选。 - static const String lanBaseUrl = 'http://192.168.31.250:8081'; + /// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机, + /// 探测可达时 App 直连本机服务;不可达时回落到公网地址。 + static const String lanBaseUrl = 'http://127.0.0.1:8081'; /// 公网地址(远程/移动网络访问时使用)。 static const String publicBaseUrl = 'http://39.162.50.99:8081'; From 959edc0a32baab9e1425d84901668a40cd43ec2f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 09:49:42 +0800 Subject: [PATCH 018/124] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=9B=9B=20Tab=20=E4=B8=8E=E6=B5=8F=E8=A7=88=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_dimens.dart | 10 + lib/core/widgets/app_bottom_nav.dart | 2 +- lib/core/widgets/remote_image.dart | 9 +- lib/l10n/app_en.arb | 30 + lib/l10n/app_id.arb | 30 + lib/l10n/app_localizations.dart | 114 +++ lib/l10n/app_localizations_en.dart | 61 ++ lib/l10n/app_localizations_id.dart | 62 ++ lib/l10n/app_localizations_zh.dart | 61 ++ lib/l10n/app_zh.arb | 30 + lib/pages/create/create_view.dart | 145 ++++ lib/pages/discover/discover_logic.dart | 16 + lib/pages/discover/discover_types.dart | 7 +- lib/pages/discover/discover_view.dart | 236 +++--- .../discover/widgets/discover_header.dart | 99 +-- .../widgets/discover_search_field.dart | 4 +- lib/pages/discover/widgets/drama_grid.dart | 72 +- .../widgets/feed_banner_carousel.dart | 43 +- lib/pages/home/home_browse_logic.dart | 70 ++ lib/pages/home/home_browse_view.dart | 333 +++++++++ lib/pages/home/home_logic.dart | 25 +- lib/pages/home/home_view.dart | 90 +-- .../home/widgets/home_browse_header.dart | 134 ++++ .../home/widgets/home_continue_watching.dart | 147 ++++ lib/pages/home/widgets/home_menu_sheet.dart | 2 +- lib/pages/mine/mine_types.dart | 2 +- lib/pages/mine/mine_view.dart | 13 +- lib/pages/player/player_view.dart | 185 ++--- test/account/mine_account_entry_test.dart | 4 +- test/discover/discover_banner_test.dart | 12 +- test/discover/home_all_dramas_grid_test.dart | 35 +- test/home/home_logic_test.dart | 20 +- test/home/home_nav_ui_test.dart | 40 +- test/home/home_preview_layout_test.dart | 54 +- test/home/k28_a_navigation_test.dart | 206 ++++++ test/mine/mine_preview_layout_test.dart | 2 +- test/mine/settings_page_test.dart | 2 +- test/notice/notices_entry_test.dart | 2 +- test/player/player_fullscreen_test.dart | 222 +----- test/watch/watch_history_provider_test.dart | 2 +- test/widget_test.dart | 694 +++++------------- 41 files changed, 2148 insertions(+), 1179 deletions(-) create mode 100644 lib/pages/create/create_view.dart create mode 100644 lib/pages/home/home_browse_logic.dart create mode 100644 lib/pages/home/home_browse_view.dart create mode 100644 lib/pages/home/widgets/home_browse_header.dart create mode 100644 lib/pages/home/widgets/home_continue_watching.dart create mode 100644 test/home/k28_a_navigation_test.dart diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 9bc0cb6..32bfcda 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -42,6 +42,8 @@ abstract final class AppDimens { static const double buttonMedium = 40; static const double buttonLarge = 44; static const double tapTarget = 44; + static const double contentHeaderHeight = 46; + static const double filterChipHeight = 34; static const double navHeight = 54; static const double navIcon = 17; @@ -58,4 +60,12 @@ abstract final class AppDimens { static const double accountLabel = 11; static const double watchPlaceholderHeight = 114; + static const double heroBannerHeight = 176; + static const double continueCoverWidth = 52; + static const double continueCoverHeight = 66; + + static const double cardMeta = 9; + static const double cardViews = 9; + static const double createIcon = 22; + static const double createArrow = 18; } diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index 898d032..880ae8f 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -26,7 +26,7 @@ class AppBottomNavItem { final bool selected; } -/// Shared 3-tab navigation used by all root tabs and the player fallback. +/// Shared bottom navigation used by the root shell and standalone tabs. class AppBottomNav extends StatelessWidget { const AppBottomNav({ super.key, diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index 455a342..fd3a490 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -1,3 +1,5 @@ +// Author: fruit.com.cn + import 'package:flutter/material.dart'; import '../config/app_config.dart'; @@ -13,6 +15,7 @@ class RemoteImage extends StatelessWidget { this.fit = BoxFit.cover, this.showFallbackIcon = false, this.fallbackIconKey, + this.fallbackAspectRatio, }); final String path; @@ -21,6 +24,7 @@ class RemoteImage extends StatelessWidget { final BoxFit fit; final bool showFallbackIcon; final Key? fallbackIconKey; + final double? fallbackAspectRatio; @override Widget build(BuildContext context) { @@ -40,7 +44,7 @@ class RemoteImage extends StatelessWidget { } Widget _fallback() { - return Container( + final fallback = Container( width: width, height: height, color: AppColors.bar, @@ -54,5 +58,8 @@ class RemoteImage extends StatelessWidget { ) : null, ); + final ratio = fallbackAspectRatio; + if (ratio == null) return fallback; + return AspectRatio(aspectRatio: ratio, child: fallback); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 95aa570..9bc68f1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -9,6 +9,8 @@ "demoTitle": "The Signal", "demoSynopsis": "A short local video preview. More episodes can be added when media is available.", "homeTab": "Home", + "theaterTab": "Theater", + "createTab": "Create", "openQuickMenu": "Open quick menu", "quickAccess": "Quick access", "currentSeries": "Current series", @@ -29,6 +31,7 @@ "filter": "Filter", "topCharts": "Top Charts", "newReleases": "New Releases", + "newDramas": "New", "trendingMicroDramas": "Trending Micro-Dramas", "seeAll": "See All", "trendingSubtitle": "Most binge-watched this week", @@ -66,6 +69,19 @@ "localViewer": "Local Viewer", "localDemoProfile": "Local demo profile", "continueWatching": "Continue Watching", + "episodeRemaining": "Episode {episode} · {minutes} min left", + "@episodeRemaining": { + "placeholders": { + "episode": {"type": "int"}, + "minutes": {"type": "int"} + } + }, + "viewCountLabel": "{count} views", + "@viewCountLabel": { + "placeholders": { + "count": {"type": "int"} + } + }, "watchedPercent": "Watched {percent}%", "@watchedPercent": { "placeholders": { @@ -88,6 +104,20 @@ "recommendedTab": "Recommended", "noCategoryDramas": "No dramas in this category yet.", "allDramas": "All Dramas", + "allLanguages": "All languages", + "bahasaMelayu": "Malay", + "createSubtitle": "Stories become short dramas", + "createAiTitle": "AI production", + "createAiSubtitle": "Start from a story or an idea", + "createUploadTitle": "Upload and publish", + "createUploadSubtitle": "Publish a finished short drama", + "weekdayMon": "Mon", + "weekdayTue": "Tue", + "weekdayWed": "Wed", + "weekdayThu": "Thu", + "weekdayFri": "Fri", + "weekdaySat": "Sat", + "weekdaySun": "Sun", "trendingTab": "Trending", "liveActionTab": "Live Action", "search": "Search", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index b30bb89..63617df 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -9,6 +9,8 @@ "demoTitle": "Sinyal", "demoSynopsis": "Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.", "homeTab": "Beranda", + "theaterTab": "Teater", + "createTab": "Buat", "openQuickMenu": "Buka menu cepat", "quickAccess": "Akses cepat", "currentSeries": "Serial saat ini", @@ -29,6 +31,7 @@ "filter": "Filter", "topCharts": "Peringkat Teratas", "newReleases": "Rilisan Baru", + "newDramas": "Baru", "trendingMicroDramas": "Drama Mini Populer", "seeAll": "Lihat Semua", "trendingSubtitle": "Paling banyak ditonton minggu ini", @@ -66,6 +69,19 @@ "localViewer": "Penonton Lokal", "localDemoProfile": "Profil demo lokal", "continueWatching": "Lanjut Tonton", + "episodeRemaining": "Episode {episode} · {minutes} menit tersisa", + "@episodeRemaining": { + "placeholders": { + "episode": {"type": "int"}, + "minutes": {"type": "int"} + } + }, + "viewCountLabel": "{count} tayangan", + "@viewCountLabel": { + "placeholders": { + "count": {"type": "int"} + } + }, "watchedPercent": "Sudah ditonton {percent}%", "@watchedPercent": { "placeholders": { @@ -88,6 +104,20 @@ "recommendedTab": "Rekomendasi", "noCategoryDramas": "Belum ada drama di kategori ini.", "allDramas": "Semua Drama", + "allLanguages": "Semua bahasa", + "bahasaMelayu": "Bahasa Melayu", + "createSubtitle": "Cerita menjadi drama pendek", + "createAiTitle": "Produksi AI", + "createAiSubtitle": "Mulai dari cerita atau ide", + "createUploadTitle": "Unggah dan terbitkan", + "createUploadSubtitle": "Terbitkan drama pendek yang sudah selesai", + "weekdayMon": "Sen", + "weekdayTue": "Sel", + "weekdayWed": "Rab", + "weekdayThu": "Kam", + "weekdayFri": "Jum", + "weekdaySat": "Sab", + "weekdaySun": "Min", "trendingTab": "Sedang Tren", "liveActionTab": "Live Action", "search": "Cari", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c814895..c184243 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -154,6 +154,18 @@ abstract class AppLocalizations { /// **'Home'** String get homeTab; + /// No description provided for @theaterTab. + /// + /// In en, this message translates to: + /// **'Theater'** + String get theaterTab; + + /// No description provided for @createTab. + /// + /// In en, this message translates to: + /// **'Create'** + String get createTab; + /// No description provided for @openQuickMenu. /// /// In en, this message translates to: @@ -274,6 +286,12 @@ abstract class AppLocalizations { /// **'New Releases'** String get newReleases; + /// No description provided for @newDramas. + /// + /// In en, this message translates to: + /// **'New'** + String get newDramas; + /// No description provided for @trendingMicroDramas. /// /// In en, this message translates to: @@ -430,6 +448,18 @@ abstract class AppLocalizations { /// **'Continue Watching'** String get continueWatching; + /// No description provided for @episodeRemaining. + /// + /// In en, this message translates to: + /// **'Episode {episode} · {minutes} min left'** + String episodeRemaining(int episode, int minutes); + + /// No description provided for @viewCountLabel. + /// + /// In en, this message translates to: + /// **'{count} views'** + String viewCountLabel(int count); + /// No description provided for @watchedPercent. /// /// In en, this message translates to: @@ -520,6 +550,90 @@ abstract class AppLocalizations { /// **'All Dramas'** String get allDramas; + /// No description provided for @allLanguages. + /// + /// In en, this message translates to: + /// **'All languages'** + String get allLanguages; + + /// No description provided for @bahasaMelayu. + /// + /// In en, this message translates to: + /// **'Malay'** + String get bahasaMelayu; + + /// No description provided for @createSubtitle. + /// + /// In en, this message translates to: + /// **'Stories become short dramas'** + String get createSubtitle; + + /// No description provided for @createAiTitle. + /// + /// In en, this message translates to: + /// **'AI production'** + String get createAiTitle; + + /// No description provided for @createAiSubtitle. + /// + /// In en, this message translates to: + /// **'Start from a story or an idea'** + String get createAiSubtitle; + + /// No description provided for @createUploadTitle. + /// + /// In en, this message translates to: + /// **'Upload and publish'** + String get createUploadTitle; + + /// No description provided for @createUploadSubtitle. + /// + /// In en, this message translates to: + /// **'Publish a finished short drama'** + String get createUploadSubtitle; + + /// No description provided for @weekdayMon. + /// + /// In en, this message translates to: + /// **'Mon'** + String get weekdayMon; + + /// No description provided for @weekdayTue. + /// + /// In en, this message translates to: + /// **'Tue'** + String get weekdayTue; + + /// No description provided for @weekdayWed. + /// + /// In en, this message translates to: + /// **'Wed'** + String get weekdayWed; + + /// No description provided for @weekdayThu. + /// + /// In en, this message translates to: + /// **'Thu'** + String get weekdayThu; + + /// No description provided for @weekdayFri. + /// + /// In en, this message translates to: + /// **'Fri'** + String get weekdayFri; + + /// No description provided for @weekdaySat. + /// + /// In en, this message translates to: + /// **'Sat'** + String get weekdaySat; + + /// No description provided for @weekdaySun. + /// + /// In en, this message translates to: + /// **'Sun'** + String get weekdaySun; + /// No description provided for @trendingTab. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b33f730..10367de 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -36,6 +36,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeTab => 'Home'; + @override + String get theaterTab => 'Theater'; + + @override + String get createTab => 'Create'; + @override String get openQuickMenu => 'Open quick menu'; @@ -98,6 +104,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get newReleases => 'New Releases'; + @override + String get newDramas => 'New'; + @override String get trendingMicroDramas => 'Trending Micro-Dramas'; @@ -182,6 +191,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get continueWatching => 'Continue Watching'; + @override + String episodeRemaining(int episode, int minutes) { + return 'Episode $episode · $minutes min left'; + } + + @override + String viewCountLabel(int count) { + return '$count views'; + } + @override String watchedPercent(int percent) { return 'Watched $percent%'; @@ -229,6 +248,48 @@ class AppLocalizationsEn extends AppLocalizations { @override String get allDramas => 'All Dramas'; + @override + String get allLanguages => 'All languages'; + + @override + String get bahasaMelayu => 'Malay'; + + @override + String get createSubtitle => 'Stories become short dramas'; + + @override + String get createAiTitle => 'AI production'; + + @override + String get createAiSubtitle => 'Start from a story or an idea'; + + @override + String get createUploadTitle => 'Upload and publish'; + + @override + String get createUploadSubtitle => 'Publish a finished short drama'; + + @override + String get weekdayMon => 'Mon'; + + @override + String get weekdayTue => 'Tue'; + + @override + String get weekdayWed => 'Wed'; + + @override + String get weekdayThu => 'Thu'; + + @override + String get weekdayFri => 'Fri'; + + @override + String get weekdaySat => 'Sat'; + + @override + String get weekdaySun => 'Sun'; + @override String get trendingTab => 'Trending'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 88bef94..69ba775 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -36,6 +36,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeTab => 'Beranda'; + @override + String get theaterTab => 'Teater'; + + @override + String get createTab => 'Buat'; + @override String get openQuickMenu => 'Buka menu cepat'; @@ -98,6 +104,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get newReleases => 'Rilisan Baru'; + @override + String get newDramas => 'Baru'; + @override String get trendingMicroDramas => 'Drama Mini Populer'; @@ -182,6 +191,16 @@ class AppLocalizationsId extends AppLocalizations { @override String get continueWatching => 'Lanjut Tonton'; + @override + String episodeRemaining(int episode, int minutes) { + return 'Episode $episode · $minutes menit tersisa'; + } + + @override + String viewCountLabel(int count) { + return '$count tayangan'; + } + @override String watchedPercent(int percent) { return 'Sudah ditonton $percent%'; @@ -229,6 +248,49 @@ class AppLocalizationsId extends AppLocalizations { @override String get allDramas => 'Semua Drama'; + @override + String get allLanguages => 'Semua bahasa'; + + @override + String get bahasaMelayu => 'Bahasa Melayu'; + + @override + String get createSubtitle => 'Cerita menjadi drama pendek'; + + @override + String get createAiTitle => 'Produksi AI'; + + @override + String get createAiSubtitle => 'Mulai dari cerita atau ide'; + + @override + String get createUploadTitle => 'Unggah dan terbitkan'; + + @override + String get createUploadSubtitle => + 'Terbitkan drama pendek yang sudah selesai'; + + @override + String get weekdayMon => 'Sen'; + + @override + String get weekdayTue => 'Sel'; + + @override + String get weekdayWed => 'Rab'; + + @override + String get weekdayThu => 'Kam'; + + @override + String get weekdayFri => 'Jum'; + + @override + String get weekdaySat => 'Sab'; + + @override + String get weekdaySun => 'Min'; + @override String get trendingTab => 'Sedang Tren'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6a31d17..2256dbc 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -35,6 +35,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeTab => '首页'; + @override + String get theaterTab => '剧场'; + + @override + String get createTab => '创作'; + @override String get openQuickMenu => '打开快捷菜单'; @@ -95,6 +101,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get newReleases => '新剧'; + @override + String get newDramas => '新剧'; + @override String get trendingMicroDramas => '热门短剧'; @@ -177,6 +186,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get continueWatching => '继续观看'; + @override + String episodeRemaining(int episode, int minutes) { + return '第 $episode 集 · 还剩 $minutes 分钟'; + } + + @override + String viewCountLabel(int count) { + return '$count 次播放'; + } + @override String watchedPercent(int percent) { return '已观看 $percent%'; @@ -224,6 +243,48 @@ class AppLocalizationsZh extends AppLocalizations { @override String get allDramas => '全部剧目'; + @override + String get allLanguages => '全部语言'; + + @override + String get bahasaMelayu => '马来语'; + + @override + String get createSubtitle => '让故事成为漫剧'; + + @override + String get createAiTitle => 'AI 制作'; + + @override + String get createAiSubtitle => '从故事或想法开始'; + + @override + String get createUploadTitle => '上传发布'; + + @override + String get createUploadSubtitle => '发布已完成的漫剧'; + + @override + String get weekdayMon => '周一'; + + @override + String get weekdayTue => '周二'; + + @override + String get weekdayWed => '周三'; + + @override + String get weekdayThu => '周四'; + + @override + String get weekdayFri => '周五'; + + @override + String get weekdaySat => '周六'; + + @override + String get weekdaySun => '周日'; + @override String get trendingTab => '热门'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f63ec02..a434316 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -9,6 +9,8 @@ "demoTitle": "信号", "demoSynopsis": "使用本地视频进行播放预览,后续有素材再增加剧集。", "homeTab": "首页", + "theaterTab": "剧场", + "createTab": "创作", "openQuickMenu": "打开快捷菜单", "quickAccess": "快捷入口", "currentSeries": "当前短剧", @@ -29,6 +31,7 @@ "filter": "筛选", "topCharts": "排行榜", "newReleases": "新剧", + "newDramas": "新剧", "trendingMicroDramas": "热门短剧", "seeAll": "查看全部", "trendingSubtitle": "本周追看最多", @@ -66,6 +69,19 @@ "localViewer": "本地观众", "localDemoProfile": "本地演示档案", "continueWatching": "继续观看", + "episodeRemaining": "第 {episode} 集 · 还剩 {minutes} 分钟", + "@episodeRemaining": { + "placeholders": { + "episode": {"type": "int"}, + "minutes": {"type": "int"} + } + }, + "viewCountLabel": "{count} 次播放", + "@viewCountLabel": { + "placeholders": { + "count": {"type": "int"} + } + }, "watchedPercent": "已观看 {percent}%", "@watchedPercent": { "placeholders": { @@ -88,6 +104,20 @@ "recommendedTab": "推荐", "noCategoryDramas": "这个分类暂时没有剧集。", "allDramas": "全部剧目", + "allLanguages": "全部语言", + "bahasaMelayu": "马来语", + "createSubtitle": "让故事成为漫剧", + "createAiTitle": "AI 制作", + "createAiSubtitle": "从故事或想法开始", + "createUploadTitle": "上传发布", + "createUploadSubtitle": "发布已完成的漫剧", + "weekdayMon": "周一", + "weekdayTue": "周二", + "weekdayWed": "周三", + "weekdayThu": "周四", + "weekdayFri": "周五", + "weekdaySat": "周六", + "weekdaySun": "周日", "trendingTab": "热门", "liveActionTab": "真人剧", "search": "搜索", diff --git a/lib/pages/create/create_view.dart b/lib/pages/create/create_view.dart new file mode 100644 index 0000000..0ff779c --- /dev/null +++ b/lib/pages/create/create_view.dart @@ -0,0 +1,145 @@ +// Author: fruit.com.cn + +// K28 creation tab placeholder; creator workflows land in the next batch. +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../l10n/app_localizations.dart'; + +/// Honest skeleton for the future creation workspace. +class CreatePage extends StatelessWidget { + const CreatePage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Theme( + data: AppTheme.dark(), + child: SafeArea( + bottom: false, + child: ListView( + key: const Key('create-page'), + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceXl, + AppDimens.pageHorizontal, + AppDimens.spaceXl, + ), + children: [ + Text( + l10n.createTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: AppDimens.spaceSm), + Text( + l10n.createSubtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, + ), + ), + const SizedBox(height: AppDimens.spaceXl), + _CreateEntry( + key: const Key('create-ai'), + icon: Icons.auto_awesome_rounded, + title: l10n.createAiTitle, + subtitle: l10n.createAiSubtitle, + detail: l10n.comingSoon, + ), + const SizedBox(height: AppDimens.spaceMd), + _CreateEntry( + key: const Key('create-upload'), + icon: Icons.upload_file_rounded, + title: l10n.createUploadTitle, + subtitle: l10n.createUploadSubtitle, + detail: l10n.comingSoon, + ), + ], + ), + ), + ); + } +} + +class _CreateEntry extends StatelessWidget { + const _CreateEntry({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.detail, + }); + + final IconData icon; + final String title; + final String subtitle; + final String detail; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppDimens.cardPadding), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Container( + width: AppDimens.tapTarget, + height: AppDimens.tapTarget, + decoration: BoxDecoration( + color: AppColors.blue.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + ), + child: Icon( + icon, + color: AppColors.blue, + size: AppDimens.createIcon, + ), + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + '$subtitle · $detail', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), + const SizedBox(width: AppDimens.spaceSm), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: AppDimens.createArrow, + ), + ], + ), + ); + } +} diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 7df910f..b654e35 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -28,6 +28,8 @@ class DiscoverLogic extends ChangeNotifier { String _lastRequestedQuery = ''; int _searchRequest = 0; bool _disposed = false; + TheaterLanguage language = TheaterLanguage.all; + String? selectedCategoryId; bool get hasSearchQuery => search.text.trim().isNotEmpty; @@ -48,6 +50,20 @@ class DiscoverLogic extends ChangeNotifier { void openSearch() => searchFocus.requestFocus(); + /// Selects a language for the visual filter state. + void selectLanguage(TheaterLanguage value) { + if (language == value) return; + language = value; + notify(); + } + + /// Selects one category chip; null represents all categories. + void selectCategory(String? categoryId) { + if (selectedCategoryId == categoryId) return; + selectedCategoryId = categoryId; + notify(); + } + void retrySearch() { final query = search.text.trim(); if (query.isNotEmpty) unawaited(_runSearch(query)); diff --git a/lib/pages/discover/discover_types.dart b/lib/pages/discover/discover_types.dart index a609965..25e3f47 100644 --- a/lib/pages/discover/discover_types.dart +++ b/lib/pages/discover/discover_types.dart @@ -4,8 +4,11 @@ import 'package:flutter/foundation.dart'; import '../../core/data/models/drama.dart'; -/// 剧场页与播放器之间的返回动作。 -enum DiscoverPageAction { home, my } +/// 剧场页与根导航之间的动作。 +enum DiscoverPageAction { home, create, my } + +/// Language choices shown by the theater filter UI. +enum TheaterLanguage { all, zh, id, ms, en } /// 剧场页导航回调;没有 `onAction` 时按独立页面处理。 class DiscoverPageCallbacks { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index bfa0d41..3f7a245 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -4,12 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../../core/data/models/content_category.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/models/home_feed.dart'; -import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/search_repository.dart'; -import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; @@ -19,19 +15,14 @@ import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; -import 'category_dramas_page.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'discover_types.dart'; -import 'widgets/category_grid.dart'; import 'widgets/discover_header.dart'; -import 'widgets/discover_search_field.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/drama_grid.dart'; -import 'widgets/feed_banner_carousel.dart'; -import 'widgets/ranking_section.dart'; -/// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。 +/// Theater page with fixed search, category chips, and a two-column catalogue. class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, @@ -96,13 +87,18 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); - final banners = context.watch().banners; final content = SafeArea( bottom: !embedded, child: Column( children: [ - DiscoverHeader(onSearch: logic.openSearch, onProfile: _openProfile), - Expanded(child: _feed(l10n, categories, banners)), + DiscoverHeader( + controller: logic.search, + focusNode: logic.searchFocus, + onChanged: (_) => logic.onSearchChanged(), + loading: logic.searchLoading, + onProfile: _openProfile, + ), + Expanded(child: _feed(l10n, categories)), if (!embedded) _bottomNav(l10n), ], ), @@ -118,43 +114,28 @@ class _DiscoverPageState extends State { ); } - Widget _feed( - AppLocalizations l10n, - CategoryController categories, - List banners, - ) { - return RefreshIndicator( - key: const Key('discover-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, - onRefresh: categories.load, - child: ListView( - controller: logic.scroll, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 24), - children: [ - Padding( - padding: const EdgeInsets.only(top: 8), - child: DiscoverSearchField( - controller: logic.search, - focusNode: logic.searchFocus, - onChanged: (_) => logic.onSearchChanged(), - loading: logic.searchLoading, - ), - ), - ..._content(l10n, categories, banners), - ], + Widget _feed(AppLocalizations l10n, CategoryController categories) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: logic.searchFocus.unfocus, + child: RefreshIndicator( + key: const Key('discover-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: categories.load, + child: ListView( + controller: logic.scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 24), + children: _content(l10n, categories), + ), ), ); } - List _content( - AppLocalizations l10n, - CategoryController categories, - List banners, - ) { + List _content(AppLocalizations l10n, CategoryController categories) { if (logic.hasSearchQuery) return [_searchContent(l10n)]; - return _theaterContent(l10n, categories, banners); + return _theaterContent(l10n, categories); } Widget _searchContent(AppLocalizations l10n) => DiscoverSearchResults( @@ -168,7 +149,6 @@ class _DiscoverPageState extends State { List _theaterContent( AppLocalizations l10n, CategoryController categories, - List banners, ) { if (categories.loading && categories.feed.sections.isEmpty) { return [_block(const CircularProgressIndicator(strokeWidth: 2))]; @@ -188,38 +168,19 @@ class _DiscoverPageState extends State { ), ]; } - return _theaterBlocks(l10n, categories, banners); + return _theaterBlocks(l10n, categories); } List _theaterBlocks( AppLocalizations l10n, CategoryController categories, - List banners, ) { - final dramas = categories.feed.dramas; + final dramas = logic.selectedCategoryId == null + ? categories.feed.dramas + : categories.dramasOf(logic.selectedCategoryId!); return [ - Padding( - padding: const EdgeInsets.only(top: 12), - child: CategoryGrid( - categories: categories.categories, - onTap: (category) => _openCategory(category, categories), - ), - ), - if (banners.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 12), - child: FeedBannerCarousel( - banners: banners, - active: widget.active, - onOpen: _openBanner, - ), - ), - if (categories.feed.sections.isNotEmpty || dramas.isNotEmpty) - RankingSection( - sections: categories.feed.sections, - fallback: dramas, - onOpen: _openDrama, - ), + _categoryChips(l10n, categories), + _languageChips(l10n), _sectionTitle(l10n.allDramas), if (dramas.isEmpty) _emptyCatalog(l10n) else _dramaGrid(l10n, dramas), ]; @@ -242,6 +203,93 @@ class _DiscoverPageState extends State { return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama); } + Widget _categoryChips(AppLocalizations l10n, CategoryController categories) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceMd, + AppDimens.pageHorizontal, + 0, + ), + child: Wrap( + key: const Key('theater-category-chips'), + spacing: AppDimens.spaceSm, + runSpacing: AppDimens.spaceSm, + children: [ + _filterChip( + key: const Key('theater-category-all'), + label: l10n.all, + selected: logic.selectedCategoryId == null, + onSelected: () => logic.selectCategory(null), + ), + for (final category in categories.categories) + _filterChip( + key: Key('theater-category-${category.id}'), + label: category.name, + selected: logic.selectedCategoryId == category.id, + onSelected: () => logic.selectCategory(category.id), + ), + ], + ), + ); + } + + Widget _languageChips(AppLocalizations l10n) { + final entries = [ + (TheaterLanguage.all, l10n.allLanguages), + (TheaterLanguage.zh, l10n.simplifiedChinese), + (TheaterLanguage.id, l10n.bahasaIndonesia), + (TheaterLanguage.ms, l10n.bahasaMelayu), + (TheaterLanguage.en, l10n.english), + ]; + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceSm, + AppDimens.pageHorizontal, + 0, + ), + child: Wrap( + key: const Key('theater-language-chips'), + spacing: AppDimens.spaceSm, + runSpacing: AppDimens.spaceSm, + children: [ + for (final entry in entries) + _filterChip( + key: Key('theater-language-${entry.$1.name}'), + label: entry.$2, + selected: logic.language == entry.$1, + onSelected: () => logic.selectLanguage(entry.$1), + ), + ], + ), + ); + } + + Widget _filterChip({ + required Key key, + required String label, + required bool selected, + required VoidCallback onSelected, + }) { + return ChoiceChip( + key: key, + label: Text(label), + selected: selected, + showCheckmark: false, + onSelected: (_) => onSelected(), + backgroundColor: AppColors.card, + selectedColor: AppColors.blue, + side: BorderSide(color: selected ? AppColors.blue : AppColors.border), + labelStyle: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: AppDimens.chipFont, + fontWeight: FontWeight.w700, + ), + shape: const StadiumBorder(), + ); + } + Widget _sectionTitle(String title) { return Padding( padding: const EdgeInsets.fromLTRB(14, 18, 14, 10), @@ -291,18 +339,6 @@ class _DiscoverPageState extends State { ); } - void _openCategory(ContentCategory category, CategoryController categories) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CategoryDramasPage( - title: category.name, - dramas: categories.dramasOf(category.id), - onOpen: widget.callbacks.onDramaSelected == null ? null : _openDrama, - ), - ), - ); - } - void _openDrama(Drama drama) { final callback = widget.callbacks.onDramaSelected; if (callback != null) { @@ -318,33 +354,12 @@ class _DiscoverPageState extends State { ); } - Future _openBanner(FeedBanner banner) async { - final dramaId = banner.dramaId; - if (dramaId == null) return; - for (final drama in context.read().dramas) { - if (drama.id == dramaId) { - _pushDetail(drama); - return; - } - } - try { - final drama = await context.read().fetchDrama(dramaId); - if (mounted) _pushDetail(drama); - } catch (_) { - if (!mounted) return; - final message = AppLocalizations.of(context)!.detailLoadFailed; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - } - } - Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, + icon: Icons.home_outlined, + selectedIcon: Icons.home_rounded, label: l10n.homeTab, onTap: () => _act(DiscoverPageAction.home), ), @@ -352,10 +367,17 @@ class _DiscoverPageState extends State { tabKey: const Key('discover-nav-current'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, + label: l10n.theaterTab, selected: true, onTap: () {}, ), + AppBottomNavItem( + tabKey: const Key('discover-nav-create'), + icon: Icons.add_box_outlined, + selectedIcon: Icons.add_box_rounded, + label: l10n.createTab, + onTap: () => _act(DiscoverPageAction.create), + ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), icon: Icons.video_library_outlined, diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart index 1d20061..f034c89 100644 --- a/lib/pages/discover/widgets/discover_header.dart +++ b/lib/pages/discover/widgets/discover_header.dart @@ -3,37 +3,48 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; +import 'discover_search_field.dart'; -/// 剧场页页头:品牌、搜索与个人页入口。 +/// Theater header with a persistent search field and profile entry. class DiscoverHeader extends StatelessWidget { const DiscoverHeader({ super.key, - required this.onSearch, + required this.controller, + required this.focusNode, + required this.onChanged, + required this.loading, required this.onProfile, }); - final VoidCallback onSearch; + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + final bool loading; final VoidCallback onProfile; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return SizedBox( - height: 46, + height: AppDimens.contentHeaderHeight, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), child: Row( children: [ - _brand(l10n), - const Spacer(), - _HeaderIcon( - key: const Key('discover-search-button'), - icon: Icons.search_rounded, - tooltip: l10n.search, - onTap: onSearch, + Expanded( + child: DiscoverSearchField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + loading: loading, + horizontalPadding: 0, + ), ), - const SizedBox(width: 8), + const SizedBox(width: AppDimens.spaceSm), _profile(l10n), ], ), @@ -41,37 +52,6 @@ class DiscoverHeader extends StatelessWidget { ); } - Widget _brand(AppLocalizations l10n) { - return Row( - children: [ - Container( - width: 24, - height: 24, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(7), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 16, - ), - ), - const SizedBox(width: 8), - Text( - l10n.discoverTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ); - } - Widget _profile(AppLocalizations l10n) { return Semantics( button: true, @@ -101,34 +81,3 @@ class DiscoverHeader extends StatelessWidget { ); } } - -class _HeaderIcon extends StatelessWidget { - const _HeaderIcon({ - super.key, - required this.icon, - required this.tooltip, - required this.onTap, - }); - - final IconData icon; - final String tooltip; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 36, - height: 36, - child: IconButton( - tooltip: tooltip, - onPressed: onTap, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: const Color(0xFF2C2D33), - foregroundColor: AppColors.onSurface, - ), - icon: Icon(icon, size: 18), - ), - ); - } -} diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index de549af..7688b8a 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -13,17 +13,19 @@ class DiscoverSearchField extends StatelessWidget { required this.focusNode, required this.onChanged, required this.loading, + this.horizontalPadding = 14, }); final TextEditingController controller; final FocusNode focusNode; final ValueChanged onChanged; final bool loading; + final double horizontalPadding; @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: SizedBox( height: 36, child: TextField( diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index 7974b5d..71a3b55 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; -import '../../../core/config/app_config.dart'; import '../../../core/data/models/drama.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 int _columns = 2; @@ -83,6 +84,8 @@ class DramaGridCard extends StatelessWidget { _title(), const SizedBox(height: 2), _genre(), + const SizedBox(height: AppDimens.spaceXs), + _metadata(), ], ), ); @@ -115,7 +118,7 @@ class DramaGridCard extends StatelessWidget { l10n.free, style: const TextStyle( color: AppColors.onPrimary, - fontSize: 8, + fontSize: AppDimens.cardMeta, fontWeight: FontWeight.w800, ), ), @@ -130,7 +133,7 @@ class DramaGridCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: 11, + fontSize: AppDimens.previewSectionTitle, fontWeight: FontWeight.w800, ), ); @@ -143,7 +146,39 @@ class DramaGridCard extends StatelessWidget { key: Key('home-drama-genre-${drama.id}'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.cardMeta, + ), + ); + } + + Widget _metadata() { + return Wrap( + spacing: AppDimens.spaceSm, + runSpacing: AppDimens.spaceXs, + children: [ + Text( + l10n.episodeCount(drama.episodeCount), + key: Key('home-drama-episode-count-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.cardViews, + ), + ), + Text( + l10n.viewCountLabel(drama.viewCount), + key: Key('home-drama-views-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.cardViews, + ), + ), + ], ); } } @@ -155,33 +190,12 @@ class _DiscoverCoverImage extends StatelessWidget { @override Widget build(BuildContext context) { - final url = AppConfig.mediaUrl(path); - if (url.isEmpty) return _placeholder(); - return Image.network( - url, + return RemoteImage( + path, width: double.infinity, fit: BoxFit.fitWidth, - loadingBuilder: (context, child, progress) { - if (progress == null) return child; - return _placeholder(); - }, - errorBuilder: (_, _, _) => _placeholder(), - ); - } - - Widget _placeholder() { - return AspectRatio( - aspectRatio: 3 / 4, - child: ColoredBox( - color: AppColors.card, - child: const Center( - child: Icon( - Icons.image_not_supported_outlined, - color: AppColors.muted, - size: 32, - ), - ), - ), + showFallbackIcon: true, + fallbackAspectRatio: 3 / 4, ); } } diff --git a/lib/pages/discover/widgets/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart index 9d9c944..8ad894d 100644 --- a/lib/pages/discover/widgets/feed_banner_carousel.dart +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -5,12 +5,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../../core/data/models/home_feed.dart'; +import '../../../core/data/models/drama.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; @@ -25,11 +25,13 @@ class FeedBannerCarousel extends StatefulWidget { required this.banners, required this.onOpen, this.active = true, + this.dramaFor, }); final List banners; final ValueChanged onOpen; final bool active; + final Drama? Function(FeedBanner banner)? dramaFor; @override State createState() => _FeedBannerCarouselState(); @@ -121,7 +123,7 @@ class _FeedBannerCarouselState extends State { child: Column( children: [ SizedBox( - height: _carouselHeight, + height: AppDimens.heroBannerHeight, child: PageView.builder( controller: _controller, itemCount: widget.banners.length, @@ -138,6 +140,7 @@ class _FeedBannerCarouselState extends State { Widget _bannerCard(int index) { final banner = widget.banners[index]; final dramaId = banner.dramaId; + final drama = widget.dramaFor?.call(banner); final card = ClipRRect( borderRadius: BorderRadius.circular(AppDimens.authCardRadius), child: Stack( @@ -149,10 +152,44 @@ class _FeedBannerCarouselState extends State { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x00000000), Color(0x8A0B0C10)], + colors: [Color(0x120B0C10), Color(0xE60B0C10)], ), ), ), + if (drama != null) + Positioned( + left: 16, + right: 64, + bottom: 14, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + drama.title, + key: Key('home-hero-title-$index'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + drama.synopsis, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), if (dramaId != null) const Positioned(right: 14, bottom: 14, child: _PlayMark()), ], diff --git a/lib/pages/home/home_browse_logic.dart b/lib/pages/home/home_browse_logic.dart new file mode 100644 index 0000000..5e16f7f --- /dev/null +++ b/lib/pages/home/home_browse_logic.dart @@ -0,0 +1,70 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../core/data/models/watch_progress.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; + +/// Home browse channel, backed only by data already present in the catalogue. +enum HomeChannel { hot, free, newReleases, ranking } + +/// Local UI state for the browse-first home page. +class HomeBrowseLogic extends ChangeNotifier { + HomeBrowseLogic({required this.history}) { + unawaited(refreshRecent()); + } + + static const int recentLimit = 20; + + final WatchHistoryRepository history; + HomeChannel channel = HomeChannel.hot; + List recent = const []; + bool loadingRecent = false; + bool _disposed = false; + + /// First unfinished record with a real position. + WatchProgress? get primaryRecord { + for (final record in recent) { + if (!record.finished && record.hasPosition) return record; + } + return null; + } + + /// Changes the selected browse channel without triggering network work. + void selectChannel(HomeChannel value) { + if (channel == value) return; + channel = value; + _notify(); + } + + /// Reloads continuation data; signed-out reads simply return an empty list. + Future refreshRecent() async { + loadingRecent = true; + _notify(); + try { + recent = await history.recent(limit: recentLimit); + } catch (_) { + recent = const []; + } finally { + loadingRecent = false; + _notify(); + } + } + + /// Refreshes continuation data when the home tab becomes visible again. + void syncPage({required bool active, required bool wasActive}) { + if (active && !wasActive) unawaited(refreshRecent()); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart new file mode 100644 index 0000000..a13408d --- /dev/null +++ b/lib/pages/home/home_browse_view.dart @@ -0,0 +1,333 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.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/catalog_controller.dart'; +import '../../core/data/services/category_controller.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; +import '../discover/widgets/drama_grid.dart'; +import '../discover/widgets/feed_banner_carousel.dart'; +import 'home_browse_logic.dart'; +import 'widgets/home_browse_header.dart'; +import 'widgets/home_continue_watching.dart'; + +/// Browse-first home page with channels, hero, continuation, and catalogue. +class HomeBrowseView extends StatefulWidget { + const HomeBrowseView({ + super.key, + required this.active, + required this.onSearch, + required this.onProfile, + required this.onTheater, + required this.onHistory, + required this.onOpenDrama, + required this.onOpenContinue, + }); + + final bool active; + final VoidCallback onSearch; + final VoidCallback onProfile; + final VoidCallback onTheater; + final VoidCallback onHistory; + final Future Function(Drama drama) onOpenDrama; + final Future Function(Drama drama, WatchProgress record) onOpenContinue; + + @override + State createState() => _HomeBrowseViewState(); +} + +class _HomeBrowseViewState extends State { + late final HomeBrowseLogic logic; + + @override + void initState() { + super.initState(); + logic = HomeBrowseLogic(history: context.read()); + } + + @override + void didUpdateWidget(covariant HomeBrowseView oldWidget) { + super.didUpdateWidget(oldWidget); + logic.syncPage(active: widget.active, wasActive: oldWidget.active); + } + + @override + void dispose() { + logic.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: logic, + builder: (context, _) => _page(context), + ); + } + + Widget _page(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final categories = context.watch(); + final catalog = context.watch(); + final source = categories.feed.dramas.isEmpty + ? catalog.dramas + : categories.feed.dramas; + return SafeArea( + bottom: false, + child: Column( + children: [ + HomeBrowseHeader( + onSearch: widget.onSearch, + onProfile: widget.onProfile, + ), + Expanded(child: _body(l10n, categories, catalog, source)), + ], + ), + ); + } + + Widget _body( + AppLocalizations l10n, + CategoryController categories, + CatalogController catalog, + List source, + ) { + return RefreshIndicator( + key: const Key('home-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: () async { + await Future.wait([categories.load(), logic.refreshRecent()]); + }, + child: ListView( + key: const Key('home-browse-page'), + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + children: _sections(l10n, catalog, source), + ), + ); + } + + List _sections( + AppLocalizations l10n, + CatalogController catalog, + List source, + ) { + final dramas = _channelDramas(source, logic.channel); + final continueItems = _continueItems(logic.recent, catalog.dramas); + return [ + _channels(l10n), + if (catalog.banners.isNotEmpty) _hero(catalog.banners, source), + if (continueItems.isNotEmpty) ...[ + _continueHeader(l10n), + HomeContinueWatching( + items: continueItems, + onPlay: (drama, record) => + unawaited(widget.onOpenContinue(drama, record)), + ), + ], + _channelHeader(l10n), + if (dramas.isEmpty) + _empty() + else + DramaGrid( + l10n: l10n, + dramas: dramas, + onOpen: (drama) => unawaited(widget.onOpenDrama(drama)), + ), + ]; + } + + Widget _continueHeader(AppLocalizations l10n) => _sectionHeader( + l10n.continueWatching, + actionKey: const Key('home-continue-all'), + actionLabel: l10n.seeAll, + onAction: widget.onHistory, + ); + + Widget _channelHeader(AppLocalizations l10n) => _sectionHeader( + _channelTitle(l10n), + actionKey: const Key('home-popular-more'), + actionLabel: l10n.seeAll, + onAction: widget.onTheater, + ); + + Widget _empty() => Padding( + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXl), + child: EmptyState( + key: const Key('home-browse-empty'), + icon: Icons.movie_filter_outlined, + title: AppLocalizations.of(context)!.noStories, + subtitle: AppLocalizations.of(context)!.emptyHint, + compact: true, + ), + ); + + Widget _channels(AppLocalizations l10n) { + final entries = [ + (HomeChannel.hot, l10n.hot), + (HomeChannel.free, l10n.free), + (HomeChannel.newReleases, l10n.newDramas), + (HomeChannel.ranking, l10n.rankingTab), + ]; + return SizedBox( + height: AppDimens.buttonMedium, + child: ListView.separated( + key: const Key('home-channels'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + itemCount: entries.length, + separatorBuilder: (_, _) => const SizedBox(width: AppDimens.spaceSm), + itemBuilder: (context, index) { + final entry = entries[index]; + final selected = logic.channel == entry.$1; + return ChoiceChip( + key: Key('home-channel-${entry.$1.name}'), + label: Text(entry.$2), + selected: selected, + showCheckmark: false, + onSelected: (_) => logic.selectChannel(entry.$1), + backgroundColor: AppColors.card, + selectedColor: AppColors.blue, + side: BorderSide( + color: selected ? AppColors.blue : AppColors.border, + ), + labelStyle: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: AppDimens.chipFont, + fontWeight: FontWeight.w700, + ), + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + shape: const StadiumBorder(), + ); + }, + ), + ); + } + + Widget _hero(List banners, List dramas) { + return Padding( + padding: const EdgeInsets.only(top: AppDimens.spaceMd), + child: FeedBannerCarousel( + key: const Key('home-hero-banner'), + banners: banners, + active: widget.active, + dramaFor: (banner) => _dramaForBanner(banner, dramas), + onOpen: (banner) { + final drama = _dramaForBanner(banner, dramas); + if (drama != null) unawaited(widget.onOpenDrama(drama)); + }, + ), + ); + } + + Widget _sectionHeader( + String title, { + required Key actionKey, + required String actionLabel, + required VoidCallback onAction, + }) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceLg, + AppDimens.spaceSm, + AppDimens.spaceSm, + ), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w800, + ), + ), + ), + TextButton( + key: actionKey, + onPressed: onAction, + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget), + ), + child: Text(actionLabel), + ), + ], + ), + ); + } + + String _channelTitle(AppLocalizations l10n) { + return switch (logic.channel) { + HomeChannel.hot => l10n.trendingMicroDramas, + HomeChannel.free => l10n.free, + HomeChannel.newReleases => l10n.newDramas, + HomeChannel.ranking => l10n.rankingTitle, + }; + } + + List _channelDramas(List source, HomeChannel channel) { + final dramas = List.of(source); + switch (channel) { + case HomeChannel.hot: + dramas.sort((a, b) { + final hot = (b.isHot ? 1 : 0).compareTo(a.isHot ? 1 : 0); + return hot != 0 ? hot : b.viewCount.compareTo(a.viewCount); + }); + case HomeChannel.free: + return dramas.where(_isFree).toList(); + case HomeChannel.newReleases: + return dramas; + case HomeChannel.ranking: + dramas.sort((a, b) => b.viewCount.compareTo(a.viewCount)); + } + return dramas; + } + + bool _isFree(Drama drama) { + return drama.freeEpisodes > 0 || + drama.episodes.any((episode) => episode.isFree); + } + + Drama? _dramaForBanner(FeedBanner banner, List dramas) { + final id = banner.dramaId; + if (id == null) return null; + for (final drama in dramas) { + if (drama.id == id) return drama; + } + return null; + } + + List _continueItems( + List records, + List dramas, + ) { + final items = []; + for (final record in records) { + if (record.finished || !record.hasPosition) continue; + for (final drama in dramas) { + if (drama.id == record.dramaId) { + items.add((drama: drama, record: record)); + break; + } + } + } + return items; + } +} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index 8834669..f8b5110 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -69,7 +69,11 @@ class HomeLogic extends ChangeNotifier { ); } - Future playDrama(BuildContext context, Drama drama) async { + Future playDrama( + BuildContext context, + Drama drama, { + String? episodeId, + }) async { // 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。 var playable = drama; try { @@ -78,11 +82,14 @@ class HomeLogic extends ChangeNotifier { // 取详情失败就用卡片自带的那一集起播。 } if (!context.mounted) return; - Navigator.of(context).push( + final index = episodeId == null + ? 0 + : playable.episodes.indexWhere((episode) => episode.id == episodeId); + await Navigator.of(context).push( MaterialPageRoute( builder: (_) => PlayerPage( drama: playable, - initialIndex: 0, + initialIndex: index < 0 ? 0 : index, wallet: context.read(), downloads: context.read(), ), @@ -90,14 +97,16 @@ class HomeLogic extends ChangeNotifier { ); } - void openProfile() => select(2); + void openProfile() => select(3); void discoverAction(DiscoverPageAction action) { switch (action) { case DiscoverPageAction.home: select(0); - case DiscoverPageAction.my: + case DiscoverPageAction.create: select(2); + case DiscoverPageAction.my: + select(3); } } @@ -119,6 +128,8 @@ class HomeLogic extends ChangeNotifier { select(0); case MyPageAction.discover: select(1, search: true); + case MyPageAction.create: + select(2); case MyPageAction.resume: select(0, resume: true); } @@ -128,8 +139,8 @@ class HomeLogic extends ChangeNotifier { if (videoImmersive) { backRequest++; notify(); - } else if (tab != 1) { - select(1); + } else if (tab != 0) { + select(0); } } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 5fadb0b..c4e4e37 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -7,8 +7,6 @@ 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 '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; @@ -17,13 +15,13 @@ import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; +import '../create/create_view.dart'; import '../mine/mine_types.dart'; import '../splash/splash_page.dart'; import '../mine/mine_view.dart'; -import '../player/player_view.dart'; import 'home_binding.dart'; +import 'home_browse_view.dart'; import 'home_logic.dart'; -import 'widgets/home_menu_sheet.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); @@ -95,7 +93,7 @@ class HomeFailureView extends StatelessWidget { } } -/// The three primary destinations share a single route and keep their state. +/// The four primary destinations share a single route and keep their state. class _HomeTabs extends StatefulWidget { const _HomeTabs({super.key, required this.dramas}); @@ -134,11 +132,7 @@ class _HomeTabsState extends State<_HomeTabs> { canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - if (!logic.videoImmersive && logic.tab == 1) { - unawaited(_openMenu(context)); - } else { - logic.onSystemBack(); - } + logic.onSystemBack(); }, child: Scaffold( key: logic.scaffoldKey, @@ -152,28 +146,25 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _body(BuildContext context) { return IndexedStack( index: logic.tab, - children: [_player(context), _discover(context), _my(context)], + children: [ + _home(context), + _discover(context), + const CreatePage(), + _my(context), + ], ); } - Widget _player(BuildContext context) { - return PlayerPage( - drama: widget.dramas.first, - feedDramas: widget.dramas, - onDramaChanged: logic.onDramaChanged, - initialIndex: 0, - homeMode: true, - embedded: true, - active: logic.tab == 0 && !logic.drawerOpen && !logic.detailOpen, - resumeRequest: logic.resumeRequest, - backRequest: logic.backRequest, - onDiscover: () => logic.select(1), - onMy: logic.openProfile, + Widget _home(BuildContext context) { + return HomeBrowseView( + active: logic.tab == 0, onSearch: () => logic.select(1, search: true), - onMenu: () => _openMenu(context), - onImmersiveChanged: logic.onImmersiveChanged, - wallet: context.read(), - downloads: context.read(), + onProfile: logic.openProfile, + onTheater: () => logic.select(1), + onHistory: () => logic.openHistory(context), + onOpenDrama: (drama) => logic.playDrama(context, drama), + onOpenContinue: (drama, record) => + logic.playDrama(context, drama, episodeId: record.episodeId), ); } @@ -191,7 +182,7 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _my(BuildContext context) { return MyPage( - active: logic.tab == 2, + active: logic.tab == 3, callbacks: MyPageCallbacks( onAction: logic.myAction, onDramaSelected: (drama) => logic.openDrama(context, drama), @@ -206,6 +197,7 @@ class _HomeTabsState extends State<_HomeTabs> { _navItem(context, l10n, 0), _navItem(context, l10n, 1), _navItem(context, l10n, 2), + _navItem(context, l10n, 3), ], ); } @@ -219,24 +211,32 @@ class _HomeTabsState extends State<_HomeTabs> { return switch (index) { 0 => AppBottomNavItem( tabKey: const Key('tab-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, + icon: Icons.home_outlined, + selectedIcon: Icons.home_rounded, label: l10n.homeTab, selected: selected, onTap: () => _selectTab(context, index), ), 1 => AppBottomNavItem( - tabKey: const Key('tab-discover'), + tabKey: const Key('tab-theater'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, + label: l10n.theaterTab, + selected: selected, + onTap: () => _selectTab(context, index), + ), + 2 => AppBottomNavItem( + tabKey: const Key('tab-create'), + icon: Icons.add_box_outlined, + selectedIcon: Icons.add_box_rounded, + label: l10n.createTab, selected: selected, onTap: () => _selectTab(context, index), ), _ => AppBottomNavItem( - tabKey: const Key('tab-my'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, + tabKey: const Key('tab-mine'), + icon: Icons.person_outline_rounded, + selectedIcon: Icons.person_rounded, label: l10n.myTab, selected: selected, onTap: () => _selectTab(context, index), @@ -251,22 +251,4 @@ class _HomeTabsState extends State<_HomeTabs> { } logic.select(tab); } - - Future _openMenu(BuildContext context) async { - logic.setMenuOpen(true); - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - isScrollControlled: true, - builder: (_) => HomeMenuSheet( - drama: logic.currentDrama, - onOpenSeries: () => logic.openCurrentSeries(context), - onDiscover: () => logic.select(1), - onHistory: () => logic.openHistory(context), - onWatchlist: () => logic.openFavorites(context), - ), - ); - logic.setMenuOpen(false); - } } diff --git a/lib/pages/home/widgets/home_browse_header.dart b/lib/pages/home/widgets/home_browse_header.dart new file mode 100644 index 0000000..91406db --- /dev/null +++ b/lib/pages/home/widgets/home_browse_header.dart @@ -0,0 +1,134 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _searchHeight = 36; + +/// Fixed home header with catalogue search, weekday, and profile entry. +class HomeBrowseHeader extends StatelessWidget { + const HomeBrowseHeader({ + super.key, + required this.onSearch, + required this.onProfile, + }); + + final VoidCallback onSearch; + final VoidCallback onProfile; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final weekday = _weekday(l10n); + return SizedBox( + height: AppDimens.contentHeaderHeight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: Row( + children: [ + Expanded(child: _search(l10n, onSearch)), + const SizedBox(width: AppDimens.spaceSm), + _weekdayText(weekday), + const SizedBox(width: AppDimens.spaceSm), + _profile(l10n), + ], + ), + ), + ); + } + + String _weekday(AppLocalizations l10n) { + return switch (DateTime.now().weekday) { + DateTime.monday => l10n.weekdayMon, + DateTime.tuesday => l10n.weekdayTue, + DateTime.wednesday => l10n.weekdayWed, + DateTime.thursday => l10n.weekdayThu, + DateTime.friday => l10n.weekdayFri, + DateTime.saturday => l10n.weekdaySat, + _ => l10n.weekdaySun, + }; + } + + Widget _search(AppLocalizations l10n, VoidCallback onTap) { + return Semantics( + button: true, + label: l10n.search, + child: InkWell( + key: const Key('home-search'), + onTap: onTap, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + child: Container( + height: _searchHeight, + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + decoration: BoxDecoration( + color: AppColors.input, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Row( + children: [ + const Icon( + Icons.search_rounded, + color: AppColors.subtle, + size: 17, + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded( + child: Text( + l10n.searchDiscover, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.caption, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _weekdayText(String value) { + return Text( + value, + key: const Key('home-weekday'), + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + fontWeight: FontWeight.w600, + ), + ); + } + + Widget _profile(AppLocalizations l10n) { + return Semantics( + button: true, + label: l10n.myTab, + child: InkWell( + key: const Key('home-profile'), + customBorder: const CircleBorder(), + onTap: onProfile, + child: Container( + width: AppDimens.tapTarget, + height: AppDimens.tapTarget, + decoration: const BoxDecoration( + color: AppColors.card, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: AppDimens.accountIcon, + ), + ), + ), + ); + } +} diff --git a/lib/pages/home/widgets/home_continue_watching.dart b/lib/pages/home/widgets/home_continue_watching.dart new file mode 100644 index 0000000..10544cb --- /dev/null +++ b/lib/pages/home/widgets/home_continue_watching.dart @@ -0,0 +1,147 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/data/models/watch_progress.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 _railHeight = 96; +const double _cardWidth = 286; +const double _coverWidth = 64; +const double _playSize = 34; + +/// One drama/record pair rendered by the continuation rail. +typedef HomeContinueItem = ({Drama drama, WatchProgress record}); + +/// Home continuation cards for records that still have unwatched time. +class HomeContinueWatching extends StatelessWidget { + const HomeContinueWatching({ + super.key, + required this.items, + required this.onPlay, + }); + + final List items; + final void Function(Drama drama, WatchProgress record) onPlay; + + @override + Widget build(BuildContext context) { + if (items.isEmpty) return const SizedBox.shrink(); + return SizedBox( + key: const Key('home-continue-watching'), + height: _railHeight, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: AppDimens.spaceSm), + itemBuilder: (context, index) { + final item = items[index]; + return _ContinueCard( + item: item, + onPlay: () => onPlay(item.drama, item.record), + ); + }, + ), + ); + } +} + +class _ContinueCard extends StatelessWidget { + const _ContinueCard({required this.item, required this.onPlay}); + + final HomeContinueItem item; + final VoidCallback onPlay; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + width: _cardWidth, + padding: const EdgeInsets.all(AppDimens.spaceSm), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + child: RemoteImage( + item.drama.posterUrl, + width: _coverWidth, + height: _railHeight - AppDimens.spaceLg, + ), + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.previewCardTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + _progressText(l10n, item.drama, item.record), + key: Key('home-continue-progress-${item.drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.cardMeta, + ), + ), + ], + ), + ), + const SizedBox(width: AppDimens.spaceSm), + IconButton( + key: Key('home-continue-play-${item.drama.id}'), + onPressed: onPlay, + style: IconButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + fixedSize: const Size.square(_playSize), + padding: EdgeInsets.zero, + ), + icon: const Icon( + Icons.play_arrow_rounded, + size: AppDimens.accountIcon, + ), + ), + ], + ), + ); + } + + String _progressText( + AppLocalizations l10n, + Drama drama, + WatchProgress record, + ) { + final episode = drama.episodes.where( + (value) => value.id == record.episodeId, + ); + final number = episode.isEmpty ? 1 : episode.first.number; + final remaining = record.duration - record.position; + final minutes = remaining.inSeconds <= 0 + ? 0 + : (remaining.inSeconds / Duration.secondsPerMinute).ceil(); + return l10n.episodeRemaining(number, minutes); + } +} diff --git a/lib/pages/home/widgets/home_menu_sheet.dart b/lib/pages/home/widgets/home_menu_sheet.dart index 0a255b3..80b4f3a 100644 --- a/lib/pages/home/widgets/home_menu_sheet.dart +++ b/lib/pages/home/widgets/home_menu_sheet.dart @@ -74,7 +74,7 @@ class HomeMenuSheet extends StatelessWidget { _item( key: const Key('drawer-discover'), icon: Icons.explore_outlined, - label: l10n.discoverTab, + label: l10n.theaterTab, onTap: () => closeThen(onDiscover), ), _item( diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart index 192bd47..51783b3 100644 --- a/lib/pages/mine/mine_types.dart +++ b/lib/pages/mine/mine_types.dart @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart'; import '../../core/data/models/drama.dart'; /// 「我的」页与根导航之间的返回动作。 -enum MyPageAction { home, discover, resume } +enum MyPageAction { home, discover, create, resume } /// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。 class MyPageCallbacks { diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 92e41bb..0beee47 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -245,8 +245,8 @@ class _MyPageState extends State { items: [ AppBottomNavItem( tabKey: const Key('mine-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, + icon: Icons.home_outlined, + selectedIcon: Icons.home_rounded, label: l10n.homeTab, onTap: () => _act(MyPageAction.home), ), @@ -254,9 +254,16 @@ class _MyPageState extends State { tabKey: const Key('mine-nav-discover'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, + label: l10n.theaterTab, onTap: () => _act(MyPageAction.discover), ), + AppBottomNavItem( + tabKey: const Key('mine-nav-create'), + icon: Icons.add_box_outlined, + selectedIcon: Icons.add_box_rounded, + label: l10n.createTab, + onTap: () => _act(MyPageAction.create), + ), AppBottomNavItem( tabKey: const Key('mine-nav-current'), icon: Icons.video_library_outlined, diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 986f88c..2f462d3 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -944,43 +944,19 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { backgroundColor: AppColors.card, showDragHandle: true, builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - key: const Key('player-more-speed'), - leading: const Icon( - Icons.speed_rounded, - color: AppColors.secondary, - ), - title: Text(widget.l10n.playbackSpeed), - trailing: Text( - _speedLabel(widget.playbackSpeed), - style: const TextStyle( - color: AppColors.blue, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - onTap: () { - Navigator.pop(sheetContext); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onSpeedTap(); - }); - }, - ), - if (widget.qualities.isNotEmpty) + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ ListTile( - key: const Key('player-more-quality'), + key: const Key('player-more-speed'), leading: const Icon( - Icons.high_quality_rounded, + Icons.speed_rounded, color: AppColors.secondary, ), - title: Text(widget.l10n.playbackQuality), + title: Text(widget.l10n.playbackSpeed), trailing: Text( - widget.qualities.contains(QualitySheet.autoQuality) - ? widget.l10n.qualityAuto - : widget.qualities.first, + _speedLabel(widget.playbackSpeed), style: const TextStyle( color: AppColors.blue, fontSize: 12, @@ -989,76 +965,105 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { ), onTap: () { Navigator.pop(sheetContext); - widget.onQualityTap(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onSpeedTap(); + }); }, ), - ListTile( - key: const Key('player-more-download'), - leading: const Icon( - Icons.download_outlined, - color: AppColors.secondary, - ), - title: Text(widget.l10n.downloads), - onTap: () { - Navigator.pop(sheetContext); - widget.onDownload(); - }, - ), - if (widget.onPip != null) + if (widget.qualities.isNotEmpty) + ListTile( + key: const Key('player-more-quality'), + leading: const Icon( + Icons.high_quality_rounded, + color: AppColors.secondary, + ), + title: Text(widget.l10n.playbackQuality), + trailing: Text( + widget.qualities.contains(QualitySheet.autoQuality) + ? widget.l10n.qualityAuto + : widget.qualities.first, + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + widget.onQualityTap(); + }, + ), ListTile( - key: const Key('player-more-pip'), + key: const Key('player-more-download'), leading: const Icon( - Icons.picture_in_picture_alt_outlined, + Icons.download_outlined, color: AppColors.secondary, ), - title: Text(widget.l10n.pictureInPicture), + title: Text(widget.l10n.downloads), onTap: () { Navigator.pop(sheetContext); - widget.onPip!(); + widget.onDownload(); }, ), - ListTile( - key: const Key('player-more-like'), - leading: Icon( - widget.liked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - color: widget.liked ? AppColors.blue : AppColors.muted, + if (widget.onPip != null) + ListTile( + key: const Key('player-more-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(widget.l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + widget.onPip!(); + }, + ), + ListTile( + key: const Key('player-more-like'), + leading: Icon( + widget.liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + 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(); + }, ), - 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(); - }, - ), - ListTile( - key: const Key('player-more-favorite'), - leading: Icon( - widget.saved ? Icons.star_rounded : Icons.star_border_rounded, - color: widget.saved ? AppColors.blue : AppColors.muted, + ListTile( + key: const Key('player-more-favorite'), + leading: Icon( + widget.saved ? Icons.star_rounded : Icons.star_border_rounded, + color: widget.saved ? AppColors.blue : AppColors.muted, + ), + title: Text(widget.l10n.favorite), + onTap: () { + Navigator.pop(sheetContext); + widget.onSave(); + }, ), - title: Text(widget.l10n.favorite), - onTap: () { - Navigator.pop(sheetContext); - widget.onSave(); - }, - ), - ListTile( - key: const Key('player-more-share'), - leading: const Icon(Icons.share_outlined, color: AppColors.muted), - title: Text(widget.l10n.share), - onTap: () { - Navigator.pop(sheetContext); - widget.onShare(); - }, - ), - ], + ListTile( + key: const Key('player-more-share'), + leading: const Icon( + Icons.share_outlined, + color: AppColors.muted, + ), + title: Text(widget.l10n.share), + onTap: () { + Navigator.pop(sheetContext); + widget.onShare(); + }, + ), + ], + ), ), ), ); diff --git a/test/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart index 8cfad42..305292c 100644 --- a/test/account/mine_account_entry_test.dart +++ b/test/account/mine_account_entry_test.dart @@ -24,7 +24,7 @@ void main() { InMemorySharedPreferencesAsync.empty(); await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); @@ -47,7 +47,7 @@ void main() { ); await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); diff --git a/test/discover/discover_banner_test.dart b/test/discover/discover_banner_test.dart index 991e7c7..1a441c5 100644 --- a/test/discover/discover_banner_test.dart +++ b/test/discover/discover_banner_test.dart @@ -7,7 +7,7 @@ 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:fruitshorts/pages/player/player_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'; @@ -23,7 +23,7 @@ void main() { InMemorySharedPreferencesAsync.withData({}); }); - testWidgets('should_open_linked_drama_when_discover_banner_is_tapped', ( + testWidgets('should_open_fullscreen_player_when_home_hero_is_tapped', ( tester, ) async { final catalog = FakeCatalogRepository( @@ -37,15 +37,13 @@ void main() { ], ); 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); + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + expect(player.drama.title, 'The Signal'); }); } diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 8b5c736..81b001b 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -24,37 +24,27 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_show_search_categories_ranking_and_all_dramas', ( + testWidgets('should_show_search_category_language_and_drama_grid', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('ranking-tab-hot')), findsOneWidget); - expect(find.byKey(const Key('ranking-tab-new')), findsOneWidget); - - final grid = tester.widget( - find.byKey(const Key('discover-category-grid')), - ); - final delegate = - grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, 3); expect(tester.takeException(), isNull); }); - testWidgets('should_open_category_dramas_page_when_tile_is_tapped', ( + testWidgets('should_filter_grid_when_category_chip_is_tapped', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - await tester.tap(find.byKey(const Key('discover-category-7'))); + await tester.tap(find.byKey(const Key('theater-category-7'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('category-dramas-page')), findsOneWidget); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, @@ -76,22 +66,23 @@ void main() { ); final headerBefore = tester.getTopLeft( - find.byKey(const Key('discover-search-button')), + find.byKey(const Key('discover-search')), ); - final contentBefore = tester.getTopLeft( - find.byKey(const Key('discover-category-grid')), + final scrollable = find.descendant( + of: find.byKey(const Key('discover-refresh')), + matching: find.byType(Scrollable), ); await tester.drag(find.byType(ListView), const Offset(0, -140)); await tester.pumpAndSettle(); expect( - tester.getTopLeft(find.byKey(const Key('discover-search-button'))), + tester.getTopLeft(find.byKey(const Key('discover-search'))), headerBefore, ); expect( - tester.getTopLeft(find.byKey(const Key('discover-category-grid'))).dy, - lessThan(contentBefore.dy), + tester.state(scrollable).position.pixels, + greaterThan(0), ); }); @@ -172,7 +163,7 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); } diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 54ff50f..f9c2427 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -66,22 +66,20 @@ void main() { logic.openProfile(); - expect(logic.tab, 2); + expect(logic.tab, 3); logic.dispose(); }); - test('should_return_to_discover_and_handle_immersive_back', () { + test('should_return_home_and_handle_immersive_back', () { final logic = HomeLogic(dramas); logic.onSystemBack(); - expect(logic.tab, 1); - logic.onSystemBack(); - expect(logic.tab, 1); + expect(logic.tab, 0); expect(logic.backRequest, 0); - logic.select(0); + logic.select(1); logic.onSystemBack(); - expect(logic.tab, 1); + expect(logic.tab, 0); logic.select(0); logic.onImmersiveChanged(true); @@ -91,7 +89,7 @@ void main() { logic.onImmersiveChanged(false); logic.onSystemBack(); - expect(logic.tab, 1); + expect(logic.tab, 0); logic.dispose(); }); @@ -130,6 +128,9 @@ void main() { expect(logic.tab, 1); expect(logic.searchRequest, 1); + logic.myAction(MyPageAction.create); + expect(logic.tab, 2); + logic.myAction(MyPageAction.resume); expect(logic.tab, 0); expect(logic.resumeRequest, 1); @@ -144,6 +145,9 @@ void main() { final logic = HomeLogic(dramas); logic.discoverAction(DiscoverPageAction.my); + expect(logic.tab, 3); + + logic.discoverAction(DiscoverPageAction.create); expect(logic.tab, 2); logic.discoverAction(DiscoverPageAction.home); diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 65db607..d5f2563 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -19,8 +19,9 @@ import '../support/widget_player.dart'; const _keys = [ Key('home-tabs-bar'), Key('tab-home'), - Key('tab-discover'), - Key('tab-my'), + Key('tab-theater'), + Key('tab-create'), + Key('tab-mine'), ]; void main() { @@ -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(); @@ -68,20 +69,20 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.play_circle_fill_rounded), + matching: find.byIcon(Icons.home_rounded), ), findsOneWidget, ); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); expect( find.descendant( - of: find.byKey(const Key('tab-discover')), + of: find.byKey(const Key('tab-theater')), matching: find.byIcon(Icons.explore_rounded), ), findsOneWidget, @@ -89,25 +90,32 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.play_circle_outline_rounded), + matching: find.byIcon(Icons.home_outlined), ), findsOneWidget, ); }); - testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async { + testWidgets('should_map_four_tabs_to_home_theater_create_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-my')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('tab-theater')), findsOneWidget); + expect(find.byKey(const Key('tab-create')), findsOneWidget); + expect(find.byKey(const Key('tab-mine')), findsOneWidget); + expect(find.byKey(const Key('home-browse-page')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-create'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('create-page')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); expect(find.text('Viewer'), findsOneWidget); expect(find.byKey(const Key('mine-menu')), findsOneWidget); @@ -116,7 +124,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-browse-page')), findsOneWidget); }); testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', ( @@ -124,7 +132,7 @@ void main() { ) async { final haptics = FakeHaptics(); await _pumpApp(tester, haptics); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); haptics.reset(); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 82badc6..dccae62 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.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'; @@ -22,7 +23,7 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_preview_home_card_without_hot_or_follow_avatar', ( + testWidgets('should_render_browse_home_with_header_channels_and_cards', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -32,7 +33,16 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final catalog = FakeCatalogRepository(); + final catalog = FakeCatalogRepository( + banners: const [ + FeedBanner( + id: 'banner-1', + imageUrl: '/files/banner.jpg', + linkType: 'DRAMA', + linkValue: 'the-signal-demo', + ), + ], + ); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, @@ -51,40 +61,18 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); - expect(find.byKey(const Key('home-feed-episode')), findsOneWidget); - final cardRect = tester.getRect( - find.byKey(const Key('home-feed-info-card')), - ); - final episodeRect = tester.getRect( - find.byKey(const Key('home-feed-episode')), - ); - expect(episodeRect.left, lessThan(cardRect.center.dx)); - final railCenter = tester.getCenter(find.byKey(const Key('rail-like'))); - final railBottom = tester.getBottomLeft(find.byKey(const Key('rail-save'))); - expect(railCenter.dy, greaterThan(844 * 0.4)); - expect(railBottom.dy, lessThan(cardRect.top)); - expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget); - expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget); - for (final key in const [Key('home-feed-search'), Key('home-feed-more')]) { - expect(tester.getSize(find.byKey(key)), const Size(36, 36)); - expect( - tester - .widget( - find.descendant(of: find.byKey(key), matching: find.byType(Icon)), - ) - .size, - 18, - ); + expect(find.byKey(const Key('home-search')), findsOneWidget); + expect(find.byKey(const Key('home-profile')), findsOneWidget); + expect(find.byKey(const Key('home-weekday')), findsOneWidget); + for (final channel in ['hot', 'free', 'newReleases', 'ranking']) { + expect(find.byKey(Key('home-channel-$channel')), findsOneWidget); } + expect(find.byKey(const Key('home-hero-banner')), findsOneWidget); expect( - tester.getSize(find.byKey(const Key('home-feed-follow-series'))).height, - 34, + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, ); - expect(find.text('HOT'), findsNothing); - expect(find.textContaining('Season'), findsNothing); - expect(find.byKey(const Key('home-feed-follow')), findsNothing); + expect(find.byKey(const Key('home-feed-info-card')), findsNothing); expect(tester.takeException(), isNull); }); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart new file mode 100644 index 0000000..cc142da --- /dev/null +++ b/test/home/k28_a_navigation_test.dart @@ -0,0 +1,206 @@ +// 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/models/watch_progress.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/player/player_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.empty(); + }); + + testWidgets('should_render_four_tabs_and_open_create_placeholder', ( + tester, + ) async { + await _pumpApp(tester); + + for (final key in _tabKeys) { + expect(find.byKey(key), findsOneWidget); + } + + await tester.tap(find.byKey(const Key('tab-create'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('create-page')), findsOneWidget); + expect(find.text('Create'), findsWidgets); + expect(find.text('Stories become short dramas'), findsOneWidget); + }); + + testWidgets('should_switch_home_channel_when_chip_is_tapped', (tester) async { + await _pumpApp(tester); + + expect(_chip(tester, 'home-channel-hot').selected, isTrue); + expect(_chip(tester, 'home-channel-free').selected, isFalse); + + await tester.tap(find.byKey(const Key('home-channel-free'))); + await tester.pumpAndSettle(); + + expect(_chip(tester, 'home-channel-hot').selected, isFalse); + expect(_chip(tester, 'home-channel-free').selected, isTrue); + }); + + testWidgets('should_open_theater_search_when_home_search_is_tapped', ( + tester, + ) async { + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('home-search'))); + await tester.pumpAndSettle(); + + final field = tester.widget( + find.byKey(const Key('discover-search')), + ); + expect(field.focusNode?.hasFocus, isTrue); + }); + + testWidgets('should_select_theater_language_without_filtering_catalog', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + + expect(_chip(tester, 'theater-language-all').selected, isTrue); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('theater-language-id'))); + await tester.pumpAndSettle(); + + expect(_chip(tester, 'theater-language-all').selected, isFalse); + expect(_chip(tester, 'theater-language-id').selected, isTrue); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, + ); + }); + + testWidgets('should_open_fullscreen_player_when_home_card_is_tapped', ( + tester, + ) async { + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + }); + + testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + }); + + testWidgets('should_resume_continue_watching_at_saved_episode', ( + tester, + ) async { + await _pumpApp( + tester, + history: InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'the-signal-demo', + episodeId: 'the-signal-demo-3', + position: Duration(minutes: 2), + duration: Duration(minutes: 10), + ), + ], + ), + ); + + expect(find.byKey(const Key('home-continue-watching')), findsOneWidget); + await tester.tap( + find.byKey(const Key('home-continue-play-the-signal-demo')), + ); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + expect(player.initialIndex, 2); + }); + + testWidgets('should_fit_home_and_theater_on_320_width', (tester) async { + await _pumpApp(tester, size: const Size(320, 568)); + + expect(find.byKey(const Key('home-search')), findsOneWidget); + expect(find.byKey(const Key('home-hero-title-0')), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +const _tabKeys = [ + Key('tab-home'), + Key('tab-theater'), + Key('tab-create'), + Key('tab-mine'), +]; + +ChoiceChip _chip(WidgetTester tester, String key) => + tester.widget(find.byKey(Key(key))); + +Future _pumpApp( + WidgetTester tester, { + WatchHistoryRepository? history, + Size size = const Size(390, 844), +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository( + banners: const [ + FeedBanner( + id: 'banner-1', + imageUrl: '/files/banner.jpg', + linkType: 'DRAMA', + linkValue: 'the-signal-demo', + ), + ], + ); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 9edf04e..ddafc78 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -53,7 +53,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-user-row')), findsOneWidget); diff --git a/test/mine/settings_page_test.dart b/test/mine/settings_page_test.dart index 0a8e1a8..def8456 100644 --- a/test/mine/settings_page_test.dart +++ b/test/mine/settings_page_test.dart @@ -44,7 +44,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index 221d024..b413c55 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -44,7 +44,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index b5ecb15..f18776f 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -29,7 +29,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); await tester.pumpAndSettle(); @@ -50,43 +50,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_open_fullscreen_overlay_from_detail_cta_and_chip', ( - tester, - ) async { - await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); - _expectFullscreenOverlay(tester); - - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-episode-chip-2'))); - await tester.pumpAndSettle(); - _expectFullscreenOverlay(tester); - expect( - tester.widget(find.byKey(const Key('player-title'))).data, - contains('Episode 2'), - ); - expect(tester.takeException(), isNull); - }); - testWidgets('should_show_center_toggle_only_when_paused', (tester) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-play-toggle')), findsNothing); @@ -118,14 +84,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); @@ -208,14 +167,7 @@ void main() { testWidgets('should_fit_player_header_on_320_width', (tester) async { await _pumpApp(tester, size: const Size(320, 568)); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); expect( tester.getSize(find.byKey(const Key('player-back'))), @@ -228,14 +180,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-clear-screen'))); await tester.pump(); @@ -259,14 +204,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); @@ -298,14 +236,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); @@ -349,143 +280,13 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); final bar = tester.widget( find.byKey(const Key('player-progress')), ); expect(bar.showTimes, isTrue); }); - - testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); - expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - - await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); - await tester.pump(); - - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect(find.byKey(const Key('home-feed-info-card')), findsNothing); - expect(find.byKey(const Key('home-feed-scrim')), findsNothing); - expect(find.byKey(const Key('home-feed-video-tap')), findsOneWidget); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - expect( - tester.getSize(find.byKey(const Key('home-feed-exit-clear-screen'))), - const Size(36, 36), - ); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - expect( - find.ancestor( - of: find.byKey(const Key('home-feed-exit-clear-screen')), - matching: find.byType(SafeArea), - ), - findsWidgets, - ); - expect( - tester - .getTopLeft(find.byKey(const Key('home-feed-exit-clear-screen'))) - .dy, - greaterThanOrEqualTo(8), - ); - - await tester.tapAt(const Offset(80, 300)); - await tester.pump(); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('home-feed-center-play')), - ) - .playing, - isFalse, - ); - - await tester.tapAt(const Offset(80, 300)); - await tester.pump(); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - - await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen'))); - await tester.pump(); - - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); - expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); - expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - }); - - testWidgets('should_show_home_center_toggle_only_when_paused', ( - tester, - ) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - await tester.tapAt(const Offset(80, 300)); - await tester.pump(); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('home-feed-center-play')), - ) - .playing, - isFalse, - ); - - await tester.tap(find.byKey(const Key('home-feed-center-play'))); - await tester.pump(); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - }); - - testWidgets('should_keep_home_clear_state_when_swiping_to_next_page', ( - tester, - ) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); - await tester.pump(); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect(find.byKey(const Key('home-feed-info-card')), findsNothing); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - }); } void _expectFullscreenOverlay(WidgetTester tester) { @@ -505,6 +306,13 @@ void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('rail-save')), findsNothing); } +Future _openSeriesPlayer(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + await tester.pumpAndSettle(); +} + Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), diff --git a/test/watch/watch_history_provider_test.dart b/test/watch/watch_history_provider_test.dart index 2b10336..aef7233 100644 --- a/test/watch/watch_history_provider_test.dart +++ b/test/watch/watch_history_provider_test.dart @@ -40,7 +40,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); await tester.drag(find.byType(ListView), const Offset(0, -520)); await tester.pumpAndSettle(); diff --git a/test/widget_test.dart b/test/widget_test.dart index 1fce792..09849bc 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,7 +5,12 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/comment.dart'; -import 'package:fruitshorts/core/data/models/interaction.dart'; +import 'package:fruitshorts/core/data/repositories/comment_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'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/pages/player/player_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'; @@ -14,18 +19,8 @@ import 'support/fake_catalog.dart'; import 'support/fake_category.dart'; import 'support/fake_interaction_repositories.dart'; import 'support/widget_player.dart'; -import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; -import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; -import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; -import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; -import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; -import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; -import 'package:fruitshorts/core/data/services/playback_session.dart'; void main() { - /// 默认按「已登录」起测:账号体系接上后,"登录"不再是本地演示开关, - /// 而是本地会话(这里直接给 shared_preferences 的内存实现塞一份会话)。 - /// 昵称/uid 与演示文案保持一致,避免为显示细节改写既有断言。 void seedSignedInSession() { SharedPreferencesAsyncPlatform .instance = InMemorySharedPreferencesAsync.withData({ @@ -35,32 +30,20 @@ void main() { }); } - void seedSignedOutSession() { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - } + setUp(seedSignedInSession); - setUp(() { - seedSignedInSession(); - }); - - /// 所有 widget 测试都注入离线仓库:票据 / 进度 / 收藏都不碰网络, - /// 这样断言的是 UI 行为,而不是后端有没有起。 Widget appUnderTest({ PlayerFactory? playerFactory, - CatalogRepository? catalogRepository, CommentRepository? commentRepository, InteractionRepository? interactionRepository, }) { - final catalog = catalogRepository ?? FakeCatalogRepository(); + final catalog = FakeCatalogRepository(); return LumaDramaApp( playerFactory: playerFactory ?? createWidgetPlayer, catalogRepository: catalog, categoryRepository: FakeCategoryRepository(catalog: catalog), watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository( - cards: FakeCatalogRepository().dramas, - ), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), playbackRepository: FakePlaybackRepository(), commentRepository: commentRepository ?? FakeCommentRepository(), interactionRepository: @@ -68,36 +51,48 @@ void main() { ); } - testWidgets( - 'should_promote_next_without_recreating_it_or_showing_loading_when_home_is_swiped', - (tester) async { - final created = []; - await tester.pumpWidget( - appUnderTest( - playerFactory: (episode) { - created.add(episode.id); - return WidgetPlayer(); - }, - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(created, hasLength(2)); - expect(find.byType(CircularProgressIndicator), findsNothing); - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pump(); - expect(find.byType(CircularProgressIndicator), findsNothing); - await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - expect(created, hasLength(3)); - expect(created.toSet(), hasLength(3)); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pumpAndSettle(); - }, - ); + testWidgets('should_preload_next_episode_when_fullscreen_player_opens', ( + tester, + ) async { + final created = []; + await tester.pumpWidget( + appUnderTest( + playerFactory: (episode) { + created.add(episode.id); + return WidgetPlayer(); + }, + ), + ); + await tester.pumpAndSettle(); + await _openTheaterPlayer(tester); - testWidgets('should_open_series_detail_when_immersive_home_is_tapped', ( + expect(created, hasLength(2)); + expect(find.byType(CircularProgressIndicator), findsNothing); + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); + expect(created, hasLength(3)); + expect(created.toSet(), hasLength(3)); + }); + + testWidgets('should_open_fullscreen_player_when_home_card_is_tapped', ( + tester, + ) async { + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + }); + + testWidgets('should_keep_four_root_tabs_on_one_route_and_return_home', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); @@ -107,232 +102,12 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect(find.byType(Slider), findsNothing); - expect(find.text('Home'), findsWidgets); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); - expect(find.byKey(const Key('rail-like')), findsOneWidget); - expect(find.byKey(const Key('rail-comments')), findsOneWidget); - expect(find.byKey(const Key('rail-share')), findsOneWidget); - expect(find.byKey(const Key('rail-save')), findsOneWidget); - await tester.tap(find.byKey(const Key('home-feed-more'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-download')), findsOneWidget); - expect(find.byKey(const Key('home-feed-pip')), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.text('Watch Full'), findsNothing); - expect(find.byTooltip('Explore series'), findsNothing); - }); + await _openTheaterPlayer(tester); + expect(find.byType(PlayerPage), findsOneWidget); - testWidgets( - 'should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used', - (tester) async { - tester.view.physicalSize = const Size(360, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect( - tester - .widget(find.byType(PlayerPage, skipOffstage: false)) - .active, - isFalse, - ); - expect(find.text('Quick access'), findsOneWidget); - expect(find.text('7 EPISODES'), findsOneWidget); - expect(tester.takeException(), isNull); - - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('detail-cta')), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect( - tester - .widget(find.byType(PlayerPage, skipOffstage: false)) - .active, - isFalse, - ); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-discover'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect( - Navigator.of( - tester.element(find.byKey(const Key('home-all-dramas-grid'))), - ).canPop(), - isFalse, - ); - }, - ); - - testWidgets('should_follow_system_locale_without_language_switch', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - expect(find.byTooltip('Captions'), findsNothing); - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - expect(find.text('Vicky sheng'), findsOneWidget); - expect(find.text('Coins'), findsOneWidget); - expect(find.text('App Language'), findsNothing); - expect(find.text('简体中文'), findsNothing); - expect(find.byKey(const Key('mine-wallet')), findsOneWidget); - expect(find.text('Home'), findsWidgets); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - expect(find.text('Discover'), findsWidgets); - }); - - testWidgets( - 'should_fit_compact_android_viewport_when_home_controls_are_shown', - (tester) async { - tester.view.physicalSize = const Size(360, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - await tester.drag(find.byType(PageView), const Offset(0, -500)); - await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('home-drama-card-city-lights-demo')), - ); - await tester.pumpAndSettle(); - expect(find.byType(Slider), findsOneWidget); - expect(tester.takeException(), isNull); - }, - ); - - testWidgets( - 'should_fit_compact_android_viewport_and_return_home_when_my_page_is_shown', - (tester) async { - tester.view.physicalSize = const Size(360, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - expect(find.text('Vicky sheng'), findsOneWidget); - expect(find.text('Coins'), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - }, - ); - - testWidgets('should_fit_compact_dark_viewport_when_evening_theme_is_used', ( - tester, - ) async { - tester.view.physicalSize = const Size(320, 568); - tester.view.devicePixelRatio = 1; - tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect(tester.takeException(), isNull); - - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - expect(find.text('Coins'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - testWidgets( - 'should_show_three_playable_dramas_and_one_pending_poster_when_discover_is_opened', - (tester) async { - tester.view.physicalSize = const Size(360, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-profile')), findsOneWidget); - expect( - find.byKey( - const Key('home-drama-genre-city-lights-demo'), - skipOffstage: false, - ), - findsOneWidget, - ); - expect( - find.byKey( - const Key('home-drama-genre-vengeance-demo'), - skipOffstage: false, - ), - findsOneWidget, - ); - expect(find.byTooltip('Search'), findsOneWidget); - expect(tester.takeException(), isNull); - - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('mine-menu')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - }, - ); - - testWidgets('should_stay_on_one_route_when_bottom_tabs_are_used', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byTooltip('Search'), findsOneWidget); expect( Navigator.of( tester.element(find.byKey(const Key('home-all-dramas-grid'))), @@ -340,278 +115,187 @@ void main() { isFalse, ); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(find.byKey(const Key('mine-history')), findsOneWidget); + expect(find.byKey(const Key('home-browse-page')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-create'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('create-page')), findsOneWidget); + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mine-menu')), findsOneWidget); expect( Navigator.of(tester.element(find.byKey(const Key('mine-menu')))).canPop(), isFalse, ); - - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byTooltip('Search'), findsOneWidget); - expect( - Navigator.of( - tester.element(find.byKey(const Key('home-all-dramas-grid'))), - ).canPop(), - isFalse, - ); - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('mine-menu')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect( - Navigator.of(tester.element(find.text('The Signal'))).canPop(), - isFalse, - ); }); - testWidgets( - 'should_change_drama_on_home_swipe_and_episode_on_player_swipe_when_swiping', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - expect( - tester - .widget(find.byKey(const Key('home-feed-episode-title'))) - .data, - contains('Episode 1'), - ); - - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('home-drama-card-city-lights-demo')), - ); - await tester.pumpAndSettle(); - expect(find.byType(Slider), findsOneWidget); - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byKey(const Key('player-title'))).data, - contains('Episode 2'), - ); - expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); - }, - ); - - testWidgets('should_hide_category_navigation_when_shorts_page_is_shown', ( + testWidgets('should_follow_system_locale_without_language_switch', ( tester, ) async { - tester.view.physicalSize = const Size(390, 844); + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + expect(find.byTooltip('Captions'), findsNothing); + + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); + expect(find.text('Vicky sheng'), findsOneWidget); + expect(find.text('Coins'), findsOneWidget); + expect(find.text('App Language'), findsNothing); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + expect(find.text('Theater'), findsWidgets); + }); + + testWidgets('should_fit_compact_viewport_when_player_is_opened', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 720); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); + await _openTheaterPlayer(tester); + + expect(find.byType(Slider), findsOneWidget); + await tester.drag(find.byType(PageView), const Offset(0, -500)); await tester.pumpAndSettle(); - expect(find.text('Following'), findsNothing); - expect(find.text('Animated'), findsNothing); - expect(find.text('Live Action'), findsNothing); - expect(find.text('Recommended'), findsNothing); - expect(find.text('The Signal'), findsOneWidget); - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); expect(tester.takeException(), isNull); }); - testWidgets( - 'should_save_comment_like_and_share_in_order_when_right_rail_is_used', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - String? sharedText; - final interactions = FakeInteractionRepository(); - final comments = FakeCommentRepository(pages: [const CommentPage()]); - const channel = MethodChannel('com.stormg.lumadrama/share'); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, - ) async { - sharedText = (call.arguments as Map)['text'] as String; - return null; - }); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ), - ); - await tester.pumpWidget( - appUnderTest( - commentRepository: comments, - interactionRepository: interactions, - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - final keys = [ - const Key('rail-like'), - const Key('rail-comments'), - const Key('rail-share'), - const Key('rail-save'), - ]; - final tops = keys - .map((key) => tester.getTopLeft(find.byKey(key)).dy) - .toList(); - expect(tops, orderedEquals(tops.toList()..sort())); + testWidgets('should_render_theater_with_filters_and_same_route_tabs', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); - await tester.tap(find.byKey(const Key('rail-save'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-comments'))); - await tester.pumpAndSettle(); - expect(find.text('No comments yet.'), findsOneWidget); - await tester.enterText( - find.byKey(const Key('comment-input')), - 'Nice demo', - ); - await tester.tap(find.byKey(const Key('comment-post'))); - await tester.pumpAndSettle(); - expect(find.text('Nice demo'), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-like'))); - await tester.pumpAndSettle(); - expect(interactions.likeTargets, contains('DRAMA:the-signal-demo')); - await tester.tap(find.byKey(const Key('rail-share'))); - await tester.pumpAndSettle(); - expect(sharedText, contains('The Signal')); - expect(sharedText, contains('A demo series for vertical playback')); - expect(sharedText, contains('FruitShorts')); - expect(tester.takeException(), isNull); - }, - ); + expect(find.byKey(const Key('discover-search')), findsOneWidget); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-profile')), findsOneWidget); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_save_comment_like_and_share_from_player_more', ( + tester, + ) async { + String? sharedText; + final interactions = FakeInteractionRepository(); + final comments = FakeCommentRepository(pages: [const CommentPage()]); + const channel = MethodChannel('com.stormg.lumadrama/share'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + sharedText = (call.arguments as Map)['text'] as String; + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + + await tester.pumpWidget( + appUnderTest( + commentRepository: comments, + interactionRepository: interactions, + ), + ); + await tester.pumpAndSettle(); + await _openTheaterPlayer(tester); + + await tester.tap(find.byKey(const Key('player-comments'))); + await tester.pumpAndSettle(); + expect(find.text('No comments yet.'), findsOneWidget); + await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo'); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); + expect(find.text('Nice demo'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await _tapPlayerMore(tester, const Key('player-more-like')); + expect(interactions.likeTargets, contains('DRAMA:city-lights-demo')); + + await _tapPlayerMore(tester, const Key('player-more-favorite')); + await _tapPlayerMore(tester, const Key('player-more-share')); + expect(sharedText, contains('City Lights & Velvet Nights')); + expect(sharedText, contains('FruitShorts')); + expect(tester.takeException(), isNull); + }); testWidgets('should_open_auth_gate_without_liking_when_signed_out', ( tester, ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - seedSignedOutSession(); + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); final interactions = FakeInteractionRepository(); await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-like'))); - await tester.pumpAndSettle(); + await _openTheaterPlayer(tester); + await _tapPlayerMore(tester, const Key('player-more-like')); expect(find.text('Sign in to continue'), findsOneWidget); expect(interactions.likeTargets, isEmpty); }); - testWidgets('should_report_drama_from_detail_more_menu_when_submitted', ( - tester, - ) async { + testWidgets('should_show_saved_player_drama_in_favorites', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final interactions = FakeInteractionRepository(); - await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); + await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); + await _openTheaterPlayer(tester); + await _tapPlayerMore(tester, const Key('player-more-favorite')); + + await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-more'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Report drama')); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('report-submit'))); + await tester.tap(find.byKey(const Key('mine-watchlist'))); await tester.pumpAndSettle(); - expect(interactions.reports.single.targetType, InteractionTargetType.drama); - expect(find.text('Report submitted. Thank you.'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); }); +} - testWidgets( - 'should_open_drawer_and_stay_on_home_when_system_back_is_pressed', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); +Future _openTheaterPlayer(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + await tester.pumpAndSettle(); +} - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.text('Quick access'), findsOneWidget); - expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.text('Quick access'), findsNothing); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect( - Navigator.of( - tester.element(find.byKey(const Key('home-tabs-bar'))), - ).canPop(), - isFalse, - ); - }, - ); - - testWidgets( - 'should_list_saved_dramas_across_home_feed_when_watchlist_is_opened', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - // 收藏要求登录态:沿用本文件已有的"假登录"初始化 - seedSignedInSession(); - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('rail-save'))); - await tester.pumpAndSettle(); - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-save'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mine-watchlist'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); - await tester.tap(find.text('The Signal')); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('detail-cta')), findsOneWidget); - }, - ); +Future _tapPlayerMore(WidgetTester tester, Key action) async { + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(action)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(action)); + await tester.pumpAndSettle(); } From 49f3e63ed0247b72d4229864ed8465756150d26d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 09:57:10 +0800 Subject: [PATCH 019/124] =?UTF-8?q?feat(flutter):=20=E5=AE=8C=E6=88=90=20K?= =?UTF-8?q?28-A1=20=E5=9B=9B=20Tab=20=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_dimens.dart | 5 - lib/l10n/app_en.arb | 8 +- lib/l10n/app_id.arb | 8 +- lib/l10n/app_localizations.dart | 38 +-- lib/l10n/app_localizations_en.dart | 20 +- lib/l10n/app_localizations_id.dart | 21 +- lib/l10n/app_localizations_zh.dart | 20 +- lib/l10n/app_zh.arb | 8 +- lib/pages/create/create_view.dart | 134 ++--------- lib/pages/discover/discover_logic.dart | 16 -- lib/pages/discover/discover_types.dart | 3 - lib/pages/discover/discover_view.dart | 225 ++++++++---------- .../discover/widgets/discover_header.dart | 99 ++++++-- .../widgets/discover_search_field.dart | 4 +- lib/pages/discover/widgets/drama_grid.dart | 72 +++--- .../widgets/feed_banner_carousel.dart | 22 +- lib/pages/home/home_browse_view.dart | 75 +++--- .../home/widgets/home_continue_watching.dart | 147 ------------ lib/pages/home/widgets/home_drama_grid.dart | 188 +++++++++++++++ lib/pages/home/widgets/home_menu_sheet.dart | 2 +- .../mine/widgets/continue_watching_card.dart | 21 +- test/discover/home_all_dramas_grid_test.dart | 33 ++- test/home/k28_a_navigation_test.dart | 80 ++++--- test/player/player_fullscreen_test.dart | 15 +- test/widget_test.dart | 31 ++- 25 files changed, 606 insertions(+), 689 deletions(-) delete mode 100644 lib/pages/home/widgets/home_continue_watching.dart create mode 100644 lib/pages/home/widgets/home_drama_grid.dart diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 32bfcda..c482578 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -43,7 +43,6 @@ abstract final class AppDimens { static const double buttonLarge = 44; static const double tapTarget = 44; static const double contentHeaderHeight = 46; - static const double filterChipHeight = 34; static const double navHeight = 54; static const double navIcon = 17; @@ -61,11 +60,7 @@ abstract final class AppDimens { static const double watchPlaceholderHeight = 114; static const double heroBannerHeight = 176; - static const double continueCoverWidth = 52; - static const double continueCoverHeight = 66; static const double cardMeta = 9; static const double cardViews = 9; - static const double createIcon = 22; - static const double createArrow = 18; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9bc68f1..30c0b29 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -14,7 +14,7 @@ "openQuickMenu": "Open quick menu", "quickAccess": "Quick access", "currentSeries": "Current series", - "discoverTab": "Discover", + "discoverTab": "Theater", "searchDiscover": "Search dramas, actors, genres...", "featured": "Featured", "recommended": "Recommended", @@ -104,13 +104,7 @@ "recommendedTab": "Recommended", "noCategoryDramas": "No dramas in this category yet.", "allDramas": "All Dramas", - "allLanguages": "All languages", - "bahasaMelayu": "Malay", "createSubtitle": "Stories become short dramas", - "createAiTitle": "AI production", - "createAiSubtitle": "Start from a story or an idea", - "createUploadTitle": "Upload and publish", - "createUploadSubtitle": "Publish a finished short drama", "weekdayMon": "Mon", "weekdayTue": "Tue", "weekdayWed": "Wed", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 63617df..6ec3a23 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -14,7 +14,7 @@ "openQuickMenu": "Buka menu cepat", "quickAccess": "Akses cepat", "currentSeries": "Serial saat ini", - "discoverTab": "Temukan", + "discoverTab": "Teater", "searchDiscover": "Cari drama, aktor, genre...", "featured": "Unggulan", "recommended": "Rekomendasi", @@ -104,13 +104,7 @@ "recommendedTab": "Rekomendasi", "noCategoryDramas": "Belum ada drama di kategori ini.", "allDramas": "Semua Drama", - "allLanguages": "Semua bahasa", - "bahasaMelayu": "Bahasa Melayu", "createSubtitle": "Cerita menjadi drama pendek", - "createAiTitle": "Produksi AI", - "createAiSubtitle": "Mulai dari cerita atau ide", - "createUploadTitle": "Unggah dan terbitkan", - "createUploadSubtitle": "Terbitkan drama pendek yang sudah selesai", "weekdayMon": "Sen", "weekdayTue": "Sel", "weekdayWed": "Rab", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c184243..44dbabc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -187,7 +187,7 @@ abstract class AppLocalizations { /// No description provided for @discoverTab. /// /// In en, this message translates to: - /// **'Discover'** + /// **'Theater'** String get discoverTab; /// No description provided for @searchDiscover. @@ -550,48 +550,12 @@ abstract class AppLocalizations { /// **'All Dramas'** String get allDramas; - /// No description provided for @allLanguages. - /// - /// In en, this message translates to: - /// **'All languages'** - String get allLanguages; - - /// No description provided for @bahasaMelayu. - /// - /// In en, this message translates to: - /// **'Malay'** - String get bahasaMelayu; - /// No description provided for @createSubtitle. /// /// In en, this message translates to: /// **'Stories become short dramas'** String get createSubtitle; - /// No description provided for @createAiTitle. - /// - /// In en, this message translates to: - /// **'AI production'** - String get createAiTitle; - - /// No description provided for @createAiSubtitle. - /// - /// In en, this message translates to: - /// **'Start from a story or an idea'** - String get createAiSubtitle; - - /// No description provided for @createUploadTitle. - /// - /// In en, this message translates to: - /// **'Upload and publish'** - String get createUploadTitle; - - /// No description provided for @createUploadSubtitle. - /// - /// In en, this message translates to: - /// **'Publish a finished short drama'** - String get createUploadSubtitle; - /// No description provided for @weekdayMon. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 10367de..0195af7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -52,7 +52,7 @@ class AppLocalizationsEn extends AppLocalizations { String get currentSeries => 'Current series'; @override - String get discoverTab => 'Discover'; + String get discoverTab => 'Theater'; @override String get searchDiscover => 'Search dramas, actors, genres...'; @@ -248,27 +248,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get allDramas => 'All Dramas'; - @override - String get allLanguages => 'All languages'; - - @override - String get bahasaMelayu => 'Malay'; - @override String get createSubtitle => 'Stories become short dramas'; - @override - String get createAiTitle => 'AI production'; - - @override - String get createAiSubtitle => 'Start from a story or an idea'; - - @override - String get createUploadTitle => 'Upload and publish'; - - @override - String get createUploadSubtitle => 'Publish a finished short drama'; - @override String get weekdayMon => 'Mon'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 69ba775..2a36cc2 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -52,7 +52,7 @@ class AppLocalizationsId extends AppLocalizations { String get currentSeries => 'Serial saat ini'; @override - String get discoverTab => 'Temukan'; + String get discoverTab => 'Teater'; @override String get searchDiscover => 'Cari drama, aktor, genre...'; @@ -248,28 +248,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get allDramas => 'Semua Drama'; - @override - String get allLanguages => 'Semua bahasa'; - - @override - String get bahasaMelayu => 'Bahasa Melayu'; - @override String get createSubtitle => 'Cerita menjadi drama pendek'; - @override - String get createAiTitle => 'Produksi AI'; - - @override - String get createAiSubtitle => 'Mulai dari cerita atau ide'; - - @override - String get createUploadTitle => 'Unggah dan terbitkan'; - - @override - String get createUploadSubtitle => - 'Terbitkan drama pendek yang sudah selesai'; - @override String get weekdayMon => 'Sen'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 2256dbc..1cf3021 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -51,7 +51,7 @@ class AppLocalizationsZh extends AppLocalizations { String get currentSeries => '当前短剧'; @override - String get discoverTab => '发现'; + String get discoverTab => '剧场'; @override String get searchDiscover => '搜索短剧、演员、类型…'; @@ -243,27 +243,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get allDramas => '全部剧目'; - @override - String get allLanguages => '全部语言'; - - @override - String get bahasaMelayu => '马来语'; - @override String get createSubtitle => '让故事成为漫剧'; - @override - String get createAiTitle => 'AI 制作'; - - @override - String get createAiSubtitle => '从故事或想法开始'; - - @override - String get createUploadTitle => '上传发布'; - - @override - String get createUploadSubtitle => '发布已完成的漫剧'; - @override String get weekdayMon => '周一'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a434316..8bbee93 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -14,7 +14,7 @@ "openQuickMenu": "打开快捷菜单", "quickAccess": "快捷入口", "currentSeries": "当前短剧", - "discoverTab": "发现", + "discoverTab": "剧场", "searchDiscover": "搜索短剧、演员、类型…", "featured": "精选", "recommended": "推荐", @@ -104,13 +104,7 @@ "recommendedTab": "推荐", "noCategoryDramas": "这个分类暂时没有剧集。", "allDramas": "全部剧目", - "allLanguages": "全部语言", - "bahasaMelayu": "马来语", "createSubtitle": "让故事成为漫剧", - "createAiTitle": "AI 制作", - "createAiSubtitle": "从故事或想法开始", - "createUploadTitle": "上传发布", - "createUploadSubtitle": "发布已完成的漫剧", "weekdayMon": "周一", "weekdayTue": "周二", "weekdayWed": "周三", diff --git a/lib/pages/create/create_view.dart b/lib/pages/create/create_view.dart index 0ff779c..4e0a414 100644 --- a/lib/pages/create/create_view.dart +++ b/lib/pages/create/create_view.dart @@ -1,6 +1,5 @@ // Author: fruit.com.cn -// K28 creation tab placeholder; creator workflows land in the next batch. import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; @@ -8,7 +7,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; -/// Honest skeleton for the future creation workspace. +/// K28-A1 placeholder for the future creation workspace. class CreatePage extends StatelessWidget { const CreatePage({super.key}); @@ -19,7 +18,7 @@ class CreatePage extends StatelessWidget { data: AppTheme.dark(), child: SafeArea( bottom: false, - child: ListView( + child: Padding( key: const Key('create-page'), padding: const EdgeInsets.fromLTRB( AppDimens.pageHorizontal, @@ -27,119 +26,30 @@ class CreatePage extends StatelessWidget { AppDimens.pageHorizontal, AppDimens.spaceXl, ), - children: [ - Text( - l10n.createTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: AppDimens.pageTitle, - fontWeight: FontWeight.w800, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.createTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, + ), ), - ), - const SizedBox(height: AppDimens.spaceSm), - Text( - l10n.createSubtitle, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.body, + const SizedBox(height: AppDimens.spaceSm), + Text( + l10n.createSubtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, + ), ), - ), - const SizedBox(height: AppDimens.spaceXl), - _CreateEntry( - key: const Key('create-ai'), - icon: Icons.auto_awesome_rounded, - title: l10n.createAiTitle, - subtitle: l10n.createAiSubtitle, - detail: l10n.comingSoon, - ), - const SizedBox(height: AppDimens.spaceMd), - _CreateEntry( - key: const Key('create-upload'), - icon: Icons.upload_file_rounded, - title: l10n.createUploadTitle, - subtitle: l10n.createUploadSubtitle, - detail: l10n.comingSoon, - ), - ], + ], + ), ), ), ); } } - -class _CreateEntry extends StatelessWidget { - const _CreateEntry({ - super.key, - required this.icon, - required this.title, - required this.subtitle, - required this.detail, - }); - - final IconData icon; - final String title; - final String subtitle; - final String detail; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppDimens.cardPadding), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - border: Border.all(color: AppColors.border), - ), - child: Row( - children: [ - Container( - width: AppDimens.tapTarget, - height: AppDimens.tapTarget, - decoration: BoxDecoration( - color: AppColors.blue.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: Icon( - icon, - color: AppColors.blue, - size: AppDimens.createIcon, - ), - ), - const SizedBox(width: AppDimens.spaceMd), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppDimens.spaceXs), - Text( - '$subtitle · $detail', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - ), - ), - ], - ), - ), - const SizedBox(width: AppDimens.spaceSm), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.subtle, - size: AppDimens.createArrow, - ), - ], - ), - ); - } -} diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index b654e35..7df910f 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -28,8 +28,6 @@ class DiscoverLogic extends ChangeNotifier { String _lastRequestedQuery = ''; int _searchRequest = 0; bool _disposed = false; - TheaterLanguage language = TheaterLanguage.all; - String? selectedCategoryId; bool get hasSearchQuery => search.text.trim().isNotEmpty; @@ -50,20 +48,6 @@ class DiscoverLogic extends ChangeNotifier { void openSearch() => searchFocus.requestFocus(); - /// Selects a language for the visual filter state. - void selectLanguage(TheaterLanguage value) { - if (language == value) return; - language = value; - notify(); - } - - /// Selects one category chip; null represents all categories. - void selectCategory(String? categoryId) { - if (selectedCategoryId == categoryId) return; - selectedCategoryId = categoryId; - notify(); - } - void retrySearch() { final query = search.text.trim(); if (query.isNotEmpty) unawaited(_runSearch(query)); diff --git a/lib/pages/discover/discover_types.dart b/lib/pages/discover/discover_types.dart index 25e3f47..d369cc9 100644 --- a/lib/pages/discover/discover_types.dart +++ b/lib/pages/discover/discover_types.dart @@ -7,9 +7,6 @@ import '../../core/data/models/drama.dart'; /// 剧场页与根导航之间的动作。 enum DiscoverPageAction { home, create, my } -/// Language choices shown by the theater filter UI. -enum TheaterLanguage { all, zh, id, ms, en } - /// 剧场页导航回调;没有 `onAction` 时按独立页面处理。 class DiscoverPageCallbacks { const DiscoverPageCallbacks({ diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 3f7a245..277177c 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -4,8 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../../core/data/models/content_category.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/home_feed.dart'; +import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/search_repository.dart'; +import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; @@ -15,14 +19,19 @@ import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; +import 'category_dramas_page.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'discover_types.dart'; +import 'widgets/category_grid.dart'; import 'widgets/discover_header.dart'; +import 'widgets/discover_search_field.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/drama_grid.dart'; +import 'widgets/feed_banner_carousel.dart'; +import 'widgets/ranking_section.dart'; -/// Theater page with fixed search, category chips, and a two-column catalogue. +/// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。 class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, @@ -87,18 +96,13 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); + final banners = context.watch().banners; final content = SafeArea( bottom: !embedded, child: Column( children: [ - DiscoverHeader( - controller: logic.search, - focusNode: logic.searchFocus, - onChanged: (_) => logic.onSearchChanged(), - loading: logic.searchLoading, - onProfile: _openProfile, - ), - Expanded(child: _feed(l10n, categories)), + DiscoverHeader(onSearch: logic.openSearch, onProfile: _openProfile), + Expanded(child: _feed(l10n, categories, banners)), if (!embedded) _bottomNav(l10n), ], ), @@ -114,28 +118,43 @@ class _DiscoverPageState extends State { ); } - Widget _feed(AppLocalizations l10n, CategoryController categories) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: logic.searchFocus.unfocus, - child: RefreshIndicator( - key: const Key('discover-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, - onRefresh: categories.load, - child: ListView( - controller: logic.scroll, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 24), - children: _content(l10n, categories), - ), + Widget _feed( + AppLocalizations l10n, + CategoryController categories, + List banners, + ) { + return RefreshIndicator( + key: const Key('discover-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: categories.load, + child: ListView( + controller: logic.scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 24), + children: [ + Padding( + padding: const EdgeInsets.only(top: 8), + child: DiscoverSearchField( + controller: logic.search, + focusNode: logic.searchFocus, + onChanged: (_) => logic.onSearchChanged(), + loading: logic.searchLoading, + ), + ), + ..._content(l10n, categories, banners), + ], ), ); } - List _content(AppLocalizations l10n, CategoryController categories) { + List _content( + AppLocalizations l10n, + CategoryController categories, + List banners, + ) { if (logic.hasSearchQuery) return [_searchContent(l10n)]; - return _theaterContent(l10n, categories); + return _theaterContent(l10n, categories, banners); } Widget _searchContent(AppLocalizations l10n) => DiscoverSearchResults( @@ -149,6 +168,7 @@ class _DiscoverPageState extends State { List _theaterContent( AppLocalizations l10n, CategoryController categories, + List banners, ) { if (categories.loading && categories.feed.sections.isEmpty) { return [_block(const CircularProgressIndicator(strokeWidth: 2))]; @@ -168,19 +188,38 @@ class _DiscoverPageState extends State { ), ]; } - return _theaterBlocks(l10n, categories); + return _theaterBlocks(l10n, categories, banners); } List _theaterBlocks( AppLocalizations l10n, CategoryController categories, + List banners, ) { - final dramas = logic.selectedCategoryId == null - ? categories.feed.dramas - : categories.dramasOf(logic.selectedCategoryId!); + final dramas = categories.feed.dramas; return [ - _categoryChips(l10n, categories), - _languageChips(l10n), + Padding( + padding: const EdgeInsets.only(top: 12), + child: CategoryGrid( + categories: categories.categories, + onTap: (category) => _openCategory(category, categories), + ), + ), + if (banners.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: FeedBannerCarousel( + banners: banners, + active: widget.active, + onOpen: _openBanner, + ), + ), + if (categories.feed.sections.isNotEmpty || dramas.isNotEmpty) + RankingSection( + sections: categories.feed.sections, + fallback: dramas, + onOpen: _openDrama, + ), _sectionTitle(l10n.allDramas), if (dramas.isEmpty) _emptyCatalog(l10n) else _dramaGrid(l10n, dramas), ]; @@ -203,93 +242,6 @@ class _DiscoverPageState extends State { return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama); } - Widget _categoryChips(AppLocalizations l10n, CategoryController categories) { - return Padding( - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - AppDimens.spaceMd, - AppDimens.pageHorizontal, - 0, - ), - child: Wrap( - key: const Key('theater-category-chips'), - spacing: AppDimens.spaceSm, - runSpacing: AppDimens.spaceSm, - children: [ - _filterChip( - key: const Key('theater-category-all'), - label: l10n.all, - selected: logic.selectedCategoryId == null, - onSelected: () => logic.selectCategory(null), - ), - for (final category in categories.categories) - _filterChip( - key: Key('theater-category-${category.id}'), - label: category.name, - selected: logic.selectedCategoryId == category.id, - onSelected: () => logic.selectCategory(category.id), - ), - ], - ), - ); - } - - Widget _languageChips(AppLocalizations l10n) { - final entries = [ - (TheaterLanguage.all, l10n.allLanguages), - (TheaterLanguage.zh, l10n.simplifiedChinese), - (TheaterLanguage.id, l10n.bahasaIndonesia), - (TheaterLanguage.ms, l10n.bahasaMelayu), - (TheaterLanguage.en, l10n.english), - ]; - return Padding( - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - AppDimens.spaceSm, - AppDimens.pageHorizontal, - 0, - ), - child: Wrap( - key: const Key('theater-language-chips'), - spacing: AppDimens.spaceSm, - runSpacing: AppDimens.spaceSm, - children: [ - for (final entry in entries) - _filterChip( - key: Key('theater-language-${entry.$1.name}'), - label: entry.$2, - selected: logic.language == entry.$1, - onSelected: () => logic.selectLanguage(entry.$1), - ), - ], - ), - ); - } - - Widget _filterChip({ - required Key key, - required String label, - required bool selected, - required VoidCallback onSelected, - }) { - return ChoiceChip( - key: key, - label: Text(label), - selected: selected, - showCheckmark: false, - onSelected: (_) => onSelected(), - backgroundColor: AppColors.card, - selectedColor: AppColors.blue, - side: BorderSide(color: selected ? AppColors.blue : AppColors.border), - labelStyle: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.muted, - fontSize: AppDimens.chipFont, - fontWeight: FontWeight.w700, - ), - shape: const StadiumBorder(), - ); - } - Widget _sectionTitle(String title) { return Padding( padding: const EdgeInsets.fromLTRB(14, 18, 14, 10), @@ -339,6 +291,18 @@ class _DiscoverPageState extends State { ); } + void _openCategory(ContentCategory category, CategoryController categories) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CategoryDramasPage( + title: category.name, + dramas: categories.dramasOf(category.id), + onOpen: widget.callbacks.onDramaSelected == null ? null : _openDrama, + ), + ), + ); + } + void _openDrama(Drama drama) { final callback = widget.callbacks.onDramaSelected; if (callback != null) { @@ -354,6 +318,27 @@ class _DiscoverPageState extends State { ); } + Future _openBanner(FeedBanner banner) async { + final dramaId = banner.dramaId; + if (dramaId == null) return; + for (final drama in context.read().dramas) { + if (drama.id == dramaId) { + _pushDetail(drama); + return; + } + } + try { + final drama = await context.read().fetchDrama(dramaId); + if (mounted) _pushDetail(drama); + } catch (_) { + if (!mounted) return; + final message = AppLocalizations.of(context)!.detailLoadFailed; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + } + Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( @@ -367,7 +352,7 @@ class _DiscoverPageState extends State { tabKey: const Key('discover-nav-current'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, - label: l10n.theaterTab, + label: l10n.discoverTab, selected: true, onTap: () {}, ), diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart index f034c89..1d20061 100644 --- a/lib/pages/discover/widgets/discover_header.dart +++ b/lib/pages/discover/widgets/discover_header.dart @@ -3,48 +3,37 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; -import 'discover_search_field.dart'; -/// Theater header with a persistent search field and profile entry. +/// 剧场页页头:品牌、搜索与个人页入口。 class DiscoverHeader extends StatelessWidget { const DiscoverHeader({ super.key, - required this.controller, - required this.focusNode, - required this.onChanged, - required this.loading, + required this.onSearch, required this.onProfile, }); - final TextEditingController controller; - final FocusNode focusNode; - final ValueChanged onChanged; - final bool loading; + final VoidCallback onSearch; final VoidCallback onProfile; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return SizedBox( - height: AppDimens.contentHeaderHeight, + height: 46, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, - ), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ - Expanded( - child: DiscoverSearchField( - controller: controller, - focusNode: focusNode, - onChanged: onChanged, - loading: loading, - horizontalPadding: 0, - ), + _brand(l10n), + const Spacer(), + _HeaderIcon( + key: const Key('discover-search-button'), + icon: Icons.search_rounded, + tooltip: l10n.search, + onTap: onSearch, ), - const SizedBox(width: AppDimens.spaceSm), + const SizedBox(width: 8), _profile(l10n), ], ), @@ -52,6 +41,37 @@ class DiscoverHeader extends StatelessWidget { ); } + Widget _brand(AppLocalizations l10n) { + return Row( + children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, + ), + ), + const SizedBox(width: 8), + Text( + l10n.discoverTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ], + ); + } + Widget _profile(AppLocalizations l10n) { return Semantics( button: true, @@ -81,3 +101,34 @@ class DiscoverHeader extends StatelessWidget { ); } } + +class _HeaderIcon extends StatelessWidget { + const _HeaderIcon({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 18), + ), + ); + } +} diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index 7688b8a..de549af 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -13,19 +13,17 @@ class DiscoverSearchField extends StatelessWidget { required this.focusNode, required this.onChanged, required this.loading, - this.horizontalPadding = 14, }); final TextEditingController controller; final FocusNode focusNode; final ValueChanged onChanged; final bool loading; - final double horizontalPadding; @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + padding: const EdgeInsets.symmetric(horizontal: 14), child: SizedBox( height: 36, child: TextField( diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index 71a3b55..7974b5d 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -2,10 +2,9 @@ import 'package:flutter/material.dart'; +import '../../../core/config/app_config.dart'; import '../../../core/data/models/drama.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 int _columns = 2; @@ -84,8 +83,6 @@ class DramaGridCard extends StatelessWidget { _title(), const SizedBox(height: 2), _genre(), - const SizedBox(height: AppDimens.spaceXs), - _metadata(), ], ), ); @@ -118,7 +115,7 @@ class DramaGridCard extends StatelessWidget { l10n.free, style: const TextStyle( color: AppColors.onPrimary, - fontSize: AppDimens.cardMeta, + fontSize: 8, fontWeight: FontWeight.w800, ), ), @@ -133,7 +130,7 @@ class DramaGridCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: AppDimens.previewSectionTitle, + fontSize: 11, fontWeight: FontWeight.w800, ), ); @@ -146,39 +143,7 @@ class DramaGridCard extends StatelessWidget { key: Key('home-drama-genre-${drama.id}'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.cardMeta, - ), - ); - } - - Widget _metadata() { - return Wrap( - spacing: AppDimens.spaceSm, - runSpacing: AppDimens.spaceXs, - children: [ - Text( - l10n.episodeCount(drama.episodeCount), - key: Key('home-drama-episode-count-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: AppDimens.cardViews, - ), - ), - Text( - l10n.viewCountLabel(drama.viewCount), - key: Key('home-drama-views-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: AppDimens.cardViews, - ), - ), - ], + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ); } } @@ -190,12 +155,33 @@ class _DiscoverCoverImage extends StatelessWidget { @override Widget build(BuildContext context) { - return RemoteImage( - path, + final url = AppConfig.mediaUrl(path); + if (url.isEmpty) return _placeholder(); + return Image.network( + url, width: double.infinity, fit: BoxFit.fitWidth, - showFallbackIcon: true, - fallbackAspectRatio: 3 / 4, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return _placeholder(); + }, + errorBuilder: (_, _, _) => _placeholder(), + ); + } + + Widget _placeholder() { + return AspectRatio( + aspectRatio: 3 / 4, + child: ColoredBox( + color: AppColors.card, + child: const Center( + child: Icon( + Icons.image_not_supported_outlined, + color: AppColors.muted, + size: 32, + ), + ), + ), ); } } diff --git a/lib/pages/discover/widgets/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart index 8ad894d..17101bc 100644 --- a/lib/pages/discover/widgets/feed_banner_carousel.dart +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -4,13 +4,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../../core/data/models/home_feed.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/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; @@ -115,6 +116,9 @@ class _FeedBannerCarouselState extends State { @override Widget build(BuildContext context) { if (widget.banners.isEmpty) return const SizedBox.shrink(); + final height = widget.dramaFor == null + ? _carouselHeight + : AppDimens.heroBannerHeight; return Listener( key: const Key('feed-banner-carousel'), onPointerDown: (_) => _pause(), @@ -123,7 +127,7 @@ class _FeedBannerCarouselState extends State { child: Column( children: [ SizedBox( - height: AppDimens.heroBannerHeight, + height: height, child: PageView.builder( controller: _controller, itemCount: widget.banners.length, @@ -147,20 +151,22 @@ class _FeedBannerCarouselState extends State { fit: StackFit.expand, children: [ RemoteImage(banner.imageUrl, showFallbackIcon: true), - const DecoratedBox( + DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x120B0C10), Color(0xE60B0C10)], + colors: drama == null + ? const [Color(0x00000000), Color(0x8A0B0C10)] + : const [Color(0x120B0C10), Color(0xE60B0C10)], ), ), ), if (drama != null) Positioned( - left: 16, - right: 64, - bottom: 14, + left: AppDimens.pageHorizontal, + right: _copyRightGap, + bottom: AppDimens.cardPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -244,6 +250,8 @@ class _FeedBannerCarouselState extends State { } } +const double _copyRightGap = 64; + class _PlayMark extends StatelessWidget { const _PlayMark(); diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index a13408d..724e43c 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -10,16 +10,15 @@ 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/catalog_controller.dart'; -import '../../core/data/services/category_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; -import '../discover/widgets/drama_grid.dart'; import '../discover/widgets/feed_banner_carousel.dart'; +import '../mine/widgets/continue_watching_card.dart'; import 'home_browse_logic.dart'; import 'widgets/home_browse_header.dart'; -import 'widgets/home_continue_watching.dart'; +import 'widgets/home_drama_grid.dart'; /// Browse-first home page with channels, hero, continuation, and catalogue. class HomeBrowseView extends StatefulWidget { @@ -77,11 +76,7 @@ class _HomeBrowseViewState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final categories = context.watch(); final catalog = context.watch(); - final source = categories.feed.dramas.isEmpty - ? catalog.dramas - : categories.feed.dramas; return SafeArea( bottom: false, child: Column( @@ -90,57 +85,58 @@ class _HomeBrowseViewState extends State { onSearch: widget.onSearch, onProfile: widget.onProfile, ), - Expanded(child: _body(l10n, categories, catalog, source)), + Expanded(child: _body(l10n, catalog)), ], ), ); } - Widget _body( - AppLocalizations l10n, - CategoryController categories, - CatalogController catalog, - List source, - ) { + Widget _body(AppLocalizations l10n, CatalogController catalog) { return RefreshIndicator( key: const Key('home-refresh'), color: AppColors.blue, backgroundColor: AppColors.card, onRefresh: () async { - await Future.wait([categories.load(), logic.refreshRecent()]); + await Future.wait([catalog.load(), logic.refreshRecent()]); }, child: ListView( key: const Key('home-browse-page'), physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - children: _sections(l10n, catalog, source), + children: _sections(l10n, catalog), ), ); } - List _sections( - AppLocalizations l10n, - CatalogController catalog, - List source, - ) { + List _sections(AppLocalizations l10n, CatalogController catalog) { + final source = catalog.dramas; final dramas = _channelDramas(source, logic.channel); - final continueItems = _continueItems(logic.recent, catalog.dramas); + final continueItem = _continueItem(logic.recent, catalog.dramas); + final banners = catalog.banners.isEmpty + ? _fallbackBanners(source) + : catalog.banners; return [ _channels(l10n), - if (catalog.banners.isNotEmpty) _hero(catalog.banners, source), - if (continueItems.isNotEmpty) ...[ + if (banners.isNotEmpty) _hero(banners, source), + if (continueItem != null) ...[ _continueHeader(l10n), - HomeContinueWatching( - items: continueItems, - onPlay: (drama, record) => - unawaited(widget.onOpenContinue(drama, record)), + ContinueWatchingCard( + cardKey: const Key('home-continue-watching'), + playButtonKey: Key('home-continue-play-${continueItem.drama.id}'), + progressKey: Key('home-continue-progress-${continueItem.drama.id}'), + showRemaining: true, + drama: continueItem.drama, + record: continueItem.record, + onResume: () => unawaited( + widget.onOpenContinue(continueItem.drama, continueItem.record), + ), ), ], _channelHeader(l10n), if (dramas.isEmpty) _empty() else - DramaGrid( + HomeDramaGrid( l10n: l10n, dramas: dramas, onOpen: (drama) => unawaited(widget.onOpenDrama(drama)), @@ -148,6 +144,19 @@ class _HomeBrowseViewState extends State { ]; } + List _fallbackBanners(List dramas) { + if (dramas.isEmpty) return const []; + final drama = dramas.first; + return [ + FeedBanner( + id: 'home-fallback-${drama.id}', + imageUrl: drama.posterUrl, + linkType: 'DRAMA', + linkValue: drama.id, + ), + ]; + } + Widget _continueHeader(AppLocalizations l10n) => _sectionHeader( l10n.continueWatching, actionKey: const Key('home-continue-all'), @@ -314,20 +323,18 @@ class _HomeBrowseViewState extends State { return null; } - List _continueItems( + ({Drama drama, WatchProgress record})? _continueItem( List records, List dramas, ) { - final items = []; for (final record in records) { if (record.finished || !record.hasPosition) continue; for (final drama in dramas) { if (drama.id == record.dramaId) { - items.add((drama: drama, record: record)); - break; + return (drama: drama, record: record); } } } - return items; + return null; } } diff --git a/lib/pages/home/widgets/home_continue_watching.dart b/lib/pages/home/widgets/home_continue_watching.dart deleted file mode 100644 index 10544cb..0000000 --- a/lib/pages/home/widgets/home_continue_watching.dart +++ /dev/null @@ -1,147 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -import '../../../core/data/models/drama.dart'; -import '../../../core/data/models/watch_progress.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 _railHeight = 96; -const double _cardWidth = 286; -const double _coverWidth = 64; -const double _playSize = 34; - -/// One drama/record pair rendered by the continuation rail. -typedef HomeContinueItem = ({Drama drama, WatchProgress record}); - -/// Home continuation cards for records that still have unwatched time. -class HomeContinueWatching extends StatelessWidget { - const HomeContinueWatching({ - super.key, - required this.items, - required this.onPlay, - }); - - final List items; - final void Function(Drama drama, WatchProgress record) onPlay; - - @override - Widget build(BuildContext context) { - if (items.isEmpty) return const SizedBox.shrink(); - return SizedBox( - key: const Key('home-continue-watching'), - height: _railHeight, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, - ), - itemCount: items.length, - separatorBuilder: (_, _) => const SizedBox(width: AppDimens.spaceSm), - itemBuilder: (context, index) { - final item = items[index]; - return _ContinueCard( - item: item, - onPlay: () => onPlay(item.drama, item.record), - ); - }, - ), - ); - } -} - -class _ContinueCard extends StatelessWidget { - const _ContinueCard({required this.item, required this.onPlay}); - - final HomeContinueItem item; - final VoidCallback onPlay; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Container( - width: _cardWidth, - padding: const EdgeInsets.all(AppDimens.spaceSm), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - ), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: RemoteImage( - item.drama.posterUrl, - width: _coverWidth, - height: _railHeight - AppDimens.spaceLg, - ), - ), - const SizedBox(width: AppDimens.spaceSm), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.drama.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.previewCardTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppDimens.spaceXs), - Text( - _progressText(l10n, item.drama, item.record), - key: Key('home-continue-progress-${item.drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.cardMeta, - ), - ), - ], - ), - ), - const SizedBox(width: AppDimens.spaceSm), - IconButton( - key: Key('home-continue-play-${item.drama.id}'), - onPressed: onPlay, - style: IconButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - fixedSize: const Size.square(_playSize), - padding: EdgeInsets.zero, - ), - icon: const Icon( - Icons.play_arrow_rounded, - size: AppDimens.accountIcon, - ), - ), - ], - ), - ); - } - - String _progressText( - AppLocalizations l10n, - Drama drama, - WatchProgress record, - ) { - final episode = drama.episodes.where( - (value) => value.id == record.episodeId, - ); - final number = episode.isEmpty ? 1 : episode.first.number; - final remaining = record.duration - record.position; - final minutes = remaining.inSeconds <= 0 - ? 0 - : (remaining.inSeconds / Duration.secondsPerMinute).ceil(); - return l10n.episodeRemaining(number, minutes); - } -} diff --git a/lib/pages/home/widgets/home_drama_grid.dart b/lib/pages/home/widgets/home_drama_grid.dart new file mode 100644 index 0000000..3a884a8 --- /dev/null +++ b/lib/pages/home/widgets/home_drama_grid.dart @@ -0,0 +1,188 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.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 int _columns = 2; +const double _columnGap = 10; +const double _cardGap = 10; +const double _coverAspectRatio = 3 / 4; + +/// Two-column drama grid used by the browse-first home page. +class HomeDramaGrid extends StatelessWidget { + const HomeDramaGrid({ + super.key, + required this.l10n, + required this.dramas, + required this.onOpen, + }); + + final AppLocalizations l10n; + final List dramas; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + return Padding( + key: const Key('home-popular-grid'), + padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _column(0)), + const SizedBox(width: _columnGap), + Expanded(child: _column(1)), + ], + ), + ); + } + + Widget _column(int start) { + final children = []; + for (var index = start; index < dramas.length; index += _columns) { + final drama = dramas[index]; + children.add( + HomeDramaCard(l10n: l10n, drama: drama, onTap: () => onOpen(drama)), + ); + if (index + _columns < dramas.length) { + children.add(const SizedBox(height: _cardGap)); + } + } + return Column(children: children); + } +} + +/// Home catalogue card with title, genre, free badge, episode count, and heat. +class HomeDramaCard extends StatelessWidget { + const HomeDramaCard({ + super.key, + required this.l10n, + required this.drama, + required this.onTap, + }); + + final AppLocalizations l10n; + final Drama drama; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + key: Key('home-drama-card-${drama.id}'), + onTap: onTap, + borderRadius: BorderRadius.circular(AppDimens.coverRadius), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _cover(), + const SizedBox(height: AppDimens.spaceXs), + _title(), + const SizedBox(height: 2), + _genre(), + const SizedBox(height: AppDimens.spaceXs), + _metadata(), + ], + ), + ); + } + + Widget _cover() { + return ClipRRect( + key: Key('home-drama-cover-${drama.id}'), + borderRadius: BorderRadius.circular(AppDimens.coverRadius), + child: Stack( + children: [ + RemoteImage( + drama.posterUrl, + width: double.infinity, + fit: BoxFit.fitWidth, + showFallbackIcon: true, + fallbackAspectRatio: _coverAspectRatio, + ), + if (_isFree) Positioned(top: 6, left: 6, child: _freeBadge()), + ], + ), + ); + } + + bool get _isFree { + return drama.freeEpisodes > 0 || + drama.episodes.any((episode) => episode.isFree); + } + + Widget _freeBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + l10n.free, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: AppDimens.cardMeta, + fontWeight: FontWeight.w800, + ), + ), + ); + } + + Widget _title() { + return Text( + drama.title, + key: Key('home-drama-title-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.previewSectionTitle, + fontWeight: FontWeight.w800, + ), + ); + } + + Widget _genre() { + final genre = drama.genre.isEmpty ? drama.category : drama.genre; + return Text( + genre.isEmpty ? l10n.videoPending : genre, + key: Key('home-drama-genre-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.cardMeta, + ), + ); + } + + Widget _metadata() { + return Wrap( + spacing: AppDimens.spaceSm, + runSpacing: AppDimens.spaceXs, + children: [ + Text( + l10n.episodeCount(drama.episodeCount), + key: Key('home-drama-episode-count-${drama.id}'), + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.cardViews, + ), + ), + Text( + l10n.viewCountLabel(drama.viewCount), + key: Key('home-drama-views-${drama.id}'), + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.cardViews, + ), + ), + ], + ); + } +} diff --git a/lib/pages/home/widgets/home_menu_sheet.dart b/lib/pages/home/widgets/home_menu_sheet.dart index 80b4f3a..0a255b3 100644 --- a/lib/pages/home/widgets/home_menu_sheet.dart +++ b/lib/pages/home/widgets/home_menu_sheet.dart @@ -74,7 +74,7 @@ class HomeMenuSheet extends StatelessWidget { _item( key: const Key('drawer-discover'), icon: Icons.explore_outlined, - label: l10n.theaterTab, + label: l10n.discoverTab, onTap: () => closeThen(onDiscover), ), _item( diff --git a/lib/pages/mine/widgets/continue_watching_card.dart b/lib/pages/mine/widgets/continue_watching_card.dart index 357f595..e849a8d 100644 --- a/lib/pages/mine/widgets/continue_watching_card.dart +++ b/lib/pages/mine/widgets/continue_watching_card.dart @@ -15,17 +15,25 @@ class ContinueWatchingCard extends StatelessWidget { required this.drama, required this.record, required this.onResume, + this.cardKey = const Key('mine-continue-watching'), + this.playButtonKey = const Key('mine-continue-play'), + this.progressKey, + this.showRemaining = false, }); final Drama drama; final WatchProgress record; final VoidCallback onResume; + final Key cardKey; + final Key playButtonKey; + final Key? progressKey; + final bool showRemaining; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Container( - key: const Key('mine-continue-watching'), + key: cardKey, margin: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.all(9), decoration: BoxDecoration( @@ -45,7 +53,7 @@ class ContinueWatchingCard extends StatelessWidget { width: 60, height: 28, child: FilledButton( - key: const Key('mine-continue-play'), + key: playButtonKey, onPressed: onResume, style: FilledButton.styleFrom( backgroundColor: AppColors.blue, @@ -90,6 +98,7 @@ class ContinueWatchingCard extends StatelessWidget { const SizedBox(height: 2), Text( _progressText(l10n), + key: progressKey, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), @@ -100,6 +109,14 @@ class ContinueWatchingCard extends StatelessWidget { String _progressText(AppLocalizations l10n) { final episode = drama.episodes.where((item) => item.id == record.episodeId); + if (showRemaining) { + final number = episode.isEmpty ? 1 : episode.first.number; + final remaining = record.duration - record.position; + final minutes = remaining.inSeconds <= 0 + ? 0 + : (remaining.inSeconds / Duration.secondsPerMinute).ceil(); + return l10n.episodeRemaining(number, minutes); + } final label = episode.isEmpty ? l10n.episodeCount(drama.episodeCount) : l10n.episodeOfTotal(episode.first.number, drama.episodeCount); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 81b001b..fc99d42 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -24,27 +24,37 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_show_search_category_language_and_drama_grid', ( + testWidgets('should_show_search_categories_ranking_and_all_dramas', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('ranking-tab-hot')), findsOneWidget); + expect(find.byKey(const Key('ranking-tab-new')), findsOneWidget); + + final grid = tester.widget( + find.byKey(const Key('discover-category-grid')), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 3); expect(tester.takeException(), isNull); }); - testWidgets('should_filter_grid_when_category_chip_is_tapped', ( + testWidgets('should_open_category_dramas_page_when_tile_is_tapped', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - await tester.tap(find.byKey(const Key('theater-category-7'))); + await tester.tap(find.byKey(const Key('discover-category-7'))); await tester.pumpAndSettle(); + expect(find.byKey(const Key('category-dramas-page')), findsOneWidget); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, @@ -66,23 +76,22 @@ void main() { ); final headerBefore = tester.getTopLeft( - find.byKey(const Key('discover-search')), + find.byKey(const Key('discover-search-button')), ); - final scrollable = find.descendant( - of: find.byKey(const Key('discover-refresh')), - matching: find.byType(Scrollable), + final contentBefore = tester.getTopLeft( + find.byKey(const Key('discover-category-grid')), ); await tester.drag(find.byType(ListView), const Offset(0, -140)); await tester.pumpAndSettle(); expect( - tester.getTopLeft(find.byKey(const Key('discover-search'))), + tester.getTopLeft(find.byKey(const Key('discover-search-button'))), headerBefore, ); expect( - tester.state(scrollable).position.pixels, - greaterThan(0), + tester.getTopLeft(find.byKey(const Key('discover-category-grid'))).dy, + lessThan(contentBefore.dy), ); }); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index cc142da..fd6b428 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -68,35 +68,15 @@ void main() { expect(field.focusNode?.hasFocus, isTrue); }); - testWidgets('should_select_theater_language_without_filtering_catalog', ( - tester, - ) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-theater'))); - await tester.pumpAndSettle(); - - expect(_chip(tester, 'theater-language-all').selected, isTrue); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsOneWidget, - ); - - await tester.tap(find.byKey(const Key('theater-language-id'))); - await tester.pumpAndSettle(); - - expect(_chip(tester, 'theater-language-all').selected, isFalse); - expect(_chip(tester, 'theater-language-id').selected, isTrue); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsOneWidget, - ); - }); - testWidgets('should_open_fullscreen_player_when_home_card_is_tapped', ( tester, ) async { await _pumpApp(tester); + await tester.ensureVisible( + find.byKey(const Key('home-drama-card-the-signal-demo')), + ); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); await tester.pumpAndSettle(); @@ -104,6 +84,20 @@ void main() { expect(player.homeMode, isFalse); }); + testWidgets('should_use_first_drama_cover_when_home_has_no_banner', ( + tester, + ) async { + await _pumpApp(tester, withBanner: false); + + expect(find.byKey(const Key('home-hero-banner')), findsOneWidget); + expect(find.byKey(const Key('home-hero-title-0')), findsOneWidget); + await tester.tap(find.byKey(const Key('feed-banner-0'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + }); + testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( tester, ) async { @@ -111,7 +105,20 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); + final card = find.byKey(const Key('home-drama-card-the-signal-demo')); + await tester.scrollUntilVisible( + card, + 220, + scrollable: find + .descendant( + of: find.byKey(const Key('discover-refresh')), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.ensureVisible(card); + await tester.pumpAndSettle(); + await tester.tap(card); await tester.pumpAndSettle(); final player = tester.widget(find.byType(PlayerPage)); @@ -136,6 +143,7 @@ void main() { ); expect(find.byKey(const Key('home-continue-watching')), findsOneWidget); + expect(find.text('Episode 3 · 8 min left'), findsOneWidget); await tester.tap( find.byKey(const Key('home-continue-play-the-signal-demo')), ); @@ -155,7 +163,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); expect(tester.takeException(), isNull); }); } @@ -174,20 +183,23 @@ Future _pumpApp( WidgetTester tester, { WatchHistoryRepository? history, Size size = const Size(390, 844), + bool withBanner = true, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final catalog = FakeCatalogRepository( - banners: const [ - FeedBanner( - id: 'banner-1', - imageUrl: '/files/banner.jpg', - linkType: 'DRAMA', - linkValue: 'the-signal-demo', - ), - ], + banners: withBanner + ? const [ + FeedBanner( + id: 'banner-1', + imageUrl: '/files/banner.jpg', + linkType: 'DRAMA', + linkValue: 'the-signal-demo', + ), + ] + : const [], ); await tester.pumpWidget( LumaDramaApp( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index f18776f..11b4daa 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -309,7 +309,20 @@ void _expectFullscreenOverlay(WidgetTester tester) { Future _openSeriesPlayer(WidgetTester tester) async { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + final card = find.byKey(const Key('home-drama-card-city-lights-demo')); + await tester.scrollUntilVisible( + card, + 220, + scrollable: find + .descendant( + of: find.byKey(const Key('discover-refresh')), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.ensureVisible(card); + await tester.pumpAndSettle(); + await tester.tap(card); await tester.pumpAndSettle(); } diff --git a/test/widget_test.dart b/test/widget_test.dart index 09849bc..2c93dbf 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -84,6 +84,10 @@ void main() { ) async { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); + await tester.ensureVisible( + find.byKey(const Key('home-drama-card-the-signal-demo')), + ); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); await tester.pumpAndSettle(); @@ -172,7 +176,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_render_theater_with_filters_and_same_route_tabs', ( + testWidgets('should_render_existing_theater_layout_on_same_route_tab', ( tester, ) async { tester.view.physicalSize = const Size(360, 720); @@ -185,15 +189,11 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-search-button')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget); - expect( - find.byKey(const Key('home-drama-card-city-lights-demo')), - findsOneWidget, - ); expect(tester.takeException(), isNull); }); @@ -287,7 +287,20 @@ void main() { Future _openTheaterPlayer(WidgetTester tester) async { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + final card = find.byKey(const Key('home-drama-card-city-lights-demo')); + await tester.scrollUntilVisible( + card, + 220, + scrollable: find + .descendant( + of: find.byKey(const Key('discover-refresh')), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.ensureVisible(card); + await tester.pumpAndSettle(); + await tester.tap(card); await tester.pumpAndSettle(); } From 634b63e16c5fc3ded0e0ca7865743f4dc71d9cf0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 10:15:03 +0800 Subject: [PATCH 020/124] =?UTF-8?q?feat(flutter):=20=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E6=B5=85=E8=89=B2=E6=9A=96=E6=A9=99=E8=A7=86=E8=A7=89=E4=B8=BB?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 8 +- lib/core/theme/app_colors.dart | 121 +++++++----- lib/core/theme/app_dimens.dart | 35 ++-- lib/core/theme/app_theme.dart | 165 ++++++++-------- lib/core/widgets/app_bottom_nav.dart | 122 +++++++++--- lib/core/widgets/glass_panel.dart | 4 +- lib/l10n/app_en.arb | 12 ++ lib/l10n/app_id.arb | 12 ++ lib/l10n/app_localizations.dart | 12 ++ lib/l10n/app_localizations_en.dart | 10 + lib/l10n/app_localizations_id.dart | 10 + lib/l10n/app_localizations_zh.dart | 10 + lib/l10n/app_zh.arb | 12 ++ .../account/account_deactivate_page.dart | 2 +- lib/pages/account/account_devices_page.dart | 2 +- lib/pages/account/account_export_page.dart | 2 +- lib/pages/account/account_profile_page.dart | 2 +- lib/pages/auth/auth_view.dart | 9 +- lib/pages/create/create_view.dart | 4 +- lib/pages/discover/category_dramas_page.dart | 2 +- lib/pages/discover/discover_view.dart | 8 +- .../discover/widgets/discover_header.dart | 6 +- lib/pages/discover/widgets/drama_grid.dart | 147 ++++++++++++--- .../discover/widgets/ranking_section.dart | 4 +- lib/pages/downloads/downloads_page.dart | 9 +- lib/pages/drama_detail/drama_detail_view.dart | 16 +- lib/pages/favorites/favorites_page.dart | 2 +- lib/pages/home/home_browse_logic.dart | 12 +- lib/pages/home/home_browse_view.dart | 4 +- lib/pages/home/home_view.dart | 24 ++- .../home/widgets/home_browse_header.dart | 2 +- lib/pages/home/widgets/home_drama_grid.dart | 177 ++++++++++++++---- lib/pages/mine/mine_view.dart | 6 +- .../mine/widgets/continue_watching_card.dart | 2 +- lib/pages/mine/widgets/mine_header.dart | 6 +- lib/pages/notices/notices_page.dart | 2 +- lib/pages/player/player_logic.dart | 2 +- lib/pages/player/player_view.dart | 8 +- .../player/widgets/home_feed_overlay.dart | 17 +- .../player/widgets/player_center_toggle.dart | 4 +- .../widgets/player_featured_comment.dart | 4 +- .../player/widgets/seek_progress_bar.dart | 6 +- lib/pages/settings/settings_page.dart | 2 +- lib/pages/store/store_page.dart | 2 +- lib/pages/wallet/wallet_page.dart | 4 +- .../watch_records/watch_records_page.dart | 2 +- test/drama_detail/drama_detail_view_test.dart | 2 +- test/empty_state/empty_state_test.dart | 12 +- test/home/app_bottom_nav_test.dart | 60 +++++- test/home/home_card_style_test.dart | 69 +++++++ test/home/home_nav_ui_test.dart | 45 ++++- test/home/home_preview_layout_test.dart | 4 +- test/home/k28_a_navigation_test.dart | 14 +- test/player/player_center_toggle_test.dart | 3 +- test/theme/app_dimens_test.dart | 12 +- test/theme/app_theme_test.dart | 95 ++++------ 56 files changed, 941 insertions(+), 409 deletions(-) create mode 100644 test/home/home_card_style_test.dart diff --git a/lib/app.dart b/lib/app.dart index b697138..e51d6ba 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -251,14 +251,14 @@ class _LumaDramaAppState extends State { localeResolutionCallback: (deviceLocale, _) => resolveAppLocale(deviceLocale), debugShowCheckedModeBanner: false, - themeMode: ThemeMode.dark, + themeMode: ThemeMode.light, theme: AppTheme.light(), - darkTheme: AppTheme.dark(), + darkTheme: AppTheme.light(), builder: (context, child) => AnnotatedRegion( - value: SystemUiOverlayStyle.light.copyWith( + value: SystemUiOverlayStyle.dark.copyWith( statusBarColor: Colors.transparent, systemNavigationBarColor: AppColors.bar, - systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarIconBrightness: Brightness.dark, ), child: child ?? const SizedBox.shrink(), ), diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index da46392..a71d739 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -2,79 +2,102 @@ import 'package:flutter/material.dart'; -/// Obsidian Flare palette from `docs/design/README.md` and screens 01/02. +/// K28-S 浅色暖橙主题色板。 /// -/// Existing semantic names (`blue`, `blueSoft`, `ink`, ...) are kept so page -/// code does not need a mass rename; their values now follow the design tokens. +/// 保留既有语义名,避免页面大范围改名;实际颜色统一由本文件控制。 abstract final class AppColors { - /// Primary CTA, selected navigation, playback progress. - static const blue = Color(0xFFFF5B3D); + /// 主操作、选中态、播放进度。 + static const blue = Color(0xFFFF7A1A); static const primary = blue; - /// Pressed state for the primary CTA. - static const bluePressed = Color(0xFFFF4321); + /// 主操作按下态。 + static const bluePressed = Color(0xFFFF6A00); - /// Secondary accent for tags, locks, and highlighted icons. - static const blueSoft = Color(0xFF9278EA); + /// 粉色辅助强调色。 + static const blueSoft = Color(0xFFFF6A8A); static const secondary = blueSoft; static const lavender = blueSoft; - /// Coins and rewards. - static const gold = Color(0xFFFBBF24); + /// 金币与奖励。 + static const gold = Color(0xFFF3B34C); - /// Success and free-state accents. - static const success = Color(0xFF34D399); + /// 成功状态。 + static const success = Color(0xFF2FA873); - /// Liked hearts use the primary accent. - static const favorite = blue; + /// 收藏和喜欢使用粉色强调。 + static const favorite = secondary; - /// Text placed on [gold]. - static const onGold = Color(0xFF2A1810); + /// 金币底色上的文字。 + static const onGold = Color(0xFF5A3511); - /// Text and icons placed directly on [blue]. + /// 主色上的文字与图标。 static const onPrimary = Color(0xFFFFFFFF); - /// Page background. - static const ink = Color(0xFF0B0C10); + /// 页面背景。 + static const ink = Color(0xFFFFF8F0); - /// Bottom navigation background (rendered at 95% with blur). - static const bar = Color(0xFF0D0D11); + /// 页面顶部暖色渐变起点。 + static const surfaceTop = Color(0xFFFFE9D6); - /// Cards lifted off [ink]. - static const card = Color(0xFF1B1C21); + /// 底部导航底色。 + static const bar = Color(0xFFFFFDFB); - /// Alternate card surface from the mobile preview. - static const cardAlt = Color(0xFF1C1D21); + /// 中央操作按钮渐变。 + static const navCenterStart = Color(0xFFFF9A3D); + static const navCenterEnd = Color(0xFFFF6A00); + static const navCenterShadow = Color(0x33FF7A1A); - /// Floating surfaces, inputs, and nested rows. - static const raised = Color(0xFF1C1D21); - static const quick = Color(0xFF202126); - static const input = Color(0xFF25252A); - static const goldCard = Color(0xFF201B1E); + /// 卡片轻阴影。 + static const cardShadow = Color(0x14FF8A3D); - /// Body text on dark surfaces. - static const cream = Color(0xFFE6E1E3); + /// 卡片与弹层。 + static const card = Color(0xFFFFFFFF); - /// Titles and high-emphasis text. - static const onSurface = Color(0xFFFFFFFF); + /// 次要卡片表面。 + static const cardAlt = Color(0xFFFFF4E9); - /// Secondary text. - static const muted = Color(0xFFD2CDD0); + /// 浮层、输入框和嵌套区域。 + static const raised = Color(0xFFFFF1E5); + static const quick = Color(0xFFFFF0E1); + static const input = Color(0xFFFFF4EA); + static const goldCard = Color(0xFFFFF4DF); - /// Weak hints and timestamps. - static const subtle = Color(0xFF9A96A3); + /// 正文颜色。 + static const cream = Color(0xFF1F1B16); - /// Soft orange used for continue labels and secondary times. - static const accent = Color(0xFFFFA490); + /// 主文字。 + static const onSurface = Color(0xFF1F1B16); - /// 8% white border used by cards and inputs. - static const border = Color(0x14FFFFFF); + /// 次要文字。 + static const muted = Color(0xFF8C837B); - /// 14% white border used by glass controls. - static const glassBorder = Color(0x24FFFFFF); + /// 弱提示与时间。 + static const subtle = Color(0xFFB3AAA1); - static const outlineDark = Color(0xFF2A2C36); - static const outlineVariantDark = Color(0xFF242329); - static const outlineLight = Color(0xFF8E8E93); - static const outlineVariantLight = Color(0xFFE4E4E7); + /// 橙色强调文案。 + static const accent = blue; + + /// 卡片描边。 + static const border = Color(0xFFF0E4D6); + + /// 玻璃面板描边。 + static const glassBorder = Color(0x66FFFFFF); + + /// 粉色角标。 + static const badgePink = Color(0xFFFFE3EC); + static const badgePinkText = Color(0xFFFF6A8A); + + /// 封面锁定数量与视频遮罩。 + static const overlayDark = Color(0x33000000); + + /// 封面集数胶囊。 + static const badgeBeige = Color(0xFFFFF0DF); + + /// 导航未选中。 + static const navUnselected = muted; + + static const outlineDark = border; + static const outlineVariantDark = border; + static const outlineLight = muted; + static const outlineVariantLight = border; } diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index c482578..3e5f95c 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -1,6 +1,6 @@ // Author: fruit.com.cn -/// Cross-page spacing, type, radius, and control sizes. +/// 跨页面复用的间距、字号、圆角和控件尺寸。 abstract final class AppDimens { static const double spaceXs = 4; static const double spaceSm = 8; @@ -9,17 +9,18 @@ abstract final class AppDimens { static const double spaceXl = 24; static const double pageHorizontal = 16; - static const double cardRadius = 12; + static const double cardRadius = 16; static const double authCardRadius = 20; static const double cardPadding = 16; - static const double innerRadius = 10; + static const double innerRadius = 12; static const double innerPadding = 12; - static const double pillRadius = 20; - static const double glassSmallRadius = 12; - static const double glassRadius = 14; - static const double glassCardRadius = 14; - static const double buttonRadius = 9; - static const double coverRadius = 10; + static const double pillRadius = 999; + static const double glassSmallRadius = 16; + static const double glassRadius = 20; + static const double glassCardRadius = 20; + static const double buttonRadius = 999; + static const double coverRadius = 16; + static const double sheetRadius = 20; static const double caption = 12; static const double emptyStateSubtitle = 12.5; @@ -33,7 +34,7 @@ abstract final class AppDimens { static const double previewCardTitle = 13; static const double previewSectionTitle = 14; static const double chipFont = 10; - static const double navLabel = 9; + static const double navLabel = 10; static const double buttonHeight = 48; static const double primaryButtonHeight = 52; @@ -44,8 +45,10 @@ abstract final class AppDimens { static const double tapTarget = 44; static const double contentHeaderHeight = 46; - static const double navHeight = 54; - static const double navIcon = 17; + static const double navHeight = 64; + static const double navIcon = 20; + static const double navCenter = 56; + static const double navCenterIcon = 28; static const double progressHeight = 3; static const double progressRadius = 2; static const double progressLineHeight = 3; @@ -60,7 +63,11 @@ abstract final class AppDimens { static const double watchPlaceholderHeight = 114; static const double heroBannerHeight = 176; + static const double heroBannerRadius = 20; - static const double cardMeta = 9; - static const double cardViews = 9; + static const double cardMeta = 12; + static const double cardViews = 11; + static const double cardTitle = 15; + static const double cardShadowBlur = 18; + static const double cardShadowY = 6; } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 324e540..07f446e 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -6,79 +6,62 @@ import 'package:flutter/services.dart'; import 'app_colors.dart'; import 'app_dimens.dart'; -/// Obsidian Flare themes. Dark is the product default; light mirrors the same -/// orange primary for system-light devices. +/// K28-S 浅色暖橙主题。 abstract final class AppTheme { - static const _lightBackground = Color(0xFFF7F7F8); - static const _darkBackground = AppColors.ink; - - /// Light theme for system light mode. - static ThemeData light() => _build( - brightness: Brightness.light, - surface: _lightBackground, - accent: AppColors.blue, + /// 页面顶部暖色到米白背景的渐变。 + static const LinearGradient pageGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.surfaceTop, AppColors.ink], + stops: [0, 0.24], ); - /// Dark theme for system dark mode. - static ThemeData dark() => _build( - brightness: Brightness.dark, - surface: _darkBackground, - accent: AppColors.blue, + /// 卡片轻阴影。 + static const BoxShadow cardShadow = BoxShadow( + color: AppColors.cardShadow, + blurRadius: AppDimens.cardShadowBlur, + offset: Offset(0, AppDimens.cardShadowY), ); - static ThemeData _build({ - required Brightness brightness, - required Color surface, - required Color accent, - }) { - final dark = brightness == Brightness.dark; - final scheme = dark - ? const ColorScheme.dark( - primary: AppColors.blue, - onPrimary: AppColors.onPrimary, - secondary: AppColors.secondary, - onSecondary: AppColors.onPrimary, - primaryContainer: AppColors.bluePressed, - onPrimaryContainer: AppColors.onPrimary, - secondaryContainer: AppColors.raised, - onSecondaryContainer: AppColors.onPrimary, - surface: _darkBackground, - onSurface: AppColors.onSurface, - outline: AppColors.outlineDark, - outlineVariant: AppColors.outlineVariantDark, - surfaceTint: AppColors.blue, - inversePrimary: AppColors.blueSoft, - ) - : ColorScheme.light( - primary: AppColors.blue, - onPrimary: AppColors.onPrimary, - secondary: AppColors.secondary, - onSecondary: AppColors.onPrimary, - primaryContainer: AppColors.blue.withValues(alpha: 0.14), - onPrimaryContainer: AppColors.ink, - secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.14), - onSecondaryContainer: AppColors.ink, - surface: _lightBackground, - onSurface: AppColors.ink, - outline: AppColors.outlineLight, - outlineVariant: AppColors.outlineVariantLight, - surfaceTint: AppColors.blue, - inversePrimary: AppColors.blueSoft, - ); + /// 全局浅色暖橙主题。 + static ThemeData light() { + const scheme = ColorScheme.light( + primary: AppColors.blue, + onPrimary: AppColors.onPrimary, + secondary: AppColors.secondary, + onSecondary: AppColors.onPrimary, + primaryContainer: AppColors.surfaceTop, + onPrimaryContainer: AppColors.onSurface, + secondaryContainer: AppColors.badgePink, + onSecondaryContainer: AppColors.badgePinkText, + surface: AppColors.ink, + onSurface: AppColors.onSurface, + outline: AppColors.border, + outlineVariant: AppColors.border, + surfaceTint: AppColors.blue, + inversePrimary: AppColors.bluePressed, + ); final base = ThemeData( useMaterial3: true, - brightness: brightness, + brightness: Brightness.light, colorScheme: scheme, - scaffoldBackgroundColor: surface, + scaffoldBackgroundColor: AppColors.ink, fontFamily: 'PlusJakartaSans', - progressIndicatorTheme: ProgressIndicatorThemeData(color: accent), - sliderTheme: SliderThemeData( - activeTrackColor: accent, + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + ), + sliderTheme: const SliderThemeData( + activeTrackColor: AppColors.blue, + inactiveTrackColor: AppColors.border, thumbColor: AppColors.onPrimary, ), - tabBarTheme: TabBarThemeData(labelColor: accent, indicatorColor: accent), + tabBarTheme: const TabBarThemeData( + labelColor: AppColors.blue, + indicatorColor: AppColors.blue, + unselectedLabelColor: AppColors.muted, + ), textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom(foregroundColor: accent), + style: TextButton.styleFrom(foregroundColor: AppColors.blue), ), filledButtonTheme: FilledButtonThemeData( style: ButtonStyle( @@ -87,25 +70,26 @@ abstract final class AppTheme { minimumSize: const WidgetStatePropertyAll( Size(0, AppDimens.buttonHeight), ), + shape: const WidgetStatePropertyAll(StadiumBorder()), ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: ButtonStyle( - foregroundColor: WidgetStatePropertyAll( - dark ? AppColors.blueSoft : AppColors.bluePressed, - ), + foregroundColor: const WidgetStatePropertyAll(AppColors.blue), minimumSize: const WidgetStatePropertyAll( Size(0, AppDimens.buttonHeight), ), - side: WidgetStatePropertyAll( - BorderSide( - color: dark ? AppColors.outlineDark : AppColors.outlineLight, - ), + side: const WidgetStatePropertyAll( + BorderSide(color: AppColors.border), ), + shape: const WidgetStatePropertyAll(StadiumBorder()), ), ), appBarTheme: const AppBarTheme( - systemOverlayStyle: SystemUiOverlayStyle.light, + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onSurface, + elevation: 0, + systemOverlayStyle: SystemUiOverlayStyle.dark, titleTextStyle: TextStyle( color: AppColors.onSurface, fontFamily: 'Outfit', @@ -113,6 +97,16 @@ abstract final class AppTheme { fontWeight: FontWeight.w800, ), ), + iconTheme: const IconThemeData(color: AppColors.onSurface), + dividerTheme: const DividerThemeData(color: AppColors.border), + cardTheme: const CardThemeData( + color: AppColors.card, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(AppDimens.cardRadius)), + side: BorderSide(color: AppColors.border), + ), + ), switchTheme: SwitchThemeData( thumbColor: WidgetStateProperty.resolveWith( (states) => states.contains(WidgetState.selected) @@ -120,36 +114,35 @@ abstract final class AppTheme { : null, ), trackColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.selected) ? accent : null, + (states) => + states.contains(WidgetState.selected) ? AppColors.blue : null, ), ), - snackBarTheme: SnackBarThemeData( - backgroundColor: AppColors.bluePressed, - contentTextStyle: const TextStyle(color: AppColors.onPrimary), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.onSurface, + contentTextStyle: TextStyle(color: AppColors.onPrimary), ), ); final textTheme = base.textTheme .apply( fontFamily: 'PlusJakartaSans', - bodyColor: dark ? AppColors.cream : AppColors.ink, - displayColor: dark ? AppColors.onSurface : AppColors.ink, + bodyColor: AppColors.cream, + displayColor: AppColors.onSurface, ) .copyWith( - displayLarge: _display(base.textTheme.displayLarge, dark), - displayMedium: _display(base.textTheme.displayMedium, dark), - displaySmall: _display(base.textTheme.displaySmall, dark), - headlineLarge: _display(base.textTheme.headlineLarge, dark), - headlineMedium: _display(base.textTheme.headlineMedium, dark), - headlineSmall: _display(base.textTheme.headlineSmall, dark), - titleLarge: _display(base.textTheme.titleLarge, dark), + displayLarge: _display(base.textTheme.displayLarge), + displayMedium: _display(base.textTheme.displayMedium), + displaySmall: _display(base.textTheme.displaySmall), + headlineLarge: _display(base.textTheme.headlineLarge), + headlineMedium: _display(base.textTheme.headlineMedium), + headlineSmall: _display(base.textTheme.headlineSmall), + titleLarge: _display(base.textTheme.titleLarge), ); return base.copyWith(textTheme: textTheme); } - static TextStyle? _display(TextStyle? style, bool dark) => style?.copyWith( - fontFamily: 'Outfit', - color: dark ? AppColors.onSurface : AppColors.ink, - ); + static TextStyle? _display(TextStyle? style) => + style?.copyWith(fontFamily: 'Outfit', color: AppColors.onSurface); static Color _buttonColor(Set states) { if (states.contains(WidgetState.pressed) || diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index 880ae8f..0a7a72f 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; import '../theme/app_dimens.dart'; -/// One destination rendered by [AppBottomNav]. +/// 底部导航普通目的地。 class AppBottomNavItem { const AppBottomNavItem({ required this.tabKey, @@ -26,41 +26,69 @@ class AppBottomNavItem { final bool selected; } -/// Shared bottom navigation used by the root shell and standalone tabs. +/// 底部导航中央操作按钮。 +class AppBottomNavCenterAction { + const AppBottomNavCenterAction({ + required this.label, + required this.onTap, + this.key = const Key('nav-center-action'), + }); + + final Key key; + final String label; + final VoidCallback onTap; +} + +/// 全局五槽底部导航。 class AppBottomNav extends StatelessWidget { const AppBottomNav({ super.key, required this.items, + this.centerAction, this.backgroundColor = AppColors.bar, this.barKey, }); final List items; + final AppBottomNavCenterAction? centerAction; final Color backgroundColor; final Key? barKey; @override - Widget build(BuildContext context) => ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: SafeArea( - top: false, - child: Container( - key: barKey, - height: AppDimens.navHeight, - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.95), - border: const Border( - top: BorderSide(color: AppColors.outlineVariantDark), + Widget build(BuildContext context) { + final action = centerAction; + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: SafeArea( + top: false, + child: Container( + key: barKey, + height: AppDimens.navHeight, + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.95), + border: const Border(top: BorderSide(color: AppColors.border)), ), - ), - child: Row( - children: [for (final item in items) _NavDestination(item: item)], + child: action == null + ? Row( + children: [ + for (final item in items) _NavDestination(item: item), + ], + ) + : Row( + children: [ + for (final item in items.take(2)) + _NavDestination(item: item), + Expanded(child: _CenterDestination(action: action)), + for (final item in items.skip(2)) + _NavDestination(item: item), + ], + ), ), ), ), - ), - ); + ); + } } class _NavDestination extends StatelessWidget { @@ -78,7 +106,7 @@ class _NavDestination extends StatelessWidget { key: item.tabKey, onTap: item.onTap, child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 48), + constraints: const BoxConstraints(minHeight: AppDimens.navHeight), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -91,7 +119,7 @@ class _NavDestination extends StatelessWidget { size: AppDimens.navIcon, color: item.selected ? AppColors.blue - : Colors.white.withValues(alpha: 0.45), + : AppColors.navUnselected, ), ), const SizedBox(height: 2), @@ -104,7 +132,7 @@ class _NavDestination extends StatelessWidget { fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500, color: item.selected ? AppColors.blue - : Colors.white.withValues(alpha: 0.45), + : AppColors.navUnselected, letterSpacing: 0, ), ), @@ -115,3 +143,53 @@ class _NavDestination extends StatelessWidget { ), ); } + +class _CenterDestination extends StatelessWidget { + const _CenterDestination({required this.action}); + + final AppBottomNavCenterAction action; + + @override + Widget build(BuildContext context) { + return Center( + child: Semantics( + button: true, + label: action.label, + child: Material( + color: Colors.transparent, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: Ink( + width: AppDimens.navCenter, + height: AppDimens.navCenter, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.navCenterStart, AppColors.navCenterEnd], + ), + boxShadow: [ + BoxShadow( + color: AppColors.navCenterShadow, + blurRadius: 14, + offset: Offset(0, 5), + ), + ], + ), + child: InkWell( + key: action.key, + customBorder: const CircleBorder(), + onTap: action.onTap, + child: const Icon( + Icons.add_rounded, + color: AppColors.onPrimary, + size: AppDimens.navCenterIcon, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/glass_panel.dart b/lib/core/widgets/glass_panel.dart index 02d144c..1edd1fb 100644 --- a/lib/core/widgets/glass_panel.dart +++ b/lib/core/widgets/glass_panel.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; import '../theme/app_dimens.dart'; -/// Dark glass surface used by the home info card and floating controls. +/// 首页信息卡与浮层控件使用的浅色玻璃面板。 class GlassPanel extends StatelessWidget { const GlassPanel({ super.key, @@ -38,7 +38,7 @@ class GlassPanel extends StatelessWidget { const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0x24FFFFFF), Color(0x0DFFFFFF)], + colors: [Color(0xE6FFFFFF), Color(0xB3FFFDFB)], ), ), child: Padding(padding: padding ?? EdgeInsets.zero, child: child), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 30c0b29..c1077e2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -82,6 +82,18 @@ "count": {"type": "int"} } }, + "allEpisodesBadge": "All {count}", + "@allEpisodesBadge": { + "placeholders": { + "count": {"type": "int"} + } + }, + "lockedEpisodeCount": "{count} locked", + "@lockedEpisodeCount": { + "placeholders": { + "count": {"type": "int"} + } + }, "watchedPercent": "Watched {percent}%", "@watchedPercent": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 6ec3a23..f431983 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -82,6 +82,18 @@ "count": {"type": "int"} } }, + "allEpisodesBadge": "Semua {count}", + "@allEpisodesBadge": { + "placeholders": { + "count": {"type": "int"} + } + }, + "lockedEpisodeCount": "{count} terkunci", + "@lockedEpisodeCount": { + "placeholders": { + "count": {"type": "int"} + } + }, "watchedPercent": "Sudah ditonton {percent}%", "@watchedPercent": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 44dbabc..298338c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -460,6 +460,18 @@ abstract class AppLocalizations { /// **'{count} views'** String viewCountLabel(int count); + /// No description provided for @allEpisodesBadge. + /// + /// In en, this message translates to: + /// **'All {count}'** + String allEpisodesBadge(int count); + + /// No description provided for @lockedEpisodeCount. + /// + /// In en, this message translates to: + /// **'{count} locked'** + String lockedEpisodeCount(int count); + /// No description provided for @watchedPercent. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0195af7..18d075b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -201,6 +201,16 @@ class AppLocalizationsEn extends AppLocalizations { return '$count views'; } + @override + String allEpisodesBadge(int count) { + return 'All $count'; + } + + @override + String lockedEpisodeCount(int count) { + return '$count locked'; + } + @override String watchedPercent(int percent) { return 'Watched $percent%'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 2a36cc2..fcb7857 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -201,6 +201,16 @@ class AppLocalizationsId extends AppLocalizations { return '$count tayangan'; } + @override + String allEpisodesBadge(int count) { + return 'Semua $count'; + } + + @override + String lockedEpisodeCount(int count) { + return '$count terkunci'; + } + @override String watchedPercent(int percent) { return 'Sudah ditonton $percent%'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1cf3021..4990bb9 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -196,6 +196,16 @@ class AppLocalizationsZh extends AppLocalizations { return '$count 次播放'; } + @override + String allEpisodesBadge(int count) { + return '全 $count 集'; + } + + @override + String lockedEpisodeCount(int count) { + return '锁 $count'; + } + @override String watchedPercent(int percent) { return '已观看 $percent%'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8bbee93..111cb7c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -82,6 +82,18 @@ "count": {"type": "int"} } }, + "allEpisodesBadge": "全 {count} 集", + "@allEpisodesBadge": { + "placeholders": { + "count": {"type": "int"} + } + }, + "lockedEpisodeCount": "锁 {count}", + "@lockedEpisodeCount": { + "placeholders": { + "count": {"type": "int"} + } + }, "watchedPercent": "已观看 {percent}%", "@watchedPercent": { "placeholders": { diff --git a/lib/pages/account/account_deactivate_page.dart b/lib/pages/account/account_deactivate_page.dart index cf1799f..4b04d21 100644 --- a/lib/pages/account/account_deactivate_page.dart +++ b/lib/pages/account/account_deactivate_page.dart @@ -50,7 +50,7 @@ class _AccountDeactivatePageState extends State { final signedIn = context.watch().isSignedIn; final canShow = signedIn || _logic.scheduledAt != null; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( key: const Key('account-deactivate-page'), backgroundColor: AppColors.ink, diff --git a/lib/pages/account/account_devices_page.dart b/lib/pages/account/account_devices_page.dart index b64be9c..804a192 100644 --- a/lib/pages/account/account_devices_page.dart +++ b/lib/pages/account/account_devices_page.dart @@ -50,7 +50,7 @@ class _AccountDevicesPageState extends State { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( key: const Key('account-devices-page'), backgroundColor: AppColors.ink, diff --git a/lib/pages/account/account_export_page.dart b/lib/pages/account/account_export_page.dart index d17cde7..1bc0a9d 100644 --- a/lib/pages/account/account_export_page.dart +++ b/lib/pages/account/account_export_page.dart @@ -51,7 +51,7 @@ class _AccountExportPageState extends State { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( key: const Key('account-export-page'), backgroundColor: AppColors.ink, diff --git a/lib/pages/account/account_profile_page.dart b/lib/pages/account/account_profile_page.dart index d3aeac9..6b9ef9e 100644 --- a/lib/pages/account/account_profile_page.dart +++ b/lib/pages/account/account_profile_page.dart @@ -71,7 +71,7 @@ class _AccountProfilePageState extends State { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( key: const Key('account-profile-page'), backgroundColor: AppColors.ink, diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 0edfe59..18345a7 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -141,7 +141,7 @@ class _AuthPageState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( resizeToAvoidBottomInset: true, backgroundColor: AppColors.ink, @@ -332,7 +332,7 @@ class _AuthPageState extends State { Text( l10n.authLandingSubtitle, textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11), + style: const TextStyle(color: AppColors.subtle, fontSize: 11), ), const SizedBox(height: 22), _AuthEntryButton( @@ -358,7 +358,7 @@ class _AuthPageState extends State { key: const Key('auth-legal'), textAlign: TextAlign.center, style: const TextStyle( - color: Color(0xFF6B6772), + color: AppColors.muted, fontSize: 9, height: 1.6, ), @@ -442,8 +442,7 @@ class _AuthEntryButton extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (icon != null) - Icon(icon, size: 16, color: foreground), + if (icon != null) Icon(icon, size: 16, color: foreground), if (icon != null) const SizedBox(width: 7), Flexible( child: Text( diff --git a/lib/pages/create/create_view.dart b/lib/pages/create/create_view.dart index 4e0a414..313beb0 100644 --- a/lib/pages/create/create_view.dart +++ b/lib/pages/create/create_view.dart @@ -7,7 +7,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; -/// K28-A1 placeholder for the future creation workspace. +/// K28-A1 创作页占位骨架。 class CreatePage extends StatelessWidget { const CreatePage({super.key}); @@ -15,7 +15,7 @@ class CreatePage extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: SafeArea( bottom: false, child: Padding( diff --git a/lib/pages/discover/category_dramas_page.dart b/lib/pages/discover/category_dramas_page.dart index 385a52c..0687f49 100644 --- a/lib/pages/discover/category_dramas_page.dart +++ b/lib/pages/discover/category_dramas_page.dart @@ -27,7 +27,7 @@ class CategoryDramasPage extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( key: const Key('category-dramas-page'), backgroundColor: AppColors.ink, diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 277177c..71b06dd 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -108,9 +108,9 @@ class _DiscoverPageState extends State { ), ); return AnnotatedRegion( - value: SystemUiOverlayStyle.light, + value: SystemUiOverlayStyle.dark, child: Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), @@ -340,6 +340,10 @@ class _DiscoverPageState extends State { } Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( + centerAction: AppBottomNavCenterAction( + label: l10n.createTab, + onTap: () => _act(DiscoverPageAction.create), + ), items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart index 1d20061..c55adb5 100644 --- a/lib/pages/discover/widgets/discover_header.dart +++ b/lib/pages/discover/widgets/discover_header.dart @@ -88,12 +88,12 @@ class DiscoverHeader extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF6A5CFF), AppColors.blue], + colors: [AppColors.secondary, AppColors.blue], ), ), child: const Icon( Icons.person_rounded, - color: AppColors.onSurface, + color: AppColors.onPrimary, size: 18, ), ), @@ -124,7 +124,7 @@ class _HeaderIcon extends StatelessWidget { onPressed: onTap, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: const Color(0xFF2C2D33), + backgroundColor: AppColors.raised, foregroundColor: AppColors.onSurface, ), icon: Icon(icon, size: 18), diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index 7974b5d..437f827 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -5,11 +5,15 @@ import 'package:flutter/material.dart'; import '../../../core/config/app_config.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/theme/app_theme.dart'; import '../../../l10n/app_localizations.dart'; const int _columns = 2; const double _columnGap = 10; const double _cardGap = 10; +const double _addSize = 28; +const double _addIconSize = 18; /// 剧场/分类页共用的双列剧目网格。 class DramaGrid extends StatelessWidget { @@ -71,19 +75,33 @@ class DramaGridCard extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - key: Key('home-drama-card-${drama.id}'), - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _cover(), - const SizedBox(height: 5), - _title(), - const SizedBox(height: 2), - _genre(), - ], + return Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: Key('home-drama-card-${drama.id}'), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppDimens.spaceSm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _cover(), + const SizedBox(height: AppDimens.spaceSm), + _title(), + const SizedBox(height: AppDimens.spaceXs), + _footer(), + ], + ), + ), + ), ), ); } @@ -94,34 +112,90 @@ class DramaGridCard extends StatelessWidget { drama.episodes.any((episode) => episode.isFree); return ClipRRect( key: Key('home-drama-cover-${drama.id}'), - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(AppDimens.coverRadius), child: Stack( children: [ _DiscoverCoverImage(path: drama.posterUrl), - if (free) Positioned(top: 6, left: 6, child: _freeBadge()), + Positioned(top: 8, left: 8, child: _tagBadge(free)), + if (_lockedCount > 0) + Positioned(top: 8, right: 8, child: _lockedBadge()), + Positioned(right: 8, bottom: 8, child: _episodeBadge()), ], ), ); } - Widget _freeBadge() { + int get _lockedCount { + final explicit = drama.episodes.where((episode) => episode.locked).length; + if (explicit > 0) return explicit; + final inferred = drama.episodeCount - drama.freeEpisodes; + return inferred > 0 ? inferred : 0; + } + + Widget _tagBadge(bool free) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + key: Key('drama-tag-${drama.id}'), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(6), + color: AppColors.badgePink, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), ), child: Text( - l10n.free, + free ? l10n.free : l10n.originalTag, style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 8, + color: AppColors.badgePinkText, + fontSize: AppDimens.cardMeta, fontWeight: FontWeight.w800, ), ), ); } + Widget _lockedBadge() { + return Container( + key: Key('drama-locked-${drama.id}'), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: AppColors.overlayDark, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.lock_rounded, color: AppColors.onPrimary, size: 11), + const SizedBox(width: 2), + Text( + l10n.lockedEpisodeCount(_lockedCount), + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 9, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + Widget _episodeBadge() { + return Container( + key: Key('drama-episodes-badge-${drama.id}'), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.badgeBeige, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + l10n.allEpisodesBadge(drama.episodeCount), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 9, + fontWeight: FontWeight.w700, + ), + ), + ); + } + Widget _title() { return Text( drama.title, @@ -130,7 +204,7 @@ class DramaGridCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: 11, + fontSize: AppDimens.cardTitle, fontWeight: FontWeight.w800, ), ); @@ -143,7 +217,32 @@ class DramaGridCard extends StatelessWidget { key: Key('home-drama-genre-${drama.id}'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.cardMeta, + ), + ); + } + + Widget _footer() { + return Row( + children: [ + Expanded(child: _genre()), + Container( + key: Key('drama-add-${drama.id}'), + width: _addSize, + height: _addSize, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.add_rounded, + color: AppColors.onPrimary, + size: _addIconSize, + ), + ), + ], ); } } diff --git a/lib/pages/discover/widgets/ranking_section.dart b/lib/pages/discover/widgets/ranking_section.dart index 9dbe2ef..e3ade9d 100644 --- a/lib/pages/discover/widgets/ranking_section.dart +++ b/lib/pages/discover/widgets/ranking_section.dart @@ -135,7 +135,7 @@ class _RankingSectionState extends State { final selected = logic.board == board; return Material( key: key, - color: selected ? AppColors.blue : AppColors.raised, + color: selected ? AppColors.blue : AppColors.card, borderRadius: BorderRadius.circular(999), clipBehavior: Clip.antiAlias, child: InkWell( @@ -145,7 +145,7 @@ class _RankingSectionState extends State { child: Text( label, style: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.muted, + color: selected ? AppColors.onPrimary : AppColors.onSurface, fontSize: 9, fontWeight: FontWeight.w700, ), diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index ea53383..46e87f4 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -54,7 +54,7 @@ class _DownloadsPageState extends State { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( key: const Key('downloads-page'), backgroundColor: AppColors.ink, @@ -168,11 +168,8 @@ class _DownloadsBody extends StatelessWidget { ); Navigator.of(context).push( MaterialPageRoute( - builder: (_) => PlayerPage( - drama: drama, - initialIndex: 0, - downloads: downloads, - ), + builder: (_) => + PlayerPage(drama: drama, initialIndex: 0, downloads: downloads), ), ); } diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 952dda7..0f9ef45 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -73,7 +73,7 @@ class _DramaDetailPageState extends State { @override Widget build(BuildContext context) { return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: ListenableBuilder( listenable: logic, builder: (context, _) => _page(context), @@ -171,7 +171,7 @@ class _DramaDetailPageState extends State { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x33000000), Color(0xCC0B0C10)], + colors: [Color(0x33000000), AppColors.onSurface], ), ), ), @@ -363,7 +363,7 @@ class _DramaDetailPageState extends State { ? AppColors.blue : locked ? AppColors.secondary.withValues(alpha: 0.16) - : const Color(0xFF29272B), + : AppColors.raised, borderRadius: BorderRadius.circular(9), ), child: Text( @@ -461,8 +461,8 @@ class _DramaDetailPageState extends State { child: Container( width: 36, height: 36, - decoration: const BoxDecoration( - color: Color(0x80000000), + decoration: BoxDecoration( + color: AppColors.card.withValues(alpha: 0.92), shape: BoxShape.circle, ), child: const Icon( @@ -562,8 +562,8 @@ class _RoundAction extends StatelessWidget { return Container( width: 36, height: 36, - decoration: const BoxDecoration( - color: Color(0x80000000), + decoration: BoxDecoration( + color: AppColors.card.withValues(alpha: 0.92), shape: BoxShape.circle, ), child: IconButton( @@ -592,7 +592,7 @@ class _ResumeBar extends StatelessWidget { child: LinearProgressIndicator( value: fraction, minHeight: 3, - backgroundColor: const Color(0xFF474248), + backgroundColor: AppColors.border, valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index d1626fb..2e5fa8a 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -51,7 +51,7 @@ class _FavoritesPageState extends State { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( backgroundColor: AppColors.ink, appBar: AppBar( diff --git a/lib/pages/home/home_browse_logic.dart b/lib/pages/home/home_browse_logic.dart index 5e16f7f..907c614 100644 --- a/lib/pages/home/home_browse_logic.dart +++ b/lib/pages/home/home_browse_logic.dart @@ -7,10 +7,10 @@ import 'package:flutter/foundation.dart'; import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; -/// Home browse channel, backed only by data already present in the catalogue. +/// 首页频道,只依赖目录中已有数据排序或过滤。 enum HomeChannel { hot, free, newReleases, ranking } -/// Local UI state for the browse-first home page. +/// 浏览型首页的本地 UI 状态。 class HomeBrowseLogic extends ChangeNotifier { HomeBrowseLogic({required this.history}) { unawaited(refreshRecent()); @@ -24,7 +24,7 @@ class HomeBrowseLogic extends ChangeNotifier { bool loadingRecent = false; bool _disposed = false; - /// First unfinished record with a real position. + /// 第一条未完成且存在真实播放位置的记录。 WatchProgress? get primaryRecord { for (final record in recent) { if (!record.finished && record.hasPosition) return record; @@ -32,14 +32,14 @@ class HomeBrowseLogic extends ChangeNotifier { return null; } - /// Changes the selected browse channel without triggering network work. + /// 切换频道,不触发额外网络请求。 void selectChannel(HomeChannel value) { if (channel == value) return; channel = value; _notify(); } - /// Reloads continuation data; signed-out reads simply return an empty list. + /// 重新加载续看数据;未登录时返回空列表。 Future refreshRecent() async { loadingRecent = true; _notify(); @@ -53,7 +53,7 @@ class HomeBrowseLogic extends ChangeNotifier { } } - /// Refreshes continuation data when the home tab becomes visible again. + /// 首页重新可见时刷新续看数据。 void syncPage({required bool active, required bool wasActive}) { if (active && !wasActive) unawaited(refreshRecent()); } diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index 724e43c..6782f33 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -20,7 +20,7 @@ import 'home_browse_logic.dart'; import 'widgets/home_browse_header.dart'; import 'widgets/home_drama_grid.dart'; -/// Browse-first home page with channels, hero, continuation, and catalogue. +/// 浏览型首页:频道、Hero、继续观看与剧目目录。 class HomeBrowseView extends StatefulWidget { const HomeBrowseView({ super.key, @@ -214,7 +214,7 @@ class _HomeBrowseViewState extends State { color: selected ? AppColors.blue : AppColors.border, ), labelStyle: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.muted, + color: selected ? AppColors.onPrimary : AppColors.onSurface, fontSize: AppDimens.chipFont, fontWeight: FontWeight.w700, ), diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index c4e4e37..b5901e1 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -8,7 +8,6 @@ import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/platform/haptics.dart'; -import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; @@ -32,8 +31,11 @@ class HomePage extends StatelessWidget { if (!catalog.settled) return const SplashPage(); final l10n = AppLocalizations.of(context)!; return Theme( - data: AppTheme.dark(), - child: _buildHome(context, catalog, l10n), + data: AppTheme.light(), + child: DecoratedBox( + decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + child: _buildHome(context, catalog, l10n), + ), ); } @@ -44,19 +46,19 @@ class HomePage extends StatelessWidget { ) { if (catalog.loading) { return const Scaffold( - backgroundColor: AppColors.ink, + backgroundColor: Colors.transparent, body: Center(child: CircularProgressIndicator()), ); } if (catalog.error != null) { return Scaffold( - backgroundColor: AppColors.ink, + backgroundColor: Colors.transparent, body: HomeFailureView(onRetry: catalog.load), ); } if (catalog.dramas.isEmpty) { return Scaffold( - backgroundColor: AppColors.ink, + backgroundColor: Colors.transparent, body: EmptyState( key: const Key('home-empty'), icon: Icons.movie_outlined, @@ -75,7 +77,7 @@ class HomePage extends StatelessWidget { } } -/// Home catalog failure with a single recovery action. +/// 首页目录加载失败与重试入口。 class HomeFailureView extends StatelessWidget { const HomeFailureView({super.key, required this.onRetry}); @@ -93,7 +95,7 @@ class HomeFailureView extends StatelessWidget { } } -/// The four primary destinations share a single route and keep their state. +/// 四个根页面共享同一路由并保持各自状态。 class _HomeTabs extends StatefulWidget { const _HomeTabs({super.key, required this.dramas}); @@ -136,7 +138,7 @@ class _HomeTabsState extends State<_HomeTabs> { }, child: Scaffold( key: logic.scaffoldKey, - backgroundColor: AppColors.ink, + backgroundColor: Colors.transparent, body: _body(context), bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n), ), @@ -193,6 +195,10 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _nav(BuildContext context, AppLocalizations l10n) { return AppBottomNav( barKey: const Key('home-tabs-bar'), + centerAction: AppBottomNavCenterAction( + label: l10n.createTab, + onTap: () => _selectTab(context, 2), + ), items: [ _navItem(context, l10n, 0), _navItem(context, l10n, 1), diff --git a/lib/pages/home/widgets/home_browse_header.dart b/lib/pages/home/widgets/home_browse_header.dart index 91406db..71f6208 100644 --- a/lib/pages/home/widgets/home_browse_header.dart +++ b/lib/pages/home/widgets/home_browse_header.dart @@ -8,7 +8,7 @@ import '../../../l10n/app_localizations.dart'; const double _searchHeight = 36; -/// Fixed home header with catalogue search, weekday, and profile entry. +/// 首页固定顶栏:搜索、星期与头像入口。 class HomeBrowseHeader extends StatelessWidget { const HomeBrowseHeader({ super.key, diff --git a/lib/pages/home/widgets/home_drama_grid.dart b/lib/pages/home/widgets/home_drama_grid.dart index 3a884a8..ce650a8 100644 --- a/lib/pages/home/widgets/home_drama_grid.dart +++ b/lib/pages/home/widgets/home_drama_grid.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; @@ -12,8 +13,10 @@ const int _columns = 2; const double _columnGap = 10; const double _cardGap = 10; const double _coverAspectRatio = 3 / 4; +const double _addSize = 28; +const double _addIconSize = 18; -/// Two-column drama grid used by the browse-first home page. +/// 首页浏览态使用的双列剧目网格。 class HomeDramaGrid extends StatelessWidget { const HomeDramaGrid({ super.key, @@ -57,7 +60,7 @@ class HomeDramaGrid extends StatelessWidget { } } -/// Home catalogue card with title, genre, free badge, episode count, and heat. +/// 首页剧卡:封面、角标、标题、题材、集数与热度。 class HomeDramaCard extends StatelessWidget { const HomeDramaCard({ super.key, @@ -72,21 +75,35 @@ class HomeDramaCard extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - key: Key('home-drama-card-${drama.id}'), - onTap: onTap, - borderRadius: BorderRadius.circular(AppDimens.coverRadius), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _cover(), - const SizedBox(height: AppDimens.spaceXs), - _title(), - const SizedBox(height: 2), - _genre(), - const SizedBox(height: AppDimens.spaceXs), - _metadata(), - ], + return Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: Key('home-drama-card-${drama.id}'), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppDimens.spaceSm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _cover(), + const SizedBox(height: AppDimens.spaceSm), + _title(), + const SizedBox(height: AppDimens.spaceXs), + _genre(), + const SizedBox(height: AppDimens.spaceXs), + _metadata(), + ], + ), + ), + ), ), ); } @@ -104,7 +121,10 @@ class HomeDramaCard extends StatelessWidget { showFallbackIcon: true, fallbackAspectRatio: _coverAspectRatio, ), - if (_isFree) Positioned(top: 6, left: 6, child: _freeBadge()), + Positioned(top: 8, left: 8, child: _tagBadge()), + if (_lockedCount > 0) + Positioned(top: 8, right: 8, child: _lockedBadge()), + Positioned(right: 8, bottom: 8, child: _episodeBadge()), ], ), ); @@ -115,17 +135,29 @@ class HomeDramaCard extends StatelessWidget { drama.episodes.any((episode) => episode.isFree); } - Widget _freeBadge() { + int get _lockedCount { + final explicit = drama.episodes.where((episode) => episode.locked).length; + if (explicit > 0) return explicit; + final inferred = drama.episodeCount - drama.freeEpisodes; + return inferred > 0 ? inferred : 0; + } + + Widget _tagBadge() { + final label = _isFree ? l10n.free : l10n.originalTag; return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + key: Key('home-drama-tag-${drama.id}'), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.spaceSm, + vertical: 3, + ), decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(6), + color: AppColors.badgePink, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), ), child: Text( - l10n.free, + label, style: const TextStyle( - color: AppColors.onPrimary, + color: AppColors.badgePinkText, fontSize: AppDimens.cardMeta, fontWeight: FontWeight.w800, ), @@ -133,6 +165,51 @@ class HomeDramaCard extends StatelessWidget { ); } + Widget _lockedBadge() { + return Container( + key: Key('home-drama-locked-${drama.id}'), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: AppColors.overlayDark, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.lock_rounded, color: AppColors.onPrimary, size: 11), + const SizedBox(width: 2), + Text( + l10n.lockedEpisodeCount(_lockedCount), + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 9, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + Widget _episodeBadge() { + return Container( + key: Key('home-drama-episodes-badge-${drama.id}'), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.badgeBeige, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + l10n.allEpisodesBadge(drama.episodeCount), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 9, + fontWeight: FontWeight.w700, + ), + ), + ); + } + Widget _title() { return Text( drama.title, @@ -141,7 +218,7 @@ class HomeDramaCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: AppDimens.previewSectionTitle, + fontSize: AppDimens.cardTitle, fontWeight: FontWeight.w800, ), ); @@ -162,24 +239,44 @@ class HomeDramaCard extends StatelessWidget { } Widget _metadata() { - return Wrap( - spacing: AppDimens.spaceSm, - runSpacing: AppDimens.spaceXs, + return Row( children: [ - Text( - l10n.episodeCount(drama.episodeCount), - key: Key('home-drama-episode-count-${drama.id}'), - style: const TextStyle( - color: AppColors.subtle, - fontSize: AppDimens.cardViews, + Expanded( + child: Wrap( + spacing: AppDimens.spaceSm, + runSpacing: AppDimens.spaceXs, + children: [ + Text( + l10n.episodeCount(drama.episodeCount), + key: Key('home-drama-episode-count-${drama.id}'), + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.cardViews, + ), + ), + Text( + l10n.viewCountLabel(drama.viewCount), + key: Key('home-drama-views-${drama.id}'), + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.cardViews, + ), + ), + ], ), ), - Text( - l10n.viewCountLabel(drama.viewCount), - key: Key('home-drama-views-${drama.id}'), - style: const TextStyle( - color: AppColors.subtle, - fontSize: AppDimens.cardViews, + Container( + key: Key('home-drama-add-${drama.id}'), + width: _addSize, + height: _addSize, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.add_rounded, + color: AppColors.onPrimary, + size: _addIconSize, ), ), ], diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 0beee47..b52bf77 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -92,7 +92,7 @@ class _MyPageState extends State { ), ); return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), @@ -242,6 +242,10 @@ class _MyPageState extends State { Widget _bottomNav(AppLocalizations l10n) { return AppBottomNav( + centerAction: AppBottomNavCenterAction( + label: l10n.createTab, + onTap: () => _act(MyPageAction.create), + ), items: [ AppBottomNavItem( tabKey: const Key('mine-nav-home'), diff --git a/lib/pages/mine/widgets/continue_watching_card.dart b/lib/pages/mine/widgets/continue_watching_card.dart index e849a8d..6e17981 100644 --- a/lib/pages/mine/widgets/continue_watching_card.dart +++ b/lib/pages/mine/widgets/continue_watching_card.dart @@ -101,7 +101,7 @@ class ContinueWatchingCard extends StatelessWidget { key: progressKey, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + style: const TextStyle(color: AppColors.muted, fontSize: 9), ), ], ); diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index f2559c2..a51cc36 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -46,12 +46,12 @@ class MineHeader extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF6A5CFF), AppColors.blue], + colors: [AppColors.secondary, AppColors.blue], ), ), child: const Icon( Icons.person_rounded, - color: AppColors.onSurface, + color: AppColors.onPrimary, size: 22, ), ); @@ -76,7 +76,7 @@ class MineHeader extends StatelessWidget { subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10), + style: const TextStyle(color: AppColors.muted, fontSize: 10), ), ], ); diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index 0b16bac..a8e2f27 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -44,7 +44,7 @@ class _NoticesPageState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( key: const Key('notices-page'), backgroundColor: AppColors.ink, diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 5d47ab1..14f3bc0 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -669,7 +669,7 @@ class PlayerLogic extends ChangeNotifier { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Color(0xFFB7B3B8), + color: AppColors.muted, fontSize: 14, fontWeight: FontWeight.w400, ), diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 2f462d3..95935ea 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -146,9 +146,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ); return AnnotatedRegion( - value: SystemUiOverlayStyle.light, + value: SystemUiOverlayStyle.dark, child: Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: widget.embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), @@ -1155,7 +1155,7 @@ class _TopAction extends StatelessWidget { onPressed: onPressed, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: const Color(0xFF2C2D33), + backgroundColor: AppColors.card.withValues(alpha: 0.94), foregroundColor: AppColors.onSurface, ), icon: Icon(icon, size: 18), @@ -1179,7 +1179,7 @@ class _PlayerAction extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - color: const Color(0xFF2A2A30), + color: AppColors.card.withValues(alpha: 0.94), borderRadius: BorderRadius.circular(9), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 81dc343..220d1ab 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -160,12 +160,7 @@ class HomeFeedOverlay extends StatelessWidget { radius: 14, blur: 6, padding: const EdgeInsets.all(12), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xBF0F0F14), Color(0xA60F0F14)], - ), - borderColor: Colors.white.withValues(alpha: 0.06), + borderColor: AppColors.border, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -184,13 +179,13 @@ class HomeFeedOverlay extends StatelessWidget { if (drama.category.isNotEmpty && drama.category != genre) _tag( drama.category, - background: const Color(0xFF333333), - foreground: const Color(0xFFDDDDDD), + background: AppColors.raised, + foreground: AppColors.onSurface, ), Text( l10n.episodeOfTotal(episode.number, drama.episodeCount), key: const Key('home-feed-episode'), - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + style: const TextStyle(color: AppColors.muted, fontSize: 9), ), ], ), @@ -248,7 +243,7 @@ class HomeFeedOverlay extends StatelessWidget { key: const Key('home-feed-episodes'), onPressed: onEpisodes, style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF2A2A30), + backgroundColor: AppColors.raised, foregroundColor: AppColors.onSurface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( @@ -362,7 +357,7 @@ class _RoundIcon extends StatelessWidget { onPressed: onPressed, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: const Color(0xCC2C2D33), + backgroundColor: AppColors.card.withValues(alpha: 0.94), foregroundColor: AppColors.onSurface, ), icon: Icon(icon, size: 18), diff --git a/lib/pages/player/widgets/player_center_toggle.dart b/lib/pages/player/widgets/player_center_toggle.dart index b15e355..c939301 100644 --- a/lib/pages/player/widgets/player_center_toggle.dart +++ b/lib/pages/player/widgets/player_center_toggle.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -/// Shared play/pause control used by the home feed and full-screen player. +/// 首页沉浸流与全屏播放器共用的播放/暂停按钮。 class PlayerCenterToggle extends StatelessWidget { const PlayerCenterToggle({ super.key, @@ -26,7 +26,7 @@ class PlayerCenterToggle extends StatelessWidget { width: 52, height: 52, decoration: const BoxDecoration( - color: Color(0x80000000), + color: AppColors.blue, shape: BoxShape.circle, ), child: Icon( diff --git a/lib/pages/player/widgets/player_featured_comment.dart b/lib/pages/player/widgets/player_featured_comment.dart index e888ba8..1f1381f 100644 --- a/lib/pages/player/widgets/player_featured_comment.dart +++ b/lib/pages/player/widgets/player_featured_comment.dart @@ -117,7 +117,7 @@ class _PlayerFeaturedCommentState extends State { Text( '· $time', style: const TextStyle( - color: Color(0xFF777777), + color: AppColors.muted, fontSize: 9, ), ), @@ -133,7 +133,7 @@ class _PlayerFeaturedCommentState extends State { maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Color(0xFFDDDDDD), + color: AppColors.onSurface, fontSize: 10, height: 1.5, ), diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 6680fa1..57f7503 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -127,10 +127,8 @@ class _SeekProgressBarState extends State { ? (scrubbing ? 3 : 1.5) : (scrubbing ? 4 : 3), activeTrackColor: AppColors.blue, - inactiveTrackColor: widget.compact - ? Colors.white.withValues(alpha: 0.18) - : const Color(0xFF474248), - thumbColor: widget.compact ? AppColors.blue : Colors.white, + inactiveTrackColor: AppColors.border, + thumbColor: AppColors.onPrimary, thumbShape: RoundSliderThumbShape( enabledThumbRadius: widget.compact ? (scrubbing ? 6 : 4) diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index b1f1ec8..8d06c0a 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -22,7 +22,7 @@ class SettingsPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final auth = context.watch(); return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( key: const Key('settings-page'), backgroundColor: AppColors.ink, diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart index e5501bd..9fe52d6 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -56,7 +56,7 @@ class _StorePageState extends State { final l10n = AppLocalizations.of(context)!; final isVip = widget.type == StoreController.vipType; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( key: const Key('store-page'), backgroundColor: AppColors.ink, diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart index b9361de..7748314 100644 --- a/lib/pages/wallet/wallet_page.dart +++ b/lib/pages/wallet/wallet_page.dart @@ -44,7 +44,7 @@ class _WalletPageState extends State { final l10n = AppLocalizations.of(context)!; final wallet = context.watch(); return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( key: const Key('wallet-page'), backgroundColor: AppColors.ink, @@ -188,7 +188,7 @@ class _BalanceHero extends StatelessWidget { gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF2B2116), Color(0xFF17171D)], + colors: [AppColors.goldCard, AppColors.card], ), border: Border.all(color: AppColors.gold.withValues(alpha: 0.35)), boxShadow: [ diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index d5250c5..48b46b5 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -33,7 +33,7 @@ class WatchRecordsPage extends StatelessWidget { final signedIn = context.watch().isSignedIn; final l10n = AppLocalizations.of(context)!; return Theme( - data: AppTheme.dark(), + data: AppTheme.light(), child: Scaffold( key: const Key('watch-records-page'), backgroundColor: AppColors.ink, diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index a57e9e5..ac4bd22 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -207,7 +207,7 @@ Widget _host(Drama drama) { ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()), ], child: MaterialApp( - theme: AppTheme.dark(), + theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, diff --git a/test/empty_state/empty_state_test.dart b/test/empty_state/empty_state_test.dart index 8f1588e..cfac0bd 100644 --- a/test/empty_state/empty_state_test.dart +++ b/test/empty_state/empty_state_test.dart @@ -12,7 +12,7 @@ void main() { var taps = 0; await _pump( tester, - theme: AppTheme.dark(), + theme: AppTheme.light(), child: EmptyState( icon: Icons.movie_outlined, title: 'No stories', @@ -33,7 +33,7 @@ void main() { ); expect( tester.widget(find.byKey(const Key('empty-state-icon'))).color, - AppColors.gold, + AppColors.blue, ); expect( tester @@ -103,15 +103,15 @@ void main() { expect(find.byKey(const Key('empty-state-action')), findsNothing); }); - testWidgets('should_tint_the_bare_icon_for_light_and_dark', (tester) async { + testWidgets('should_tint_the_bare_icon_with_the_light_theme', (tester) async { await _pump( tester, - theme: AppTheme.dark(), + theme: AppTheme.light(), child: const EmptyState(icon: Icons.history_rounded, title: 'No history'), ); expect( tester.widget(find.byKey(const Key('empty-state-icon'))).color, - AppColors.gold, + AppColors.blue, ); await _pump( @@ -156,7 +156,7 @@ Future _pump( }) async { await tester.pumpWidget( MaterialApp( - theme: theme ?? AppTheme.dark(), + theme: theme ?? AppTheme.light(), home: Scaffold(body: child), ), ); diff --git a/test/home/app_bottom_nav_test.dart b/test/home/app_bottom_nav_test.dart index 6e0d181..3b8ae91 100644 --- a/test/home/app_bottom_nav_test.dart +++ b/test/home/app_bottom_nav_test.dart @@ -2,13 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; void main() { - testWidgets('should_render_64_high_navigation_and_keep_item_keys', ( - tester, - ) async { + testWidgets('should_render_five_slots_with_center_action', (tester) async { + var centerTaps = 0; await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -23,24 +23,74 @@ void main() { onTap: () {}, ), AppBottomNavItem( - tabKey: const Key('tab-my'), + tabKey: const Key('tab-theater'), + icon: Icons.explore_outlined, + selectedIcon: Icons.explore_rounded, + label: 'Theater', + onTap: () {}, + ), + AppBottomNavItem( + tabKey: const Key('tab-create'), + icon: Icons.add_box_outlined, + selectedIcon: Icons.add_box_rounded, + label: 'Create', + onTap: () {}, + ), + AppBottomNavItem( + tabKey: const Key('tab-mine'), icon: Icons.person_outline_rounded, selectedIcon: Icons.person_rounded, label: 'Me', onTap: () {}, ), ], + centerAction: AppBottomNavCenterAction( + label: 'Create', + onTap: () => centerTaps++, + ), ), ), ), ); expect(find.byKey(const Key('tab-home')), findsOneWidget); - expect(find.byKey(const Key('tab-my')), findsOneWidget); + expect(find.byKey(const Key('tab-theater')), findsOneWidget); + expect(find.byKey(const Key('nav-center-action')), findsOneWidget); + expect(find.byKey(const Key('tab-create')), findsOneWidget); + expect(find.byKey(const Key('tab-mine')), findsOneWidget); expect(find.text('Home'), findsOneWidget); + final centers = [ + Key('tab-home'), + Key('tab-theater'), + Key('nav-center-action'), + Key('tab-create'), + Key('tab-mine'), + ].map((key) => tester.getCenter(find.byKey(key)).dx).toList(); + expect(centers, orderedEquals(centers.toList()..sort())); expect( tester.getSize(find.byType(AppBottomNav)).height, AppDimens.navHeight, ); + expect( + tester.getSize(find.byKey(const Key('nav-center-action'))), + const Size.square(AppDimens.navCenter), + ); + final ink = tester.widget( + find + .ancestor( + of: find.byKey(const Key('nav-center-action')), + matching: find.byType(Ink), + ) + .first, + ); + final decoration = ink.decoration! as BoxDecoration; + expect(decoration.shape, BoxShape.circle); + expect((decoration.gradient! as LinearGradient).colors, const [ + AppColors.navCenterStart, + AppColors.navCenterEnd, + ]); + + await tester.tap(find.byKey(const Key('nav-center-action'))); + expect(centerTaps, 1); }); } diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart new file mode 100644 index 0000000..1859799 --- /dev/null +++ b/test/home/home_card_style_test.dart @@ -0,0 +1,69 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/home/widgets/home_drama_grid.dart'; + +void main() { + testWidgets('should_render_warm_orange_cover_badges_and_plus_action', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Builder( + builder: (context) => SizedBox( + width: 180, + child: HomeDramaCard( + l10n: AppLocalizations.of(context)!, + drama: _drama, + onTap: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final card = find.byKey(const Key('home-drama-card-style-drama')); + final cardContainer = tester.widget( + find.ancestor(of: card, matching: find.byType(Container)).last, + ); + final cardDecoration = cardContainer.decoration! as BoxDecoration; + expect(cardDecoration.color, AppColors.card); + expect( + cardDecoration.borderRadius, + BorderRadius.circular(AppDimens.cardRadius), + ); + expect(cardDecoration.boxShadow, const [AppTheme.cardShadow]); + + final tag = tester.widget( + find.byKey(const Key('home-drama-tag-style-drama')), + ); + expect((tag.decoration! as BoxDecoration).color, AppColors.badgePink); + expect(find.text('Free'), findsOneWidget); + expect(find.text('18 locked'), findsOneWidget); + expect(find.text('All 24'), findsOneWidget); + expect(find.byKey(const Key('home-drama-add-style-drama')), findsOneWidget); + }); +} + +const _drama = Drama( + id: 'style-drama', + title: 'Warm Orange Story', + synopsis: '暖橙短剧', + posterUrl: '/files/style.jpg', + genre: 'Romance', + totalEpisodes: 24, + freeEpisodes: 6, +); diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index d5f2563..7da4606 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-theater'), + Key('nav-center-action'), Key('tab-create'), Key('tab-mine'), ]; @@ -53,7 +54,7 @@ void main() { final decoration = bar.decoration as BoxDecoration; final border = decoration.border as Border; expect(decoration.color, AppColors.bar.withValues(alpha: 0.95)); - expect(border.top.color, AppColors.outlineVariantDark); + expect(border.top.color, AppColors.border); expect(border.top.color, isNot(Colors.white)); expect( tester @@ -73,6 +74,17 @@ void main() { ), findsOneWidget, ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byIcon(Icons.home_rounded), + ), + ) + .color, + AppColors.blue, + ); await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); @@ -87,6 +99,30 @@ void main() { ), findsOneWidget, ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('tab-theater')), + matching: find.text('Theater'), + ), + ) + .style + ?.color, + AppColors.blue, + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.text('Home'), + ), + ) + .style + ?.color, + AppColors.navUnselected, + ); expect( find.descendant( of: find.byKey(const Key('tab-home')), @@ -103,6 +139,7 @@ void main() { expect(find.byKey(const Key('tab-home')), findsOneWidget); expect(find.byKey(const Key('tab-theater')), findsOneWidget); + expect(find.byKey(const Key('nav-center-action')), findsOneWidget); expect(find.byKey(const Key('tab-create')), findsOneWidget); expect(find.byKey(const Key('tab-mine')), findsOneWidget); expect(find.byKey(const Key('home-browse-page')), findsOneWidget); @@ -111,6 +148,12 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + await tester.tap(find.byKey(const Key('nav-center-action'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('create-page')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-create'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('create-page')), findsOneWidget); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index dccae62..279abd3 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -82,7 +82,7 @@ void main() { Future pumpEpisode(Episode episode) async { await tester.pumpWidget( MaterialApp( - theme: AppTheme.dark(), + theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -141,7 +141,7 @@ void main() { ) async { await tester.pumpWidget( MaterialApp( - theme: AppTheme.dark(), + theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index fd6b428..41002e4 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -8,6 +8,7 @@ import 'package:fruitshorts/core/data/models/watch_progress.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/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/player_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'; @@ -32,8 +33,9 @@ void main() { for (final key in _tabKeys) { expect(find.byKey(key), findsOneWidget); } + expect(find.byKey(const Key('nav-center-action')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-create'))); + await tester.tap(find.byKey(const Key('nav-center-action'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('create-page')), findsOneWidget); @@ -46,12 +48,22 @@ void main() { expect(_chip(tester, 'home-channel-hot').selected, isTrue); expect(_chip(tester, 'home-channel-free').selected, isFalse); + expect(_chip(tester, 'home-channel-hot').selectedColor, AppColors.blue); + expect(_chip(tester, 'home-channel-free').backgroundColor, AppColors.card); + expect( + _chip(tester, 'home-channel-free').labelStyle?.color, + AppColors.onSurface, + ); await tester.tap(find.byKey(const Key('home-channel-free'))); await tester.pumpAndSettle(); expect(_chip(tester, 'home-channel-hot').selected, isFalse); expect(_chip(tester, 'home-channel-free').selected, isTrue); + expect( + _chip(tester, 'home-channel-free').labelStyle?.color, + AppColors.onPrimary, + ); }); testWidgets('should_open_theater_search_when_home_search_is_tapped', ( diff --git a/test/player/player_center_toggle_test.dart b/test/player/player_center_toggle_test.dart index e6912cd..dec5deb 100644 --- a/test/player/player_center_toggle_test.dart +++ b/test/player/player_center_toggle_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; void main() { @@ -36,7 +37,7 @@ void main() { find.descendant(of: find.byKey(key), matching: find.byType(Container)), ); final decoration = container.decoration! as BoxDecoration; - expect(decoration.color, const Color(0x80000000)); + expect(decoration.color, AppColors.blue); expect(decoration.shape, BoxShape.circle); final icon = tester.widget( find.descendant(of: find.byKey(key), matching: find.byType(Icon)), diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart index f4402d4..485442d 100644 --- a/test/theme/app_dimens_test.dart +++ b/test/theme/app_dimens_test.dart @@ -6,18 +6,22 @@ import 'package:fruitshorts/core/theme/app_dimens.dart'; void main() { test('should_expose_the_k20_spacing_and_type_scale', () { expect(AppDimens.pageHorizontal, 16); - expect(AppDimens.cardRadius, 12); - expect(AppDimens.innerRadius, 10); + expect(AppDimens.cardRadius, 16); + expect(AppDimens.coverRadius, 16); + expect(AppDimens.authCardRadius, 20); + expect(AppDimens.pillRadius, 999); + expect(AppDimens.buttonRadius, 999); expect(AppDimens.caption, 12); expect(AppDimens.body, 14); expect(AppDimens.listTitle, 15); expect(AppDimens.sectionTitle, 16); expect(AppDimens.pageTitle, 20); - expect(AppDimens.navLabel, 9); + expect(AppDimens.navLabel, 10); expect(AppDimens.buttonHeight, 48); expect(AppDimens.primaryButtonHeight, 52); expect(AppDimens.tapTarget, 44); - expect(AppDimens.navHeight, 54); + expect(AppDimens.navHeight, 64); + expect(AppDimens.navCenter, 56); expect(AppDimens.progressHeight, 3); }); } diff --git a/test/theme/app_theme_test.dart b/test/theme/app_theme_test.dart index 4a7eb85..62ad6ef 100644 --- a/test/theme/app_theme_test.dart +++ b/test/theme/app_theme_test.dart @@ -2,7 +2,7 @@ import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; @@ -14,71 +14,46 @@ double _contrast(Color foreground, Color background) { } void main() { - test('should_expose_obsidian_flare_brand_tokens', () { - expect(AppColors.blue, const Color(0xFFFF5B3D)); - expect(AppColors.bluePressed, const Color(0xFFFF4321)); - expect(AppColors.blueSoft, const Color(0xFF9278EA)); - expect(AppColors.secondary, const Color(0xFF9278EA)); - expect(AppColors.gold, const Color(0xFFFBBF24)); - expect(AppColors.ink, const Color(0xFF0B0C10)); - expect(AppColors.card, const Color(0xFF1B1C21)); - expect(AppColors.cardAlt, const Color(0xFF1C1D21)); - expect(AppColors.raised, const Color(0xFF1C1D21)); - expect(AppColors.bar, const Color(0xFF0D0D11)); - expect(AppColors.success, const Color(0xFF34D399)); - expect(AppColors.onPrimary, const Color(0xFFFFFFFF)); + test('should_expose_light_warm_orange_tokens', () { + expect(AppColors.blue, const Color(0xFFFF7A1A)); + expect(AppColors.bluePressed, const Color(0xFFFF6A00)); + expect(AppColors.secondary, const Color(0xFFFF6A8A)); + expect(AppColors.ink, const Color(0xFFFFF8F0)); + expect(AppColors.surfaceTop, const Color(0xFFFFE9D6)); + expect(AppColors.card, const Color(0xFFFFFFFF)); + expect(AppColors.onSurface, const Color(0xFF1F1B16)); + expect(AppColors.muted, const Color(0xFF8C837B)); + expect(AppColors.subtle, const Color(0xFFB3AAA1)); + expect(AppColors.border, const Color(0xFFF0E4D6)); + expect(AppColors.badgePink, const Color(0xFFFFE3EC)); + expect(AppColors.badgePinkText, const Color(0xFFFF6A8A)); + }); + + test('should_keep_primary_text_readable_on_warm_background', () { expect( - _contrast(AppColors.onGold, AppColors.gold), + _contrast(AppColors.onSurface, AppColors.ink), greaterThanOrEqualTo(4.5), ); + expect(_contrast(AppColors.muted, AppColors.card), greaterThanOrEqualTo(3)); }); - test('should_meet_text_and_icon_contrast_when_using_theme_tokens', () { - expect( - _contrast(AppColors.onPrimary, AppColors.blue), - greaterThanOrEqualTo(3), - ); - expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(3)); - expect( - _contrast(AppColors.blueSoft, AppColors.ink), - greaterThanOrEqualTo(3), - ); + test('should_use_light_color_scheme_and_dark_system_icons', () { + final theme = AppTheme.light(); + + expect(theme.brightness, Brightness.light); + expect(theme.colorScheme.primary, AppColors.blue); + expect(theme.colorScheme.onPrimary, AppColors.onPrimary); + expect(theme.colorScheme.surface, AppColors.ink); + expect(theme.colorScheme.onSurface, AppColors.onSurface); + expect(theme.colorScheme.outline, AppColors.border); + expect(theme.scaffoldBackgroundColor, AppColors.ink); + expect(theme.appBarTheme.systemOverlayStyle, SystemUiOverlayStyle.dark); }); - test('should_use_flare_brand_in_light_and_dark_color_schemes', () { - for (final theme in [AppTheme.light(), AppTheme.dark()]) { - expect(theme.colorScheme.primary, AppColors.blue); - expect(theme.colorScheme.onPrimary, AppColors.onPrimary); - } - }); - - test('should_define_non_white_outline_tokens_in_both_themes', () { - final light = AppTheme.light().colorScheme; - final dark = AppTheme.dark().colorScheme; - - expect(light.outline, AppColors.outlineLight); - expect(light.outlineVariant, AppColors.outlineVariantLight); - expect(dark.outline, AppColors.outlineDark); - expect(dark.outlineVariant, AppColors.outlineVariantDark); - expect(dark.outlineVariant.computeLuminance(), lessThan(0.2)); - expect(light.outlineVariant.computeLuminance(), lessThan(0.92)); - }); - - testWidgets('should_expose_chili_brand_through_widget_theme', (tester) async { - late ThemeData applied; - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.dark(), - home: Builder( - builder: (context) { - applied = Theme.of(context); - return const SizedBox.shrink(); - }, - ), - ), - ); - - expect(applied.colorScheme.primary, AppColors.blue); - expect(applied.colorScheme.onPrimary, AppColors.onPrimary); + test('should_expose_page_gradient_and_soft_card_shadow', () { + expect(AppTheme.pageGradient.colors, [AppColors.surfaceTop, AppColors.ink]); + expect(AppTheme.cardShadow.color, AppColors.cardShadow); + expect(AppTheme.cardShadow.blurRadius, 18); + expect(AppTheme.cardShadow.offset, const Offset(0, 6)); }); } From 2f1a4c5c69385feb4f7bd45e7f51e48bc0e85b11 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 10:18:12 +0800 Subject: [PATCH 021/124] =?UTF-8?q?fix(flutter):=20=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E5=BA=95=E9=83=A8=E5=AF=BC=E8=88=AA=E4=B8=BA=E5=9B=9B=E6=A7=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 5 -- lib/core/theme/app_dimens.dart | 2 - lib/core/widgets/app_bottom_nav.dart | 86 ++------------------------- lib/pages/discover/discover_view.dart | 4 -- lib/pages/home/home_view.dart | 4 -- lib/pages/mine/mine_view.dart | 4 -- test/home/app_bottom_nav_test.dart | 43 +++++--------- test/home/home_nav_ui_test.dart | 8 --- test/home/k28_a_navigation_test.dart | 3 +- test/theme/app_dimens_test.dart | 1 - 10 files changed, 21 insertions(+), 139 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index a71d739..26b8f4e 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -42,11 +42,6 @@ abstract final class AppColors { /// 底部导航底色。 static const bar = Color(0xFFFFFDFB); - /// 中央操作按钮渐变。 - static const navCenterStart = Color(0xFFFF9A3D); - static const navCenterEnd = Color(0xFFFF6A00); - static const navCenterShadow = Color(0x33FF7A1A); - /// 卡片轻阴影。 static const cardShadow = Color(0x14FF8A3D); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 3e5f95c..83e6116 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -47,8 +47,6 @@ abstract final class AppDimens { static const double navHeight = 64; static const double navIcon = 20; - static const double navCenter = 56; - static const double navCenterIcon = 28; static const double progressHeight = 3; static const double progressRadius = 2; static const double progressLineHeight = 3; diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index 0a7a72f..231deec 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -26,37 +26,21 @@ class AppBottomNavItem { final bool selected; } -/// 底部导航中央操作按钮。 -class AppBottomNavCenterAction { - const AppBottomNavCenterAction({ - required this.label, - required this.onTap, - this.key = const Key('nav-center-action'), - }); - - final Key key; - final String label; - final VoidCallback onTap; -} - -/// 全局五槽底部导航。 +/// 四槽底部导航,仅做浅色暖橙视觉换肤。 class AppBottomNav extends StatelessWidget { const AppBottomNav({ super.key, required this.items, - this.centerAction, this.backgroundColor = AppColors.bar, this.barKey, }); final List items; - final AppBottomNavCenterAction? centerAction; final Color backgroundColor; final Key? barKey; @override Widget build(BuildContext context) { - final action = centerAction; return ClipRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), @@ -69,21 +53,9 @@ class AppBottomNav extends StatelessWidget { color: backgroundColor.withValues(alpha: 0.95), border: const Border(top: BorderSide(color: AppColors.border)), ), - child: action == null - ? Row( - children: [ - for (final item in items) _NavDestination(item: item), - ], - ) - : Row( - children: [ - for (final item in items.take(2)) - _NavDestination(item: item), - Expanded(child: _CenterDestination(action: action)), - for (final item in items.skip(2)) - _NavDestination(item: item), - ], - ), + child: Row( + children: [for (final item in items) _NavDestination(item: item)], + ), ), ), ), @@ -143,53 +115,3 @@ class _NavDestination extends StatelessWidget { ), ); } - -class _CenterDestination extends StatelessWidget { - const _CenterDestination({required this.action}); - - final AppBottomNavCenterAction action; - - @override - Widget build(BuildContext context) { - return Center( - child: Semantics( - button: true, - label: action.label, - child: Material( - color: Colors.transparent, - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - child: Ink( - width: AppDimens.navCenter, - height: AppDimens.navCenter, - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.navCenterStart, AppColors.navCenterEnd], - ), - boxShadow: [ - BoxShadow( - color: AppColors.navCenterShadow, - blurRadius: 14, - offset: Offset(0, 5), - ), - ], - ), - child: InkWell( - key: action.key, - customBorder: const CircleBorder(), - onTap: action.onTap, - child: const Icon( - Icons.add_rounded, - color: AppColors.onPrimary, - size: AppDimens.navCenterIcon, - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 71b06dd..1d5c5f1 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -340,10 +340,6 @@ class _DiscoverPageState extends State { } Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( - centerAction: AppBottomNavCenterAction( - label: l10n.createTab, - onTap: () => _act(DiscoverPageAction.create), - ), items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index b5901e1..7ec90e3 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -195,10 +195,6 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _nav(BuildContext context, AppLocalizations l10n) { return AppBottomNav( barKey: const Key('home-tabs-bar'), - centerAction: AppBottomNavCenterAction( - label: l10n.createTab, - onTap: () => _selectTab(context, 2), - ), items: [ _navItem(context, l10n, 0), _navItem(context, l10n, 1), diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index b52bf77..f1203b4 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -242,10 +242,6 @@ class _MyPageState extends State { Widget _bottomNav(AppLocalizations l10n) { return AppBottomNav( - centerAction: AppBottomNavCenterAction( - label: l10n.createTab, - onTap: () => _act(MyPageAction.create), - ), items: [ AppBottomNavItem( tabKey: const Key('mine-nav-home'), diff --git a/test/home/app_bottom_nav_test.dart b/test/home/app_bottom_nav_test.dart index 3b8ae91..df8a59e 100644 --- a/test/home/app_bottom_nav_test.dart +++ b/test/home/app_bottom_nav_test.dart @@ -7,8 +7,10 @@ import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; void main() { - testWidgets('should_render_five_slots_with_center_action', (tester) async { - var centerTaps = 0; + testWidgets('should_render_four_slots_with_orange_selected_state', ( + tester, + ) async { + var theaterTaps = 0; await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -27,7 +29,7 @@ void main() { icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, label: 'Theater', - onTap: () {}, + onTap: () => theaterTaps++, ), AppBottomNavItem( tabKey: const Key('tab-create'), @@ -44,10 +46,6 @@ void main() { onTap: () {}, ), ], - centerAction: AppBottomNavCenterAction( - label: 'Create', - onTap: () => centerTaps++, - ), ), ), ), @@ -55,14 +53,13 @@ void main() { expect(find.byKey(const Key('tab-home')), findsOneWidget); expect(find.byKey(const Key('tab-theater')), findsOneWidget); - expect(find.byKey(const Key('nav-center-action')), findsOneWidget); expect(find.byKey(const Key('tab-create')), findsOneWidget); expect(find.byKey(const Key('tab-mine')), findsOneWidget); + expect(find.byKey(const Key('nav-center-action')), findsNothing); expect(find.text('Home'), findsOneWidget); final centers = [ Key('tab-home'), Key('tab-theater'), - Key('nav-center-action'), Key('tab-create'), Key('tab-mine'), ].map((key) => tester.getCenter(find.byKey(key)).dx).toList(); @@ -72,25 +69,17 @@ void main() { AppDimens.navHeight, ); expect( - tester.getSize(find.byKey(const Key('nav-center-action'))), - const Size.square(AppDimens.navCenter), - ); - final ink = tester.widget( - find - .ancestor( - of: find.byKey(const Key('nav-center-action')), - matching: find.byType(Ink), + tester + .widget( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byIcon(Icons.movie_rounded), + ), ) - .first, + .color, + AppColors.blue, ); - final decoration = ink.decoration! as BoxDecoration; - expect(decoration.shape, BoxShape.circle); - expect((decoration.gradient! as LinearGradient).colors, const [ - AppColors.navCenterStart, - AppColors.navCenterEnd, - ]); - - await tester.tap(find.byKey(const Key('nav-center-action'))); - expect(centerTaps, 1); + await tester.tap(find.byKey(const Key('tab-theater'))); + expect(theaterTaps, 1); }); } diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 7da4606..b6ed587 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -20,7 +20,6 @@ const _keys = [ Key('home-tabs-bar'), Key('tab-home'), Key('tab-theater'), - Key('nav-center-action'), Key('tab-create'), Key('tab-mine'), ]; @@ -139,7 +138,6 @@ void main() { expect(find.byKey(const Key('tab-home')), findsOneWidget); expect(find.byKey(const Key('tab-theater')), findsOneWidget); - expect(find.byKey(const Key('nav-center-action')), findsOneWidget); expect(find.byKey(const Key('tab-create')), findsOneWidget); expect(find.byKey(const Key('tab-mine')), findsOneWidget); expect(find.byKey(const Key('home-browse-page')), findsOneWidget); @@ -148,12 +146,6 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - await tester.tap(find.byKey(const Key('nav-center-action'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('create-page')), findsOneWidget); - - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-create'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('create-page')), findsOneWidget); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 41002e4..4d18208 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -33,9 +33,8 @@ void main() { for (final key in _tabKeys) { expect(find.byKey(key), findsOneWidget); } - expect(find.byKey(const Key('nav-center-action')), findsOneWidget); - await tester.tap(find.byKey(const Key('nav-center-action'))); + await tester.tap(find.byKey(const Key('tab-create'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('create-page')), findsOneWidget); diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart index 485442d..87b1073 100644 --- a/test/theme/app_dimens_test.dart +++ b/test/theme/app_dimens_test.dart @@ -21,7 +21,6 @@ void main() { expect(AppDimens.primaryButtonHeight, 52); expect(AppDimens.tapTarget, 44); expect(AppDimens.navHeight, 64); - expect(AppDimens.navCenter, 56); expect(AppDimens.progressHeight, 3); }); } From 73aef4e44e691577768d591850c7d1a1df01d8e1 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 10:24:19 +0800 Subject: [PATCH 022/124] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E5=89=A7=E5=9C=BA=E4=B8=8E=E9=A6=96=E9=A1=B5=20Hero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_dimens.dart | 4 +- lib/l10n/app_en.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_id.dart | 6 + lib/l10n/app_localizations_zh.dart | 6 + lib/l10n/app_zh.arb | 2 + lib/pages/discover/discover_logic.dart | 16 + lib/pages/discover/discover_types.dart | 3 + lib/pages/discover/discover_view.dart | 301 ++++++++---------- .../discover/widgets/discover_header.dart | 116 ++----- .../widgets/discover_search_field.dart | 15 +- .../widgets/feed_banner_carousel.dart | 76 +++-- lib/pages/home/home_browse_view.dart | 7 +- test/discover/discover_banner_test.dart | 12 + test/discover/home_all_dramas_grid_test.dart | 33 +- test/home/k28_a_navigation_test.dart | 24 +- test/widget_test.dart | 8 +- 19 files changed, 339 insertions(+), 312 deletions(-) diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 83e6116..6808229 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -60,8 +60,10 @@ abstract final class AppDimens { static const double accountLabel = 11; static const double watchPlaceholderHeight = 114; - static const double heroBannerHeight = 176; + static const double heroBannerHeight = 240; static const double heroBannerRadius = 20; + static const double heroTitle = 28; + static const double heroSubtitle = 14; static const double cardMeta = 12; static const double cardViews = 11; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c1077e2..94fd0bd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -116,6 +116,8 @@ "recommendedTab": "Recommended", "noCategoryDramas": "No dramas in this category yet.", "allDramas": "All Dramas", + "allLanguages": "All languages", + "bahasaMelayu": "Malay", "createSubtitle": "Stories become short dramas", "weekdayMon": "Mon", "weekdayTue": "Tue", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index f431983..d8c5f60 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -116,6 +116,8 @@ "recommendedTab": "Rekomendasi", "noCategoryDramas": "Belum ada drama di kategori ini.", "allDramas": "Semua Drama", + "allLanguages": "Semua bahasa", + "bahasaMelayu": "Bahasa Melayu", "createSubtitle": "Cerita menjadi drama pendek", "weekdayMon": "Sen", "weekdayTue": "Sel", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 298338c..04fc45b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -562,6 +562,18 @@ abstract class AppLocalizations { /// **'All Dramas'** String get allDramas; + /// No description provided for @allLanguages. + /// + /// In en, this message translates to: + /// **'All languages'** + String get allLanguages; + + /// No description provided for @bahasaMelayu. + /// + /// In en, this message translates to: + /// **'Malay'** + String get bahasaMelayu; + /// No description provided for @createSubtitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 18d075b..ee969f7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -258,6 +258,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get allDramas => 'All Dramas'; + @override + String get allLanguages => 'All languages'; + + @override + String get bahasaMelayu => 'Malay'; + @override String get createSubtitle => 'Stories become short dramas'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index fcb7857..0280e21 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -258,6 +258,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get allDramas => 'Semua Drama'; + @override + String get allLanguages => 'Semua bahasa'; + + @override + String get bahasaMelayu => 'Bahasa Melayu'; + @override String get createSubtitle => 'Cerita menjadi drama pendek'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 4990bb9..489c4ab 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -253,6 +253,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get allDramas => '全部剧目'; + @override + String get allLanguages => '全部语言'; + + @override + String get bahasaMelayu => '马来语'; + @override String get createSubtitle => '让故事成为漫剧'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 111cb7c..8d474f2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -116,6 +116,8 @@ "recommendedTab": "推荐", "noCategoryDramas": "这个分类暂时没有剧集。", "allDramas": "全部剧目", + "allLanguages": "全部语言", + "bahasaMelayu": "马来语", "createSubtitle": "让故事成为漫剧", "weekdayMon": "周一", "weekdayTue": "周二", diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 7df910f..9556f36 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -28,6 +28,8 @@ class DiscoverLogic extends ChangeNotifier { String _lastRequestedQuery = ''; int _searchRequest = 0; bool _disposed = false; + TheaterLanguage language = TheaterLanguage.all; + String? selectedCategoryId; bool get hasSearchQuery => search.text.trim().isNotEmpty; @@ -48,6 +50,20 @@ class DiscoverLogic extends ChangeNotifier { void openSearch() => searchFocus.requestFocus(); + /// 选择语言;当前仅改变视觉选中态,不参与数据过滤。 + void selectLanguage(TheaterLanguage value) { + if (language == value) return; + language = value; + notify(); + } + + /// 选择分类;空值表示全部。 + void selectCategory(String? categoryId) { + if (selectedCategoryId == categoryId) return; + selectedCategoryId = categoryId; + notify(); + } + void retrySearch() { final query = search.text.trim(); if (query.isNotEmpty) unawaited(_runSearch(query)); diff --git a/lib/pages/discover/discover_types.dart b/lib/pages/discover/discover_types.dart index d369cc9..5899661 100644 --- a/lib/pages/discover/discover_types.dart +++ b/lib/pages/discover/discover_types.dart @@ -7,6 +7,9 @@ import '../../core/data/models/drama.dart'; /// 剧场页与根导航之间的动作。 enum DiscoverPageAction { home, create, my } +/// 剧场语言筛选的视觉选中态。 +enum TheaterLanguage { all, zh, id, ms, en } + /// 剧场页导航回调;没有 `onAction` 时按独立页面处理。 class DiscoverPageCallbacks { const DiscoverPageCallbacks({ diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 1d5c5f1..222f127 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -4,12 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../../core/data/models/content_category.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/models/home_feed.dart'; -import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/search_repository.dart'; -import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; @@ -19,19 +15,14 @@ import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; -import 'category_dramas_page.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'discover_types.dart'; -import 'widgets/category_grid.dart'; import 'widgets/discover_header.dart'; -import 'widgets/discover_search_field.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/drama_grid.dart'; -import 'widgets/feed_banner_carousel.dart'; -import 'widgets/ranking_section.dart'; -/// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。 +/// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, @@ -96,13 +87,18 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); - final banners = context.watch().banners; final content = SafeArea( bottom: !embedded, child: Column( children: [ - DiscoverHeader(onSearch: logic.openSearch, onProfile: _openProfile), - Expanded(child: _feed(l10n, categories, banners)), + DiscoverHeader( + controller: logic.search, + focusNode: logic.searchFocus, + onChanged: (_) => logic.onSearchChanged(), + loading: logic.searchLoading, + onProfile: _openProfile, + ), + Expanded(child: _feed(l10n, categories)), if (!embedded) _bottomNav(l10n), ], ), @@ -118,58 +114,37 @@ class _DiscoverPageState extends State { ); } - Widget _feed( - AppLocalizations l10n, - CategoryController categories, - List banners, - ) { - return RefreshIndicator( - key: const Key('discover-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, - onRefresh: categories.load, - child: ListView( - controller: logic.scroll, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 24), - children: [ - Padding( - padding: const EdgeInsets.only(top: 8), - child: DiscoverSearchField( - controller: logic.search, - focusNode: logic.searchFocus, - onChanged: (_) => logic.onSearchChanged(), - loading: logic.searchLoading, - ), - ), - ..._content(l10n, categories, banners), - ], + Widget _feed(AppLocalizations l10n, CategoryController categories) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: logic.searchFocus.unfocus, + child: RefreshIndicator( + key: const Key('discover-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: categories.load, + child: ListView( + controller: logic.scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + children: _content(l10n, categories), + ), ), ); } - List _content( - AppLocalizations l10n, - CategoryController categories, - List banners, - ) { - if (logic.hasSearchQuery) return [_searchContent(l10n)]; - return _theaterContent(l10n, categories, banners); - } - - Widget _searchContent(AppLocalizations l10n) => DiscoverSearchResults( - loading: logic.searchLoading, - error: logic.searchError, - results: logic.searchResults, - onRetry: logic.retrySearch, - onOpen: _openDrama, - ); - - List _theaterContent( - AppLocalizations l10n, - CategoryController categories, - List banners, - ) { + List _content(AppLocalizations l10n, CategoryController categories) { + if (logic.hasSearchQuery) { + return [ + DiscoverSearchResults( + loading: logic.searchLoading, + error: logic.searchError, + results: logic.searchResults, + onRetry: logic.retrySearch, + onOpen: _openDrama, + ), + ]; + } if (categories.loading && categories.feed.sections.isEmpty) { return [_block(const CircularProgressIndicator(strokeWidth: 2))]; } @@ -188,91 +163,124 @@ class _DiscoverPageState extends State { ), ]; } - return _theaterBlocks(l10n, categories, banners); + return _theaterBlocks(l10n, categories); } List _theaterBlocks( AppLocalizations l10n, CategoryController categories, - List banners, ) { - final dramas = categories.feed.dramas; + final dramas = logic.selectedCategoryId == null + ? categories.feed.dramas + : categories.dramasOf(logic.selectedCategoryId!); return [ - Padding( - padding: const EdgeInsets.only(top: 12), - child: CategoryGrid( - categories: categories.categories, - onTap: (category) => _openCategory(category, categories), - ), - ), - if (banners.isNotEmpty) + _categoryChips(l10n, categories), + _languageChips(l10n), + if (dramas.isEmpty) Padding( - padding: const EdgeInsets.only(top: 12), - child: FeedBannerCarousel( - banners: banners, - active: widget.active, - onOpen: _openBanner, + padding: const EdgeInsets.symmetric(vertical: 40), + child: EmptyState( + key: const Key('discover-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.noStories, + subtitle: l10n.emptyHint, ), - ), - if (categories.feed.sections.isNotEmpty || dramas.isNotEmpty) - RankingSection( - sections: categories.feed.sections, - fallback: dramas, - onOpen: _openDrama, - ), - _sectionTitle(l10n.allDramas), - if (dramas.isEmpty) _emptyCatalog(l10n) else _dramaGrid(l10n, dramas), + ) + else + DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama), ]; } - Widget _block(Widget child) => - SizedBox(height: 280, child: Center(child: child)); - - Widget _emptyCatalog(AppLocalizations l10n) => Padding( - padding: const EdgeInsets.symmetric(vertical: 40), - child: EmptyState( - key: const Key('discover-empty'), - icon: Icons.movie_filter_outlined, - title: l10n.noStories, - subtitle: l10n.emptyHint, - ), - ); - - Widget _dramaGrid(AppLocalizations l10n, List dramas) { - return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama); - } - - Widget _sectionTitle(String title) { + Widget _categoryChips(AppLocalizations l10n, CategoryController categories) { return Padding( - padding: const EdgeInsets.fromLTRB(14, 18, 14, 10), - child: Row( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceMd, + AppDimens.pageHorizontal, + 0, + ), + child: Wrap( + key: const Key('theater-category-chips'), + spacing: 10, + runSpacing: 10, children: [ - Container( - width: 3, - height: 18, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(2), - ), + _filterChip( + key: const Key('theater-category-all'), + label: l10n.all, + selected: logic.selectedCategoryId == null, + onSelected: () => logic.selectCategory(null), ), - const SizedBox(width: 6), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.previewSectionTitle, - fontWeight: FontWeight.w800, - ), + for (final category in categories.categories) + _filterChip( + key: Key('theater-category-${category.id}'), + label: category.name, + selected: logic.selectedCategoryId == category.id, + onSelected: () => logic.selectCategory(category.id), ), - ), ], ), ); } + Widget _languageChips(AppLocalizations l10n) { + final entries = [ + (TheaterLanguage.all, l10n.allLanguages), + (TheaterLanguage.zh, l10n.simplifiedChinese), + (TheaterLanguage.id, l10n.bahasaIndonesia), + (TheaterLanguage.ms, l10n.bahasaMelayu), + (TheaterLanguage.en, l10n.english), + ]; + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceSm, + AppDimens.pageHorizontal, + 0, + ), + child: Wrap( + key: const Key('theater-language-chips'), + spacing: 10, + runSpacing: 10, + children: [ + for (final entry in entries) + _filterChip( + key: Key('theater-language-${entry.$1.name}'), + label: entry.$2, + selected: logic.language == entry.$1, + onSelected: () => logic.selectLanguage(entry.$1), + ), + ], + ), + ); + } + + Widget _filterChip({ + required Key key, + required String label, + required bool selected, + required VoidCallback onSelected, + }) { + return ChoiceChip( + key: key, + label: Text(label), + selected: selected, + showCheckmark: false, + onSelected: (_) => onSelected(), + backgroundColor: AppColors.card, + selectedColor: AppColors.blue, + side: BorderSide(color: selected ? AppColors.blue : AppColors.border), + labelStyle: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.onSurface, + fontSize: AppDimens.caption, + fontWeight: FontWeight.w700, + ), + shape: const StadiumBorder(), + ); + } + + Widget _block(Widget child) => + SizedBox(height: 280, child: Center(child: child)); + void _openProfile() { final callback = widget.callbacks.onProfile; if (callback != null) { @@ -291,54 +299,17 @@ class _DiscoverPageState extends State { ); } - void _openCategory(ContentCategory category, CategoryController categories) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CategoryDramasPage( - title: category.name, - dramas: categories.dramasOf(category.id), - onOpen: widget.callbacks.onDramaSelected == null ? null : _openDrama, - ), - ), - ); - } - void _openDrama(Drama drama) { final callback = widget.callbacks.onDramaSelected; if (callback != null) { callback(drama); return; } - _pushDetail(drama); - } - - void _pushDetail(Drama drama) { Navigator.of(context).push( MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), ); } - Future _openBanner(FeedBanner banner) async { - final dramaId = banner.dramaId; - if (dramaId == null) return; - for (final drama in context.read().dramas) { - if (drama.id == dramaId) { - _pushDetail(drama); - return; - } - } - try { - final drama = await context.read().fetchDrama(dramaId); - if (mounted) _pushDetail(drama); - } catch (_) { - if (!mounted) return; - final message = AppLocalizations.of(context)!.detailLoadFailed; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - } - } - Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( @@ -352,7 +323,7 @@ class _DiscoverPageState extends State { tabKey: const Key('discover-nav-current'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, + label: l10n.theaterTab, selected: true, onTap: () {}, ), @@ -365,8 +336,8 @@ class _DiscoverPageState extends State { ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, + icon: Icons.person_outline_rounded, + selectedIcon: Icons.person_rounded, label: l10n.myTab, onTap: () => _act(DiscoverPageAction.my), ), diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart index c55adb5..bbff9d2 100644 --- a/lib/pages/discover/widgets/discover_header.dart +++ b/lib/pages/discover/widgets/discover_header.dart @@ -3,37 +3,48 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; +import 'discover_search_field.dart'; -/// 剧场页页头:品牌、搜索与个人页入口。 +/// 剧场页页头:搜索与头像入口。 class DiscoverHeader extends StatelessWidget { const DiscoverHeader({ super.key, - required this.onSearch, + required this.controller, + required this.focusNode, + required this.onChanged, + required this.loading, required this.onProfile, }); - final VoidCallback onSearch; + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + final bool loading; final VoidCallback onProfile; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return SizedBox( - height: 46, + height: AppDimens.contentHeaderHeight, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), child: Row( children: [ - _brand(l10n), - const Spacer(), - _HeaderIcon( - key: const Key('discover-search-button'), - icon: Icons.search_rounded, - tooltip: l10n.search, - onTap: onSearch, + Expanded( + child: DiscoverSearchField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + loading: loading, + horizontalPadding: 0, + ), ), - const SizedBox(width: 8), + const SizedBox(width: AppDimens.spaceSm), _profile(l10n), ], ), @@ -41,37 +52,6 @@ class DiscoverHeader extends StatelessWidget { ); } - Widget _brand(AppLocalizations l10n) { - return Row( - children: [ - Container( - width: 24, - height: 24, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(7), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 16, - ), - ), - const SizedBox(width: 8), - Text( - l10n.discoverTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ); - } - Widget _profile(AppLocalizations l10n) { return Semantics( button: true, @@ -81,54 +61,20 @@ class DiscoverHeader extends StatelessWidget { customBorder: const CircleBorder(), onTap: onProfile, child: Container( - width: 36, - height: 36, - decoration: const BoxDecoration( + width: AppDimens.tapTarget, + height: AppDimens.tapTarget, + decoration: BoxDecoration( + color: AppColors.card, shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.secondary, AppColors.blue], - ), + border: Border.all(color: AppColors.border), ), child: const Icon( Icons.person_rounded, - color: AppColors.onPrimary, - size: 18, + color: AppColors.onSurface, + size: AppDimens.accountIcon, ), ), ), ); } } - -class _HeaderIcon extends StatelessWidget { - const _HeaderIcon({ - super.key, - required this.icon, - required this.tooltip, - required this.onTap, - }); - - final IconData icon; - final String tooltip; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 36, - height: 36, - child: IconButton( - tooltip: tooltip, - onPressed: onTap, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: AppColors.raised, - foregroundColor: AppColors.onSurface, - ), - icon: Icon(icon, size: 18), - ), - ); - } -} diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index de549af..a9a9aab 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; /// 剧场顶部搜索框。 @@ -13,19 +14,21 @@ class DiscoverSearchField extends StatelessWidget { required this.focusNode, required this.onChanged, required this.loading, + this.horizontalPadding = 14, }); final TextEditingController controller; final FocusNode focusNode; final ValueChanged onChanged; final bool loading; + final double horizontalPadding; @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: SizedBox( - height: 36, + height: AppDimens.searchHeight, child: TextField( key: const Key('discover-search'), controller: controller, @@ -33,13 +36,13 @@ class DiscoverSearchField extends StatelessWidget { onChanged: onChanged, onTapOutside: (_) => focusNode.unfocus(), textInputAction: TextInputAction.search, - style: const TextStyle(color: AppColors.onSurface, fontSize: 13), + style: const TextStyle(color: AppColors.onSurface, fontSize: 14), decoration: InputDecoration( isDense: true, filled: true, fillColor: AppColors.input, hintText: AppLocalizations.of(context)!.searchDiscover, - hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11), + hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12), prefixIcon: const Icon( Icons.search_rounded, color: AppColors.subtle, @@ -53,11 +56,11 @@ class DiscoverSearchField extends StatelessWidget { : null, contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(AppDimens.pillRadius), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(AppDimens.pillRadius), borderSide: const BorderSide(color: AppColors.blue, width: 1), ), ), diff --git a/lib/pages/discover/widgets/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart index 17101bc..28800d8 100644 --- a/lib/pages/discover/widgets/feed_banner_carousel.dart +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -39,9 +39,7 @@ class FeedBannerCarousel extends StatefulWidget { } class _FeedBannerCarouselState extends State { - final PageController _controller = PageController( - viewportFraction: _viewportFraction, - ); + late final PageController _controller; Timer? _timer; int _index = 0; bool _pressed = false; @@ -50,6 +48,9 @@ class _FeedBannerCarouselState extends State { @override void initState() { super.initState(); + _controller = PageController( + viewportFraction: widget.dramaFor == null ? _viewportFraction : 1, + ); WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer()); } @@ -116,28 +117,43 @@ class _FeedBannerCarouselState extends State { @override Widget build(BuildContext context) { if (widget.banners.isEmpty) return const SizedBox.shrink(); - final height = widget.dramaFor == null - ? _carouselHeight - : AppDimens.heroBannerHeight; + final hero = widget.dramaFor != null; + final height = hero ? AppDimens.heroBannerHeight : _carouselHeight; + final pages = PageView.builder( + controller: _controller, + itemCount: widget.banners.length, + onPageChanged: (value) => setState(() => _index = value), + itemBuilder: (context, index) => _bannerCard(index), + ); return Listener( key: const Key('feed-banner-carousel'), onPointerDown: (_) => _pause(), onPointerUp: (_) => _resume(), onPointerCancel: (_) => _resume(), - child: Column( - children: [ - SizedBox( - height: height, - child: PageView.builder( - controller: _controller, - itemCount: widget.banners.length, - onPageChanged: (value) => setState(() => _index = value), - itemBuilder: (context, index) => _bannerCard(index), + child: hero + ? SizedBox( + height: height, + child: Stack( + children: [ + Positioned.fill(child: pages), + Positioned( + left: 0, + right: 0, + bottom: AppDimens.spaceXs, + child: SizedBox( + height: _indicatorHeight, + child: _indicator(context), + ), + ), + ], + ), + ) + : Column( + children: [ + SizedBox(height: height, child: pages), + SizedBox(height: _indicatorHeight, child: _indicator(context)), + ], ), - ), - SizedBox(height: _indicatorHeight, child: _indicator(context)), - ], - ), ); } @@ -146,7 +162,9 @@ class _FeedBannerCarouselState extends State { final dramaId = banner.dramaId; final drama = widget.dramaFor?.call(banner); final card = ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.authCardRadius), + borderRadius: BorderRadius.circular( + drama == null ? AppDimens.authCardRadius : AppDimens.heroBannerRadius, + ), child: Stack( fit: StackFit.expand, children: [ @@ -158,7 +176,7 @@ class _FeedBannerCarouselState extends State { end: Alignment.bottomCenter, colors: drama == null ? const [Color(0x00000000), Color(0x8A0B0C10)] - : const [Color(0x120B0C10), Color(0xE60B0C10)], + : const [Color(0x00000000), Color(0xE6000000)], ), ), ), @@ -166,7 +184,7 @@ class _FeedBannerCarouselState extends State { Positioned( left: AppDimens.pageHorizontal, right: _copyRightGap, - bottom: AppDimens.cardPadding, + bottom: AppDimens.spaceXl + AppDimens.spaceSm, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -179,7 +197,7 @@ class _FeedBannerCarouselState extends State { style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: AppDimens.pageTitle, + fontSize: AppDimens.heroTitle, fontWeight: FontWeight.w800, ), ), @@ -190,24 +208,28 @@ class _FeedBannerCarouselState extends State { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, - fontSize: AppDimens.caption, + fontSize: AppDimens.heroSubtitle, ), ), ], ), ), - if (dramaId != null) + if (dramaId != null && drama == null) const Positioned(right: 14, bottom: 14, child: _PlayMark()), ], ), ); final page = Padding( - padding: const EdgeInsets.symmetric(horizontal: 5), + padding: EdgeInsets.symmetric(horizontal: drama == null ? 5 : 0), child: dramaId == null ? card : Material( color: Colors.transparent, - borderRadius: BorderRadius.circular(AppDimens.authCardRadius), + borderRadius: BorderRadius.circular( + drama == null + ? AppDimens.authCardRadius + : AppDimens.heroBannerRadius, + ), clipBehavior: Clip.antiAlias, child: InkWell( key: Key('feed-banner-$index'), diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index 6782f33..b8a04ea 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -228,7 +228,12 @@ class _HomeBrowseViewState extends State { Widget _hero(List banners, List dramas) { return Padding( - padding: const EdgeInsets.only(top: AppDimens.spaceMd), + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceMd, + AppDimens.pageHorizontal, + 0, + ), child: FeedBannerCarousel( key: const Key('home-hero-banner'), banners: banners, diff --git a/test/discover/discover_banner_test.dart b/test/discover/discover_banner_test.dart index 1a441c5..a5f08a0 100644 --- a/test/discover/discover_banner_test.dart +++ b/test/discover/discover_banner_test.dart @@ -38,6 +38,18 @@ void main() { ); await _pumpApp(tester, catalog); expect(find.byKey(const Key('feed-banner-carousel')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('home-hero-banner'))).height, + greaterThanOrEqualTo(230), + ); + expect( + tester + .widget(find.byKey(const Key('home-hero-title-0'))) + .style + ?.fontSize, + 28, + ); + expect(find.byKey(const Key('feed-banner-dot-0')), findsOneWidget); await tester.tap(find.byKey(const Key('feed-banner-0'))); await tester.pumpAndSettle(); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index fc99d42..5c638c1 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -24,37 +24,27 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_show_search_categories_ranking_and_all_dramas', ( + testWidgets('should_show_search_category_language_chips_and_drama_grid', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('ranking-tab-hot')), findsOneWidget); - expect(find.byKey(const Key('ranking-tab-new')), findsOneWidget); - - final grid = tester.widget( - find.byKey(const Key('discover-category-grid')), - ); - final delegate = - grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, 3); expect(tester.takeException(), isNull); }); - testWidgets('should_open_category_dramas_page_when_tile_is_tapped', ( + testWidgets('should_filter_grid_when_category_chip_is_tapped', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - await tester.tap(find.byKey(const Key('discover-category-7'))); + await tester.tap(find.byKey(const Key('theater-category-7'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('category-dramas-page')), findsOneWidget); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, @@ -76,22 +66,23 @@ void main() { ); final headerBefore = tester.getTopLeft( - find.byKey(const Key('discover-search-button')), + find.byKey(const Key('discover-search')), ); - final contentBefore = tester.getTopLeft( - find.byKey(const Key('discover-category-grid')), + final scrollable = find.descendant( + of: find.byKey(const Key('discover-refresh')), + matching: find.byType(Scrollable), ); await tester.drag(find.byType(ListView), const Offset(0, -140)); await tester.pumpAndSettle(); expect( - tester.getTopLeft(find.byKey(const Key('discover-search-button'))), + tester.getTopLeft(find.byKey(const Key('discover-search'))), headerBefore, ); expect( - tester.getTopLeft(find.byKey(const Key('discover-category-grid'))).dy, - lessThan(contentBefore.dy), + tester.state(scrollable).position.pixels, + greaterThan(0), ); }); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 4d18208..0ca92e9 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -79,6 +79,26 @@ void main() { expect(field.focusNode?.hasFocus, isTrue); }); + testWidgets('should_select_theater_language_without_filtering_catalog', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + + expect(_chip(tester, 'theater-language-all').selected, isTrue); + expect(_chip(tester, 'theater-language-id').selected, isFalse); + await tester.tap(find.byKey(const Key('theater-language-id'))); + await tester.pumpAndSettle(); + + expect(_chip(tester, 'theater-language-all').selected, isFalse); + expect(_chip(tester, 'theater-language-id').selected, isTrue); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, + ); + }); + testWidgets('should_open_fullscreen_player_when_home_card_is_tapped', ( tester, ) async { @@ -174,8 +194,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); expect(tester.takeException(), isNull); }); } diff --git a/test/widget_test.dart b/test/widget_test.dart index 2c93dbf..4a12dd3 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -176,7 +176,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_render_existing_theater_layout_on_same_route_tab', ( + testWidgets('should_render_theater_filters_and_grid_on_same_route_tab', ( tester, ) async { tester.view.physicalSize = const Size(360, 720); @@ -189,10 +189,10 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); + expect(find.byKey(const Key('discover-search')), findsOneWidget); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-search-button')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect(tester.takeException(), isNull); }); From 03c3964353e0578f55413cfcbe6eb7cb57ab6a3f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 10:28:31 +0800 Subject: [PATCH 023/124] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E5=88=9B=E4=BD=9C=E4=B8=8E=E6=88=91=E7=9A=84=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 8 + lib/l10n/app_id.arb | 8 + lib/l10n/app_localizations.dart | 48 +++ lib/l10n/app_localizations_en.dart | 24 ++ lib/l10n/app_localizations_id.dart | 25 ++ lib/l10n/app_localizations_zh.dart | 24 ++ lib/l10n/app_zh.arb | 8 + lib/pages/create/create_view.dart | 177 ++++++++-- lib/pages/mine/mine_types.dart | 14 + lib/pages/mine/mine_view.dart | 174 ++++----- lib/pages/mine/widgets/mine_header.dart | 80 +++-- lib/pages/mine/widgets/mine_menu.dart | 409 +++++++++++++++++----- test/account/mine_account_entry_test.dart | 4 + test/home/k28_a_navigation_test.dart | 7 + test/mine/mine_preview_layout_test.dart | 23 +- test/mine/settings_page_test.dart | 2 + test/notice/notices_entry_test.dart | 6 +- 17 files changed, 807 insertions(+), 234 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 94fd0bd..dfa073d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -119,6 +119,10 @@ "allLanguages": "All languages", "bahasaMelayu": "Malay", "createSubtitle": "Stories become short dramas", + "createAiTitle": "AI production", + "createAiSubtitle": "Start from a story or an idea", + "createUploadTitle": "Upload and publish", + "createUploadSubtitle": "Publish a finished short drama", "weekdayMon": "Mon", "weekdayTue": "Tue", "weekdayWed": "Wed", @@ -316,6 +320,10 @@ "newest": "New", "favorite": "Favorite", "myAccount": "My account", + "myWorks": "My works", + "creatorCenter": "Creator center", + "helpFeedback": "Help & feedback", + "vipUnlockMessage": "Unlock more with membership", "coins": "Coins", "topUpWallet": "Top Up Wallet", "vipMembership": "VIP Membership", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d8c5f60..26d598b 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -119,6 +119,10 @@ "allLanguages": "Semua bahasa", "bahasaMelayu": "Bahasa Melayu", "createSubtitle": "Cerita menjadi drama pendek", + "createAiTitle": "Produksi AI", + "createAiSubtitle": "Mulai dari cerita atau ide", + "createUploadTitle": "Unggah dan terbitkan", + "createUploadSubtitle": "Terbitkan drama pendek yang sudah selesai", "weekdayMon": "Sen", "weekdayTue": "Sel", "weekdayWed": "Rab", @@ -316,6 +320,10 @@ "newest": "Terbaru", "favorite": "Favorit", "myAccount": "Akun saya", + "myWorks": "Karya saya", + "creatorCenter": "Pusat kreator", + "helpFeedback": "Bantuan & masukan", + "vipUnlockMessage": "Buka lebih banyak dengan keanggotaan", "coins": "Koin", "topUpWallet": "Isi Ulang Dompet", "vipMembership": "Keanggotaan VIP", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 04fc45b..2e4be26 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -580,6 +580,30 @@ abstract class AppLocalizations { /// **'Stories become short dramas'** String get createSubtitle; + /// No description provided for @createAiTitle. + /// + /// In en, this message translates to: + /// **'AI production'** + String get createAiTitle; + + /// No description provided for @createAiSubtitle. + /// + /// In en, this message translates to: + /// **'Start from a story or an idea'** + String get createAiSubtitle; + + /// No description provided for @createUploadTitle. + /// + /// In en, this message translates to: + /// **'Upload and publish'** + String get createUploadTitle; + + /// No description provided for @createUploadSubtitle. + /// + /// In en, this message translates to: + /// **'Publish a finished short drama'** + String get createUploadSubtitle; + /// No description provided for @weekdayMon. /// /// In en, this message translates to: @@ -1360,6 +1384,30 @@ abstract class AppLocalizations { /// **'My account'** String get myAccount; + /// No description provided for @myWorks. + /// + /// In en, this message translates to: + /// **'My works'** + String get myWorks; + + /// No description provided for @creatorCenter. + /// + /// In en, this message translates to: + /// **'Creator center'** + String get creatorCenter; + + /// No description provided for @helpFeedback. + /// + /// In en, this message translates to: + /// **'Help & feedback'** + String get helpFeedback; + + /// No description provided for @vipUnlockMessage. + /// + /// In en, this message translates to: + /// **'Unlock more with membership'** + String get vipUnlockMessage; + /// No description provided for @coins. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ee969f7..5265724 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -267,6 +267,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get createSubtitle => 'Stories become short dramas'; + @override + String get createAiTitle => 'AI production'; + + @override + String get createAiSubtitle => 'Start from a story or an idea'; + + @override + String get createUploadTitle => 'Upload and publish'; + + @override + String get createUploadSubtitle => 'Publish a finished short drama'; + @override String get weekdayMon => 'Mon'; @@ -681,6 +693,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get myAccount => 'My account'; + @override + String get myWorks => 'My works'; + + @override + String get creatorCenter => 'Creator center'; + + @override + String get helpFeedback => 'Help & feedback'; + + @override + String get vipUnlockMessage => 'Unlock more with membership'; + @override String get coins => 'Coins'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 0280e21..e56a798 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -267,6 +267,19 @@ class AppLocalizationsId extends AppLocalizations { @override String get createSubtitle => 'Cerita menjadi drama pendek'; + @override + String get createAiTitle => 'Produksi AI'; + + @override + String get createAiSubtitle => 'Mulai dari cerita atau ide'; + + @override + String get createUploadTitle => 'Unggah dan terbitkan'; + + @override + String get createUploadSubtitle => + 'Terbitkan drama pendek yang sudah selesai'; + @override String get weekdayMon => 'Sen'; @@ -683,6 +696,18 @@ class AppLocalizationsId extends AppLocalizations { @override String get myAccount => 'Akun saya'; + @override + String get myWorks => 'Karya saya'; + + @override + String get creatorCenter => 'Pusat kreator'; + + @override + String get helpFeedback => 'Bantuan & masukan'; + + @override + String get vipUnlockMessage => 'Buka lebih banyak dengan keanggotaan'; + @override String get coins => 'Koin'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 489c4ab..f7ee1e3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -262,6 +262,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get createSubtitle => '让故事成为漫剧'; + @override + String get createAiTitle => 'AI 制作'; + + @override + String get createAiSubtitle => '从故事或想法开始'; + + @override + String get createUploadTitle => '上传发布'; + + @override + String get createUploadSubtitle => '发布已完成的漫剧'; + @override String get weekdayMon => '周一'; @@ -670,6 +682,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get myAccount => '我的账户'; + @override + String get myWorks => '我的作品'; + + @override + String get creatorCenter => '创作者中心'; + + @override + String get helpFeedback => '帮助与反馈'; + + @override + String get vipUnlockMessage => '开通会员,解锁更多精彩内容'; + @override String get coins => '金币'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8d474f2..687245a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -119,6 +119,10 @@ "allLanguages": "全部语言", "bahasaMelayu": "马来语", "createSubtitle": "让故事成为漫剧", + "createAiTitle": "AI 制作", + "createAiSubtitle": "从故事或想法开始", + "createUploadTitle": "上传发布", + "createUploadSubtitle": "发布已完成的漫剧", "weekdayMon": "周一", "weekdayTue": "周二", "weekdayWed": "周三", @@ -278,6 +282,10 @@ "newest": "最新", "favorite": "收藏", "myAccount": "我的账户", + "myWorks": "我的作品", + "creatorCenter": "创作者中心", + "helpFeedback": "帮助与反馈", + "vipUnlockMessage": "开通会员,解锁更多精彩内容", "coins": "金币", "topUpWallet": "充值", "vipMembership": "会员", diff --git a/lib/pages/create/create_view.dart b/lib/pages/create/create_view.dart index 313beb0..2004eaf 100644 --- a/lib/pages/create/create_view.dart +++ b/lib/pages/create/create_view.dart @@ -7,7 +7,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; -/// K28-A1 创作页占位骨架。 +/// K28-B 创作页:AI 制作与上传发布入口。 class CreatePage extends StatelessWidget { const CreatePage({super.key}); @@ -18,7 +18,7 @@ class CreatePage extends StatelessWidget { data: AppTheme.light(), child: SafeArea( bottom: false, - child: Padding( + child: ListView( key: const Key('create-page'), padding: const EdgeInsets.fromLTRB( AppDimens.pageHorizontal, @@ -26,30 +26,165 @@ class CreatePage extends StatelessWidget { AppDimens.pageHorizontal, AppDimens.spaceXl, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.createTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: AppDimens.pageTitle, - fontWeight: FontWeight.w800, - ), + children: [ + Text( + l10n.createTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.heroTitle, + fontWeight: FontWeight.w800, ), - const SizedBox(height: AppDimens.spaceSm), - Text( - l10n.createSubtitle, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.body, - ), + ), + const SizedBox(height: AppDimens.spaceSm), + Text( + l10n.createSubtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, ), - ], + ), + const SizedBox(height: AppDimens.spaceXl), + _CreateEntry( + key: const Key('create-ai'), + icon: Icons.auto_awesome_rounded, + title: l10n.createAiTitle, + subtitle: l10n.createAiSubtitle, + onTap: () => _openPlaceholder(context, l10n.createAiTitle), + ), + const SizedBox(height: AppDimens.spaceMd), + _CreateEntry( + key: const Key('create-upload'), + icon: Icons.upload_file_rounded, + title: l10n.createUploadTitle, + subtitle: l10n.createUploadSubtitle, + onTap: () => _openPlaceholder(context, l10n.createUploadTitle), + ), + ], + ), + ), + ); + } + + void _openPlaceholder(BuildContext context, String title) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => _CreatePlaceholderPage(title: title)), + ); + } +} + +class _CreateEntry extends StatelessWidget { + const _CreateEntry({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppDimens.cardPadding), + child: Row( + children: [ + Container( + width: AppDimens.buttonLarge, + height: AppDimens.buttonLarge, + decoration: BoxDecoration( + color: AppColors.badgePink, + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + ), + child: Icon(icon, color: AppColors.secondary, size: 22), + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + subtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 20, + ), + ], + ), ), ), ), ); } } + +class _CreatePlaceholderPage extends StatelessWidget { + const _CreatePlaceholderPage({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Scaffold( + key: const Key('create-placeholder-page'), + backgroundColor: AppColors.ink, + appBar: AppBar(title: Text(title)), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.construction_rounded, + color: AppColors.blue, + size: 42, + ), + const SizedBox(height: AppDimens.spaceMd), + Text( + l10n.comingSoon, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart index 51783b3..a823e8b 100644 --- a/lib/pages/mine/mine_types.dart +++ b/lib/pages/mine/mine_types.dart @@ -7,6 +7,20 @@ import '../../core/data/models/drama.dart'; /// 「我的」页与根导航之间的返回动作。 enum MyPageAction { home, discover, create, resume } +/// 「我的」页可进入的功能目的地。 +enum MineDestination { + watchlist, + history, + downloads, + wallet, + vip, + works, + creatorCenter, + notices, + settings, + help, +} + /// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。 class MyPageCallbacks { const MyPageCallbacks({this.onAction, this.onDramaSelected}); diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index f1203b4..b3af0f2 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -5,33 +5,30 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../core/data/models/drama.dart'; -import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.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/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; import '../../l10n/app_localizations.dart'; import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; -import '../player/player_view.dart'; +import '../notices/notices_page.dart'; import '../settings/settings_page.dart'; +import '../store/recharge_page.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; import 'mine_types.dart'; -import 'widgets/continue_watching_card.dart'; import 'widgets/mine_header.dart'; import 'widgets/mine_menu.dart'; -/// 极简「我的」页:续看 + 追剧 / 历史 / 下载 / 钱包 / 会员 / 设置。 +/// K28-B 我的页。 class MyPage extends StatefulWidget { const MyPage({ super.key, @@ -101,36 +98,22 @@ class _MyPageState extends State { Widget _topBar(AppLocalizations l10n) { return SizedBox( - height: 46, + height: AppDimens.contentHeaderHeight, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - children: [ - Container( - width: 24, - height: 24, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(7), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 16, - ), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.myTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, ), - const SizedBox(width: 8), - Text( - l10n.myTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], + ), ), ), ); @@ -139,26 +122,27 @@ class _MyPageState extends State { Widget _body(BuildContext context, AppLocalizations l10n) { final auth = context.watch(); final wallet = context.watch(); - final record = _continueRecord; - final drama = _dramaFor(record); return ListView( - padding: const EdgeInsets.only(bottom: 18), + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + 0, + AppDimens.pageHorizontal, + AppDimens.spaceXl, + ), children: [ - const MineHeader(), - if (drama != null && record != null) - ContinueWatchingCard( - drama: drama, - record: record, - onResume: () => _resume(drama), - ), - const SizedBox(height: 14), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: MineMenu( - walletValue: _walletValue(l10n, auth, wallet), - onOpen: _openDestination, - ), + MineHeader(onTap: () => _openDestination(MineDestination.settings)), + const SizedBox(height: AppDimens.spaceMd), + MineShortcutGrid(onOpen: _openDestination), + const SizedBox(height: AppDimens.spaceMd), + MineWalletCard( + balance: _walletValue(l10n, auth, wallet), + onOpen: () => _openDestination(MineDestination.wallet), + onRecharge: _openRecharge, ), + const SizedBox(height: AppDimens.spaceMd), + MineVipCard(onOpen: () => _openDestination(MineDestination.vip)), + const SizedBox(height: AppDimens.spaceMd), + MineMenuList(onOpen: _openDestination), ], ); } @@ -173,39 +157,6 @@ class _MyPageState extends State { return l10n.walletBalanceShort(wallet.balance); } - WatchProgress? get _continueRecord { - for (final record in logic.recent) { - if (!record.finished && record.hasPosition) return record; - } - return null; - } - - Drama? _dramaFor(WatchProgress? record) { - if (record == null) return null; - for (final drama in context.read().dramas) { - if (drama.id == record.dramaId) return drama; - } - return null; - } - - void _resume(Drama drama) { - final callback = widget.callbacks.onDramaSelected; - if (callback != null) { - callback(drama); - return; - } - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlayerPage( - drama: drama, - initialIndex: 0, - wallet: context.read(), - downloads: context.read(), - ), - ), - ); - } - void _openDestination(MineDestination destination) { switch (destination) { case MineDestination.watchlist: @@ -218,8 +169,16 @@ class _MyPageState extends State { _openProtected(const WalletPage()); case MineDestination.vip: _openVip(); + case MineDestination.works: + _pushPlaceholder(MinePlaceholderKind.works); + case MineDestination.creatorCenter: + _pushPlaceholder(MinePlaceholderKind.creator); + case MineDestination.notices: + _push(const NoticesPage()); case MineDestination.settings: _push(const SettingsPage()); + case MineDestination.help: + _pushPlaceholder(MinePlaceholderKind.help); } } @@ -227,6 +186,10 @@ class _MyPageState extends State { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } + void _pushPlaceholder(MinePlaceholderKind kind) { + _push(_MinePlaceholderPage(kind: kind)); + } + Future _openProtected(Widget page) async { if (!context.read().isSignedIn) { await ensureSignedIn(context); @@ -240,6 +203,11 @@ class _MyPageState extends State { _openProtected(VipPage(onPurchased: () => unawaited(wallet.load()))); } + void _openRecharge() { + final wallet = context.read(); + _openProtected(RechargePage(onPurchased: () => unawaited(wallet.load()))); + } + Widget _bottomNav(AppLocalizations l10n) { return AppBottomNav( items: [ @@ -266,8 +234,8 @@ class _MyPageState extends State { ), AppBottomNavItem( tabKey: const Key('mine-nav-current'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, + icon: Icons.person_outline_rounded, + selectedIcon: Icons.person_rounded, label: l10n.myTab, selected: true, onTap: () {}, @@ -285,3 +253,35 @@ class _MyPageState extends State { ); } } + +enum MinePlaceholderKind { works, creator, help } + +class _MinePlaceholderPage extends StatelessWidget { + const _MinePlaceholderPage({required this.kind}); + + final MinePlaceholderKind kind; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final title = switch (kind) { + MinePlaceholderKind.works => l10n.myWorks, + MinePlaceholderKind.creator => l10n.creatorCenter, + MinePlaceholderKind.help => l10n.helpFeedback, + }; + return Scaffold( + key: const Key('mine-placeholder-page'), + backgroundColor: AppColors.ink, + appBar: AppBar(title: Text(title)), + body: Center( + child: Text( + l10n.comingSoon, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, + ), + ), + ), + ); + } +} diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index a51cc36..cce6ebc 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -5,11 +5,16 @@ import 'package:provider/provider.dart'; import '../../../core/data/services/auth_controller.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -/// 「我的」页顶部身份区,只保留头像、昵称和账号提示。 +/// 「我的」页顶部身份卡。 class MineHeader extends StatelessWidget { - const MineHeader({super.key}); + const MineHeader({super.key, required this.onTap}); + + final VoidCallback onTap; @override Widget build(BuildContext context) { @@ -23,37 +28,56 @@ class MineHeader extends StatelessWidget { final subtitle = signedIn && (user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.logInHint; - return Padding( + return Container( key: const Key('mine-user-row'), - padding: const EdgeInsets.fromLTRB(14, 14, 14, 6), - child: Row( - children: [ - _avatar(), - const SizedBox(width: 10), - Expanded(child: _labels(name, subtitle)), - ], + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppDimens.cardPadding), + child: Row( + children: [ + _avatar(user?.avatarUrl ?? ''), + const SizedBox(width: AppDimens.spaceMd), + Expanded(child: _labels(name, subtitle)), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 20, + ), + ], + ), + ), + ), ), ); } - Widget _avatar() { + Widget _avatar(String path) { return Container( key: const Key('mine-avatar'), - width: 48, - height: 48, + width: 64, + height: 64, decoration: const BoxDecoration( + color: AppColors.badgePink, shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.secondary, AppColors.blue], - ), - ), - child: const Icon( - Icons.person_rounded, - color: AppColors.onPrimary, - size: 22, ), + clipBehavior: Clip.antiAlias, + child: path.isEmpty + ? const Icon( + Icons.person_rounded, + color: AppColors.badgePinkText, + size: 30, + ) + : RemoteImage(path, fit: BoxFit.cover), ); } @@ -66,17 +90,21 @@ class MineHeader extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( + fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: 15, + fontSize: 18, fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 2), + const SizedBox(height: AppDimens.spaceXs), Text( subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.muted, fontSize: 10), + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), ), ], ); diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index 125e8ad..3cda8dd 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -3,16 +3,270 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/theme/app_theme.dart'; import '../../../l10n/app_localizations.dart'; +import '../mine_types.dart'; -/// 「我的」页六个核心入口。 -enum MineDestination { watchlist, history, downloads, wallet, vip, settings } +/// 我的页三宫格快捷入口。 +class MineShortcutGrid extends StatelessWidget { + const MineShortcutGrid({super.key, required this.onOpen}); -/// 极简功能清单,避免在主页堆叠内容列表。 -class MineMenu extends StatelessWidget { - const MineMenu({super.key, required this.walletValue, required this.onOpen}); + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Row( + children: [ + Expanded( + child: _ShortcutTile( + key: const Key('mine-watchlist'), + icon: Icons.bookmark_rounded, + label: l10n.watchlist, + onTap: () => onOpen(MineDestination.watchlist), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _ShortcutTile( + key: const Key('mine-history'), + icon: Icons.history_rounded, + label: l10n.watchHistory, + onTap: () => onOpen(MineDestination.history), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _ShortcutTile( + key: const Key('mine-downloads'), + icon: Icons.download_rounded, + label: l10n.downloads, + onTap: () => onOpen(MineDestination.downloads), + ), + ), + ], + ); + } +} + +class _ShortcutTile extends StatelessWidget { + const _ShortcutTile({ + super.key, + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 88, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: AppColors.blue, size: 24), + const SizedBox(height: AppDimens.spaceSm), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.caption, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 金币余额卡。 +class MineWalletCard extends StatelessWidget { + const MineWalletCard({ + super.key, + required this.balance, + required this.onOpen, + required this.onRecharge, + }); + + final String balance; + final VoidCallback onOpen; + final VoidCallback onRecharge; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('mine-wallet'), + padding: const EdgeInsets.all(AppDimens.cardPadding), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.goldCard, AppColors.card], + ), + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Row( + children: [ + const Icon( + Icons.monetization_on_rounded, + color: AppColors.gold, + size: 30, + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: InkWell( + onTap: onOpen, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.coinBalance, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + const SizedBox(height: 3), + Text( + balance, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ), + TextButton( + key: const Key('mine-wallet-recharge'), + onPressed: onRecharge, + style: TextButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + minimumSize: const Size(64, AppDimens.buttonCompact), + shape: const StadiumBorder(), + ), + child: Text(l10n.topUpWallet), + ), + ], + ), + ); + } +} + +/// 会员权益卡。 +class MineVipCard extends StatelessWidget { + const MineVipCard({super.key, required this.onOpen}); + + final VoidCallback onOpen; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('mine-vip'), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.all(AppDimens.cardPadding), + child: Row( + children: [ + Container( + width: AppDimens.buttonLarge, + height: AppDimens.buttonLarge, + decoration: const BoxDecoration( + color: AppColors.badgePink, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.workspace_premium_rounded, + color: AppColors.badgePinkText, + size: 22, + ), + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.vipMembership, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + l10n.vipUnlockMessage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), + Text( + l10n.joinVip, + style: const TextStyle( + color: AppColors.blue, + fontSize: AppDimens.caption, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 我的页功能菜单。 +class MineMenuList extends StatelessWidget { + const MineMenuList({super.key, required this.onOpen}); - final String walletValue; final ValueChanged onOpen; @override @@ -20,94 +274,85 @@ class MineMenu extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Container( key: const Key('mine-menu'), - padding: const EdgeInsets.symmetric(vertical: 4), decoration: BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _row( + key: const Key('mine-works'), + icon: Icons.video_collection_outlined, + title: l10n.myWorks, + destination: MineDestination.works, + ), + _divider(), + _row( + key: const Key('mine-creator-center'), + icon: Icons.auto_awesome_rounded, + title: l10n.creatorCenter, + destination: MineDestination.creatorCenter, + ), + _divider(), + _row( + key: const Key('mine-notices'), + icon: Icons.notifications_none_rounded, + title: l10n.notices, + destination: MineDestination.notices, + ), + _divider(), + _row( + key: const Key('mine-settings'), + icon: Icons.settings_outlined, + title: l10n.settings, + destination: MineDestination.settings, + ), + _divider(), + _row( + key: const Key('mine-help'), + icon: Icons.help_outline_rounded, + title: l10n.helpFeedback, + destination: MineDestination.help, + ), + ], + ), ), - child: Column(children: [..._libraryTiles(l10n), ..._accountTiles(l10n)]), ); } - List _libraryTiles(AppLocalizations l10n) { - return [ - _tile( - key: const Key('mine-watchlist'), - icon: Icons.bookmark_border_rounded, - title: l10n.watchlist, - onTap: () => onOpen(MineDestination.watchlist), - ), - _tile( - key: const Key('mine-history'), - icon: Icons.history_rounded, - title: l10n.watchHistory, - onTap: () => onOpen(MineDestination.history), - ), - _tile( - key: const Key('mine-downloads'), - icon: Icons.download_outlined, - title: l10n.downloads, - onTap: () => onOpen(MineDestination.downloads), - ), - ]; - } - - List _accountTiles(AppLocalizations l10n) { - return [ - _tile( - key: const Key('mine-wallet'), - icon: Icons.monetization_on_outlined, - title: l10n.wallet, - subtitle: walletValue, - onTap: () => onOpen(MineDestination.wallet), - ), - _tile( - key: const Key('mine-vip'), - icon: Icons.workspace_premium_outlined, - title: l10n.vipMembership, - onTap: () => onOpen(MineDestination.vip), - ), - _tile( - key: const Key('mine-settings'), - icon: Icons.settings_outlined, - title: l10n.settings, - onTap: () => onOpen(MineDestination.settings), - ), - ]; - } - - Widget _tile({ + Widget _row({ required Key key, required IconData icon, required String title, - required VoidCallback onTap, - String? subtitle, + required MineDestination destination, }) { - return Material( - color: Colors.transparent, - child: ListTile( - key: key, - dense: true, - minTileHeight: 48, - contentPadding: const EdgeInsets.symmetric(horizontal: 14), - leading: Icon(icon, color: AppColors.blueSoft, size: 19), - title: Text( - title, - style: const TextStyle(color: AppColors.onSurface, fontSize: 12), + return ListTile( + key: key, + minTileHeight: 48, + contentPadding: const EdgeInsets.symmetric(horizontal: 14), + leading: Icon(icon, color: AppColors.blue, size: 20), + title: Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.body, ), - subtitle: subtitle == null - ? null - : Text( - subtitle, - style: const TextStyle(color: AppColors.muted, fontSize: 9), - ), - trailing: const Icon( - Icons.chevron_right_rounded, - color: AppColors.subtle, - size: 18, - ), - onTap: onTap, ), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 18, + ), + onTap: () => onOpen(destination), ); } + + Widget _divider() { + return const Divider(height: 1, indent: 14, endIndent: 14); + } } diff --git a/test/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart index 305292c..11d17b6 100644 --- a/test/account/mine_account_entry_test.dart +++ b/test/account/mine_account_entry_test.dart @@ -26,6 +26,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('mine-settings'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); @@ -49,6 +51,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('mine-settings'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 0ca92e9..ede4e89 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -40,6 +40,13 @@ void main() { expect(find.byKey(const Key('create-page')), findsOneWidget); expect(find.text('Create'), findsWidgets); expect(find.text('Stories become short dramas'), findsOneWidget); + expect(find.byKey(const Key('create-ai')), findsOneWidget); + expect(find.byKey(const Key('create-upload')), findsOneWidget); + + await tester.tap(find.byKey(const Key('create-ai'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('create-placeholder-page')), findsOneWidget); + expect(find.text('Coming soon'), findsOneWidget); }); testWidgets('should_switch_home_channel_when_chip_is_tapped', (tester) async { diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index ddafc78..4e7047d 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; -import 'package:fruitshorts/core/data/models/watch_progress.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'; @@ -18,7 +17,7 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_only_continue_and_six_core_entries', ( + testWidgets('should_render_minimal_profile_shortcuts_wallet_vip_and_menu', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -34,16 +33,7 @@ void main() { playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: FakeCategoryRepository(catalog: catalog), - watchHistoryRepository: InMemoryWatchHistoryRepository( - seed: const [ - WatchProgress( - dramaId: 'city-lights-demo', - episodeId: 'city-lights-demo-1', - position: Duration(minutes: 2), - duration: Duration(minutes: 5), - ), - ], - ), + watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), @@ -57,15 +47,20 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-user-row')), findsOneWidget); - expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); - expect(find.byKey(const Key('mine-continue-play')), findsOneWidget); + expect(find.byKey(const Key('mine-continue-watching')), findsNothing); for (final key in const [ Key('mine-watchlist'), Key('mine-history'), Key('mine-downloads'), Key('mine-wallet'), + Key('mine-wallet-recharge'), Key('mine-vip'), + Key('mine-menu'), + Key('mine-works'), + Key('mine-creator-center'), + Key('mine-notices'), Key('mine-settings'), + Key('mine-help'), ]) { expect(find.byKey(key), findsOneWidget); } diff --git a/test/mine/settings_page_test.dart b/test/mine/settings_page_test.dart index def8456..7280809 100644 --- a/test/mine/settings_page_test.dart +++ b/test/mine/settings_page_test.dart @@ -46,6 +46,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('mine-settings'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index b413c55..9fdbe56 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -46,11 +46,9 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mine-settings'))); + await tester.ensureVisible(find.byKey(const Key('mine-notices'))); await tester.pumpAndSettle(); - - expect(find.byKey(const Key('settings-notices')), findsOneWidget); - await tester.tap(find.byKey(const Key('settings-notices'))); + await tester.tap(find.byKey(const Key('mine-notices'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('notices-page')), findsOneWidget); From 9a4269b847e68c01dca4d699bfd88741e5672500 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 10:41:56 +0800 Subject: [PATCH 024/124] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E6=92=AD=E6=94=BE=E9=A1=B5=E4=B8=8E=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 14 +- lib/l10n/app_id.arb | 14 +- lib/l10n/app_localizations.dart | 60 ++ lib/l10n/app_localizations_en.dart | 34 + lib/l10n/app_localizations_id.dart | 35 + lib/l10n/app_localizations_zh.dart | 34 + lib/l10n/app_zh.arb | 14 +- lib/pages/player/player_logic.dart | 112 +-- lib/pages/player/player_view.dart | 881 ++---------------- lib/pages/player/player_view_actions.dart | 162 ++++ .../player/widgets/drama_comments_sheet.dart | 62 +- .../player/widgets/episode_picker_cell.dart | 29 +- .../player/widgets/episode_picker_sheet.dart | 131 +++ .../widgets/player_featured_comment.dart | 158 ---- .../player/widgets/player_more_sheet.dart | 228 +++++ lib/pages/player/widgets/player_overlay.dart | 416 +++++++++ .../player/widgets/player_overlay_models.dart | 117 +++ .../player/widgets/player_overlay_parts.dart | 243 +++++ .../player/widgets/player_speed_sheet.dart | 75 ++ .../widgets/player_text_size_sheet.dart | 110 +++ .../player/widgets/player_unlock_sheet.dart | 373 ++++++++ test/comment/drama_comments_sheet_test.dart | 2 +- test/player/player_fullscreen_test.dart | 166 +++- test/widget_test.dart | 26 +- 24 files changed, 2395 insertions(+), 1101 deletions(-) create mode 100644 lib/pages/player/player_view_actions.dart create mode 100644 lib/pages/player/widgets/episode_picker_sheet.dart delete mode 100644 lib/pages/player/widgets/player_featured_comment.dart create mode 100644 lib/pages/player/widgets/player_more_sheet.dart create mode 100644 lib/pages/player/widgets/player_overlay.dart create mode 100644 lib/pages/player/widgets/player_overlay_models.dart create mode 100644 lib/pages/player/widgets/player_overlay_parts.dart create mode 100644 lib/pages/player/widgets/player_speed_sheet.dart create mode 100644 lib/pages/player/widgets/player_text_size_sheet.dart create mode 100644 lib/pages/player/widgets/player_unlock_sheet.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dfa073d..6913b35 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -578,5 +578,17 @@ "qualityAutoHint": "HLS adjusts the bitrate automatically for your connection.", "searchFailed": "Search failed. Please try again.", "searchResultCount": "{count} results", - "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } + "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, + "episodeCountLabel": "{count} episodes", + "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, + "commentsWithCount": "Comments ({count})", + "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, + "textSize": "Text size", + "textSizeSmall": "Small", + "textSizeStandard": "Standard", + "textSizeLarge": "Large", + "watchAdToUnlock": "Watch an ad to unlock", + "unlockWithVip": "Unlock with VIP", + "unlockMethodsVary": "Available unlock methods vary by title.", + "unlockEpisode": "Unlock this episode" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 26d598b..586de55 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -576,5 +576,17 @@ "qualityAutoHint": "HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.", "searchFailed": "Pencarian gagal. Coba lagi.", "searchResultCount": "{count} hasil", - "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } + "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, + "episodeCountLabel": "{count} episode", + "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, + "commentsWithCount": "Komentar ({count})", + "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, + "textSize": "Ukuran teks", + "textSizeSmall": "Kecil", + "textSizeStandard": "Standar", + "textSizeLarge": "Besar", + "watchAdToUnlock": "Tonton iklan untuk membuka", + "unlockWithVip": "Buka dengan VIP", + "unlockMethodsVary": "Metode pembukaan tersedia dapat berbeda per judul.", + "unlockEpisode": "Buka episode ini" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2e4be26..b861cd5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2571,6 +2571,66 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'{count} results'** String searchResultCount(int count); + + /// No description provided for @episodeCountLabel. + /// + /// In en, this message translates to: + /// **'{count} episodes'** + String episodeCountLabel(int count); + + /// No description provided for @commentsWithCount. + /// + /// In en, this message translates to: + /// **'Comments ({count})'** + String commentsWithCount(int count); + + /// No description provided for @textSize. + /// + /// In en, this message translates to: + /// **'Text size'** + String get textSize; + + /// No description provided for @textSizeSmall. + /// + /// In en, this message translates to: + /// **'Small'** + String get textSizeSmall; + + /// No description provided for @textSizeStandard. + /// + /// In en, this message translates to: + /// **'Standard'** + String get textSizeStandard; + + /// No description provided for @textSizeLarge. + /// + /// In en, this message translates to: + /// **'Large'** + String get textSizeLarge; + + /// No description provided for @watchAdToUnlock. + /// + /// In en, this message translates to: + /// **'Watch an ad to unlock'** + String get watchAdToUnlock; + + /// No description provided for @unlockWithVip. + /// + /// In en, this message translates to: + /// **'Unlock with VIP'** + String get unlockWithVip; + + /// No description provided for @unlockMethodsVary. + /// + /// In en, this message translates to: + /// **'Available unlock methods vary by title.'** + String get unlockMethodsVary; + + /// No description provided for @unlockEpisode. + /// + /// In en, this message translates to: + /// **'Unlock this episode'** + String get unlockEpisode; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5265724..a16aca9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1330,4 +1330,38 @@ class AppLocalizationsEn extends AppLocalizations { String searchResultCount(int count) { return '$count results'; } + + @override + String episodeCountLabel(int count) { + return '$count episodes'; + } + + @override + String commentsWithCount(int count) { + return 'Comments ($count)'; + } + + @override + String get textSize => 'Text size'; + + @override + String get textSizeSmall => 'Small'; + + @override + String get textSizeStandard => 'Standard'; + + @override + String get textSizeLarge => 'Large'; + + @override + String get watchAdToUnlock => 'Watch an ad to unlock'; + + @override + String get unlockWithVip => 'Unlock with VIP'; + + @override + String get unlockMethodsVary => 'Available unlock methods vary by title.'; + + @override + String get unlockEpisode => 'Unlock this episode'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index e56a798..690217a 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1329,4 +1329,39 @@ class AppLocalizationsId extends AppLocalizations { String searchResultCount(int count) { return '$count hasil'; } + + @override + String episodeCountLabel(int count) { + return '$count episode'; + } + + @override + String commentsWithCount(int count) { + return 'Komentar ($count)'; + } + + @override + String get textSize => 'Ukuran teks'; + + @override + String get textSizeSmall => 'Kecil'; + + @override + String get textSizeStandard => 'Standar'; + + @override + String get textSizeLarge => 'Besar'; + + @override + String get watchAdToUnlock => 'Tonton iklan untuk membuka'; + + @override + String get unlockWithVip => 'Buka dengan VIP'; + + @override + String get unlockMethodsVary => + 'Metode pembukaan tersedia dapat berbeda per judul.'; + + @override + String get unlockEpisode => 'Buka episode ini'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f7ee1e3..a487f6d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1306,4 +1306,38 @@ class AppLocalizationsZh extends AppLocalizations { String searchResultCount(int count) { return '$count 个结果'; } + + @override + String episodeCountLabel(int count) { + return '共 $count 集'; + } + + @override + String commentsWithCount(int count) { + return '评论($count)'; + } + + @override + String get textSize => '文字大小'; + + @override + String get textSizeSmall => '小'; + + @override + String get textSizeStandard => '标准'; + + @override + String get textSizeLarge => '大'; + + @override + String get watchAdToUnlock => '看广告解锁'; + + @override + String get unlockWithVip => '会员解锁'; + + @override + String get unlockMethodsVary => '具体解锁方式以作品而定。'; + + @override + String get unlockEpisode => '解锁本集'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 687245a..f8be167 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -538,5 +538,17 @@ "qualityAutoHint": "HLS 会根据当前网络自动切换码率。", "searchFailed": "搜索失败,请重试。", "searchResultCount": "{count} 个结果", - "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } + "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, + "episodeCountLabel": "共 {count} 集", + "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, + "commentsWithCount": "评论({count})", + "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, + "textSize": "文字大小", + "textSizeSmall": "小", + "textSizeStandard": "标准", + "textSizeLarge": "大", + "watchAdToUnlock": "看广告解锁", + "unlockWithVip": "会员解锁", + "unlockMethodsVary": "具体解锁方式以作品而定。", + "unlockEpisode": "解锁本集" } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 14f3bc0..c9c94aa 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -21,7 +21,6 @@ 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'; import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; @@ -30,9 +29,7 @@ import '../mine/mine_types.dart'; import '../mine/mine_view.dart'; import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; -import 'widgets/episode_picker_cell.dart'; - -const double _episodeCellAspect = 1.05; +import 'widgets/episode_picker_sheet.dart'; class PlayerLogic extends ChangeNotifier { PlayerLogic({ @@ -628,12 +625,10 @@ class PlayerLogic extends ChangeNotifier { } Future showEpisodes(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; final entry = queue.entryAt(session.currentIndex); final drama = await _withFullEpisodes(context, entry.drama); if (!context.mounted) return; final homeFeed = feedDramas != null; - final episodes = drama.episodes; final currentNumber = entry.episode.number; session.setOverlayOpen(true); await session.playbackSettled; @@ -644,62 +639,13 @@ class PlayerLogic extends ChangeNotifier { try { await showModalBottomSheet( context: context, - backgroundColor: AppColors.ink, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: SizedBox( - height: MediaQuery.sizeOf(sheetContext).height * 0.46, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 4), - child: Text( - l10n.episodes, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), - child: Text( - localizedDramaTitle(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - ), - ), - Expanded( - child: GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 6, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: _episodeCellAspect, - ), - itemCount: episodes.length, - itemBuilder: (_, index) => _episodeCell( - context, - sheetContext, - drama, - episodes[index], - index, - homeFeed, - currentNumber, - ), - ), - ), - ], - ), - ), + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => EpisodePickerSheet( + drama: drama, + currentNumber: currentNumber, + onSelect: (index) => + _selectFromSheet(context, drama, index, homeFeed, currentNumber), ), ); } finally { @@ -724,37 +670,25 @@ class PlayerLogic extends ChangeNotifier { } } - Widget _episodeCell( + void _selectFromSheet( BuildContext pageContext, - BuildContext sheetContext, Drama drama, - Episode episode, int index, bool homeFeed, int currentNumber, ) { - final selected = episode.number == currentNumber; - final l10n = AppLocalizations.of(pageContext)!; - return EpisodePickerCell( - episode: episode, - selected: selected, - lockLabel: l10n.locked, - onTap: () { - Navigator.of(sheetContext).pop(); - if (homeFeed) { - if (episode.number == currentNumber) return; - unawaited(openSeriesAt(pageContext, drama, index)); - return; - } - if (index == session.currentIndex) return; - unawaited( - pages.animateToPage( - index, - duration: const Duration(milliseconds: 260), - curve: Curves.easeOutCubic, - ), - ); - }, + if (homeFeed) { + if (drama.episodes[index].number == currentNumber) return; + unawaited(openSeriesAt(pageContext, drama, index)); + return; + } + if (index == session.currentIndex) return; + unawaited( + pages.animateToPage( + index, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ), ); } @@ -839,8 +773,8 @@ class PlayerLogic extends ChangeNotifier { await showModalBottomSheet( context: context, isScrollControlled: true, - backgroundColor: AppColors.ink, - showDragHandle: true, + backgroundColor: Colors.transparent, + showDragHandle: false, builder: (_) => DramaCommentsSheet(drama: drama), ); } finally { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 95935ea..4ae4cc9 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -8,7 +8,6 @@ import 'package:provider/provider.dart'; 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'; @@ -16,24 +15,31 @@ 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'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; +import '../interaction/widgets/report_dialog.dart'; import 'player_binding.dart'; import 'player_logic.dart'; import 'widgets/home_feed_overlay.dart'; import 'widgets/exit_clear_screen_button.dart'; import 'widgets/player_center_toggle.dart'; +import 'widgets/player_more_sheet.dart'; +import 'widgets/player_overlay.dart'; +import 'widgets/player_overlay_models.dart'; +import 'widgets/player_speed_sheet.dart'; +import 'widgets/player_text_size_sheet.dart'; +import 'widgets/player_unlock_sheet.dart'; import 'widgets/quality_sheet.dart'; -import 'widgets/player_featured_comment.dart'; -import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; +part 'player_view_actions.dart'; + class PlayerPage extends StatefulWidget { const PlayerPage({ super.key, @@ -80,6 +86,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; bool _chromeVisible = true; double _playbackSpeed = 1.0; + double _textScale = playerTextSizeStandard; @override void initState() { @@ -117,6 +124,14 @@ class _PlayerPageState extends State with WidgetsBindingObserver { super.dispose(); } + void _updatePlaybackSpeed(double value) { + setState(() => _playbackSpeed = value); + } + + void _updateTextScale(double value) { + setState(() => _textScale = value); + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -198,7 +213,16 @@ class _PlayerPageState extends State with WidgetsBindingObserver { return Stack( fit: StackFit.expand, children: [ - const ColoredBox(color: AppColors.ink), + ColoredBox( + color: AppColors.onSurface, + child: widget.homeMode + ? null + : RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + ), ?video, if (!logic.chromeHidden && (!widget.homeMode || _chromeVisible)) IgnorePointer( @@ -287,19 +311,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), - // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 - if (selected && !ready && logic.blockedEpisodeId == episode.id) - _BlockedNotice( - l10n: l10n, - 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 && !ready && @@ -384,72 +395,57 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ) : const SizedBox.shrink() else - _PlaybackOverlay( - l10n: l10n, - drama: drama, - 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 - ? null - : () => unawaited(logic.leave(context)), - onEpisodes: () => logic.showEpisodes(context), - onNext: logic.hasNextEpisode - ? () => unawaited(logic.playNextEpisode()) - : null, - playing: logic.session.userWantsPlay, - playbackSpeed: _playbackSpeed, - onSpeedTap: () => unawaited(_openSpeedSheet(context)), - qualities: logic.qualities, - onQualityTap: () => unawaited(_openQualitySheet(context)), + PlayerPlaybackOverlay( + data: PlayerOverlayData( + l10n: l10n, + drama: drama, + episode: episode, + controller: ready ? videoPort!.controller : null, + liked: interactions.isLiked(drama.id), + likeCount: interactions.likeCount(drama.id), + saved: context.watch().isSaved(drama.id), + playing: logic.session.userWantsPlay, + textScale: _textScale, + currentIndex: logic.session.currentIndex, + totalEpisodes: logic.visibleIndices.length, + ), + actions: PlayerOverlayActions( + onBack: widget.embedded + ? null + : () => unawaited(logic.leave(context)), + onTogglePlay: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + onClear: () => setState(() => _chromeVisible = false), + onExitClear: () => setState(() => _chromeVisible = true), + onEpisodes: () => logic.showEpisodes(context), + onNext: logic.hasNextEpisode + ? () => unawaited(logic.playNextEpisode()) + : null, + onComments: () => logic.showComments(context, drama), + onLike: () => + unawaited(_toggleLike(context, interactions, drama)), + onSave: () => unawaited(_toggleSave(context, drama)), + onShare: () => logic.shareDrama(context, drama), + onMore: () => _openMore(context, drama, episode), + onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onSeek: ready + ? (position) => logic.seekTo(context, videoPort!, position) + : null, + ), controlsVisible: _chromeVisible, - onClear: () => setState(() => _chromeVisible = false), - onExitClear: () => setState(() => _chromeVisible = true), - onTogglePlay: () => - logic.session.setUserWantsPlay(!logic.session.userWantsPlay), - // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览) - onLike: () async { - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final result = await interactions.toggleDramaLike(drama.id); - if (!result.isSuccess && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.errorMessage ?? - AppLocalizations.of(context)!.likeFailed, - ), - ), - ); - } - } - }, - onSave: () async { - final favorites = context.read(); - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final ok = await favorites.toggle(drama.id, card: drama); - if (ok || !context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.favoriteToggleFailed)), - ); - } - }, - onDownload: () => requestEpisodeDownload(context, episode.id), - onComments: () => logic.showComments(context, drama), - onShare: () => logic.shareDrama(context, drama), - onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(logic.enterPip(context)) - : null, - onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, - onSeek: ready - ? (position) => logic.seekTo(context, videoPort!, position) - : null, + ), + // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 + if (selected && !ready && logic.blockedEpisodeId == episode.id) + PlayerBlockedNotice( + l10n: l10n, + episode: episode, + accessType: logic.blockedTicket?.accessType, + unlockCoin: logic.blockedTicket?.unlockCoin ?? 0, + unlocking: logic.unlockingEpisodeId == episode.id, + error: logic.unlockError, + onOpenUnlock: logic.wallet == null + ? null + : () => _openUnlockSheet(context, index, episode), ), if (widget.homeMode && !_chromeVisible) Positioned.fill( @@ -490,723 +486,4 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ], ); } - - Future _openSpeedSheet(BuildContext context) async { - final selected = await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - builder: (_) => _PlaybackSpeedSheet(selected: _playbackSpeed), - ); - if (selected == null || !mounted) return; - setState(() => _playbackSpeed = selected); - await logic.setPlaybackSpeed(selected); - } - - Future _openQualitySheet(BuildContext context) async { - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - builder: (_) => QualitySheet(qualities: logic.qualities), - ); - } - - Future _unlockEpisode(BuildContext context, int index) async { - if (!await ensureSignedIn(context)) return; - if (!context.mounted) return; - 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),前端不自行推断。 -class _BlockedNotice extends StatelessWidget { - const _BlockedNotice({ - required this.l10n, - 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) { - final needsUnlock = - accessType == PlayTicket.coinRequired || - accessType == PlayTicket.vipRequired; - final title = needsUnlock - ? l10n.lockedTitle(episode.number) - : accessType == PlayTicket.noMedia - ? l10n.noMediaNotice - : episode.locked - ? l10n.lockedTitle(episode.number) - : l10n.noMediaNotice; - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: _UnlockCard( - locked: needsUnlock, - title: title, - coinLabel: needsUnlock && unlockCoin > 0 - ? '$unlockCoin ${l10n.coins}' - : null, - footnote: needsUnlock && error != null - ? errorText(l10n, error, l10n.unlockFailed) - : null, - actionLabel: needsUnlock ? l10n.unlockWithCoins : null, - busy: unlocking, - onAction: needsUnlock ? onUnlock : null, - ), - ), - ); - } -} - -/// Coin lock presented as a reward card: gold chip, chili only on the icon. -class _UnlockCard extends StatelessWidget { - const _UnlockCard({ - required this.locked, - 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) { - return DecoratedBox( - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.gold.withValues(alpha: 0.55)), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - locked ? Icons.lock_outline_rounded : Icons.movie_filter_outlined, - color: locked ? AppColors.gold : AppColors.cream, - size: 32, - ), - const SizedBox(height: 12), - Text( - title, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.cream, - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - if (coinLabel != null) ...[ - const SizedBox(height: 12), - DecoratedBox( - decoration: BoxDecoration( - color: AppColors.gold, - borderRadius: BorderRadius.circular(20), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - child: Text( - coinLabel!, - style: const TextStyle( - color: AppColors.onGold, - fontSize: 13, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - ], - if (footnote != null) ...[ - const SizedBox(height: 8), - Text( - footnote!, - textAlign: TextAlign.center, - 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), - ), - ), - ), - ], - ], - ), - ), - ); - } -} - -class _PlaybackOverlay extends StatefulWidget { - const _PlaybackOverlay({ - required this.l10n, - required this.drama, - required this.episode, - required this.controller, - required this.liked, - this.likeCount, - required this.saved, - required this.playing, - required this.playbackSpeed, - required this.onSpeedTap, - required this.qualities, - required this.onQualityTap, - required this.controlsVisible, - required this.onTogglePlay, - required this.onClear, - required this.onExitClear, - required this.onBack, - required this.onEpisodes, - required this.onComments, - required this.onLike, - required this.onSave, - required this.onDownload, - required this.onShare, - required this.onPip, - required this.onScrubStart, - required this.onSeek, - this.onNext, - }); - - final AppLocalizations l10n; - final Drama drama; - final Episode episode; - final VideoPlayerController? controller; - final bool liked; - final int? likeCount; - final bool saved; - final bool playing; - final double playbackSpeed; - final VoidCallback onSpeedTap; - final List qualities; - final VoidCallback onQualityTap; - final bool controlsVisible; - final VoidCallback onTogglePlay; - final VoidCallback onClear; - final VoidCallback onExitClear; - final VoidCallback? onBack; - final VoidCallback onEpisodes; - final VoidCallback onComments; - final VoidCallback? onNext; - final VoidCallback onLike; - final VoidCallback onSave; - final VoidCallback onDownload; - final VoidCallback onShare; - final VoidCallback? onPip; - final VoidCallback? onScrubStart; - final Future Function(Duration)? onSeek; - - @override - State<_PlaybackOverlay> createState() => _PlaybackOverlayState(); -} - -class _PlaybackOverlayState extends State<_PlaybackOverlay> { - @override - Widget build(BuildContext context) { - return SafeArea( - child: Stack( - children: [ - Positioned.fill( - child: GestureDetector( - key: const Key('player-video-tap'), - behavior: HitTestBehavior.opaque, - onTap: widget.onTogglePlay, - ), - ), - if (widget.controlsVisible) - Column( - key: const Key('player-controls-visible'), - children: [ - _topBar(context), - const Spacer(), - Padding( - key: const Key('player-controls-bottom'), - padding: const EdgeInsets.fromLTRB(14, 8, 14, 12), - child: Column( - children: [ - SeekProgressBar( - key: const Key('player-progress'), - valueListenable: widget.controller, - semanticsLabel: widget.l10n.seekVideo, - showTimes: true, - onScrubStart: widget.onScrubStart, - onSeek: widget.onSeek, - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: _PlayerAction( - key: const Key('player-episodes'), - icon: Icons.view_list_rounded, - label: widget.l10n.episodes, - onTap: widget.onEpisodes, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _PlayerAction( - key: const Key('player-comments'), - icon: Icons.chat_bubble_outline_rounded, - label: widget.l10n.comments, - onTap: widget.onComments, - ), - ), - const SizedBox(width: 8), - Expanded( - child: SizedBox( - height: 32, - child: FilledButton( - key: const Key('player-next'), - onPressed: widget.onNext, - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - disabledBackgroundColor: AppColors.raised, - disabledForegroundColor: AppColors.subtle, - foregroundColor: AppColors.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(9), - ), - padding: EdgeInsets.zero, - textStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Text( - widget.l10n.nextEpisode, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 2), - const Icon( - Icons.skip_next_rounded, - size: 14, - ), - ], - ), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - PlayerFeaturedComment( - drama: widget.drama, - onViewAll: widget.onComments, - ), - ], - ), - ), - ], - ), - if (!widget.controlsVisible) - Positioned( - top: 8, - right: 12, - child: ExitClearScreenButton( - key: const Key('player-exit-clear-screen'), - tooltip: widget.l10n.exitClearScreen, - onPressed: widget.onExitClear, - ), - ), - if (!widget.playing) - Center( - child: PlayerCenterToggle( - key: const Key('player-play-toggle'), - playing: false, - onTap: widget.onTogglePlay, - ), - ), - ], - ), - ); - } - - Widget _topBar(BuildContext context) { - return SizedBox( - key: const Key('player-controls-top'), - height: 46, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - children: [ - if (widget.onBack != null) - _TopAction( - key: const Key('player-back'), - tooltip: widget.l10n.backToSeries, - icon: Icons.chevron_left_rounded, - onPressed: widget.onBack!, - ) - else - const SizedBox(width: 36), - const SizedBox(width: 8), - Expanded( - child: Text( - '${localizedDramaTitle(widget.l10n, widget.drama)} · ' - '${widget.l10n.episodeLabel(widget.episode.number)}', - key: const Key('player-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: 8), - _TopAction( - key: const Key('player-clear-screen'), - tooltip: widget.l10n.clearScreen, - icon: Icons.fullscreen_rounded, - onPressed: widget.onClear, - ), - const SizedBox(width: 8), - _TopAction( - key: const Key('player-more'), - tooltip: widget.l10n.more, - icon: Icons.more_horiz_rounded, - onPressed: () => _openMore(context), - ), - ], - ), - ), - ); - } - - Future _openMore(BuildContext context) async { - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - key: const Key('player-more-speed'), - leading: const Icon( - Icons.speed_rounded, - color: AppColors.secondary, - ), - title: Text(widget.l10n.playbackSpeed), - trailing: Text( - _speedLabel(widget.playbackSpeed), - style: const TextStyle( - color: AppColors.blue, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - onTap: () { - Navigator.pop(sheetContext); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onSpeedTap(); - }); - }, - ), - if (widget.qualities.isNotEmpty) - ListTile( - key: const Key('player-more-quality'), - leading: const Icon( - Icons.high_quality_rounded, - color: AppColors.secondary, - ), - title: Text(widget.l10n.playbackQuality), - trailing: Text( - widget.qualities.contains(QualitySheet.autoQuality) - ? widget.l10n.qualityAuto - : widget.qualities.first, - style: const TextStyle( - color: AppColors.blue, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - onTap: () { - Navigator.pop(sheetContext); - widget.onQualityTap(); - }, - ), - ListTile( - key: const Key('player-more-download'), - leading: const Icon( - Icons.download_outlined, - color: AppColors.secondary, - ), - title: Text(widget.l10n.downloads), - onTap: () { - Navigator.pop(sheetContext); - widget.onDownload(); - }, - ), - if (widget.onPip != null) - ListTile( - key: const Key('player-more-pip'), - leading: const Icon( - Icons.picture_in_picture_alt_outlined, - color: AppColors.secondary, - ), - title: Text(widget.l10n.pictureInPicture), - onTap: () { - Navigator.pop(sheetContext); - widget.onPip!(); - }, - ), - ListTile( - key: const Key('player-more-like'), - leading: Icon( - widget.liked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - 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(); - }, - ), - ListTile( - key: const Key('player-more-favorite'), - leading: Icon( - widget.saved ? Icons.star_rounded : Icons.star_border_rounded, - color: widget.saved ? AppColors.blue : AppColors.muted, - ), - title: Text(widget.l10n.favorite), - onTap: () { - Navigator.pop(sheetContext); - widget.onSave(); - }, - ), - ListTile( - key: const Key('player-more-share'), - leading: const Icon( - Icons.share_outlined, - color: AppColors.muted, - ), - title: Text(widget.l10n.share), - onTap: () { - Navigator.pop(sheetContext); - widget.onShare(); - }, - ), - ], - ), - ), - ), - ); - } -} - -const List _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; - -String _speedLabel(double value) => '${value.toString()}x'; - -class _PlaybackSpeedSheet extends StatelessWidget { - const _PlaybackSpeedSheet({required this.selected}); - - final double selected; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return SafeArea( - child: Column( - key: const Key('player-speed-sheet'), - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - l10n.playbackSpeed, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - for (final value in _speedOptions) - ListTile( - key: Key('player-speed-${value.toString()}'), - dense: true, - tileColor: value == selected - ? AppColors.blue.withValues(alpha: 0.12) - : null, - title: Text( - _speedLabel(value), - style: TextStyle( - color: value == selected ? AppColors.blue : AppColors.cream, - fontSize: 13, - fontWeight: value == selected - ? FontWeight.w700 - : FontWeight.w500, - ), - ), - trailing: value == selected - ? const Icon( - Icons.check_rounded, - color: AppColors.blue, - size: 18, - ) - : null, - onTap: () => Navigator.pop(context, value), - ), - ], - ), - ); - } -} - -class _TopAction extends StatelessWidget { - const _TopAction({ - super.key, - required this.tooltip, - required this.icon, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 36, - height: 36, - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: AppColors.card.withValues(alpha: 0.94), - foregroundColor: AppColors.onSurface, - ), - icon: Icon(icon, size: 18), - ), - ); - } -} - -class _PlayerAction extends StatelessWidget { - const _PlayerAction({ - super.key, - required this.icon, - required this.label, - required this.onTap, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: AppColors.card.withValues(alpha: 0.94), - borderRadius: BorderRadius.circular(9), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: SizedBox( - height: 32, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 14, color: AppColors.secondary), - const SizedBox(width: 3), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ), - ); - } } diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart new file mode 100644 index 0000000..2149186 --- /dev/null +++ b/lib/pages/player/player_view_actions.dart @@ -0,0 +1,162 @@ +// Author: fruit.com.cn + +part of 'player_view.dart'; + +extension _PlayerViewActions on _PlayerPageState { + Future _openSpeedSheet(BuildContext context) async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + showDragHandle: false, + builder: (_) => PlayerSpeedSheet(selected: _playbackSpeed), + ); + if (selected == null || !mounted) return; + _updatePlaybackSpeed(selected); + await logic.setPlaybackSpeed(selected); + } + + Future _openQualitySheet(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (_) => QualitySheet(qualities: logic.qualities), + ); + } + + Future _openMore( + BuildContext context, + Drama drama, + Episode episode, + ) async { + final l10n = AppLocalizations.of(context)!; + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + showDragHandle: false, + isScrollControlled: true, + builder: (_) => PlayerMoreSheet( + l10n: l10n, + state: PlayerMoreState( + speed: _playbackSpeed, + qualities: logic.qualities, + textScale: _textScale, + pipAvailable: logic.pictureInPicture.isPlatformSupported, + ), + actions: PlayerMoreActions( + onSpeed: () => unawaited(_openSpeedSheet(context)), + onQuality: () => unawaited(_openQualitySheet(context)), + onDownload: () => requestEpisodeDownload(context, episode.id), + onTextSize: () => unawaited(_openTextSizeSheet(context)), + onReport: () => unawaited(_reportDrama(context, drama, l10n)), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, + ), + ), + ); + } + + Future _openTextSizeSheet(BuildContext context) async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + showDragHandle: false, + builder: (_) => PlayerTextSizeSheet(selected: _textScale), + ); + if (selected == null || !mounted) return; + _updateTextScale(selected); + } + + Future _openUnlockSheet( + BuildContext context, + int index, + Episode episode, + ) async { + final wallet = logic.wallet; + if (wallet == null) return; + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + showDragHandle: false, + isScrollControlled: true, + builder: (_) => PlayerUnlockSheet( + l10n: AppLocalizations.of(context)!, + episodeNumber: episode.number, + coinCost: logic.blockedTicket?.unlockCoin ?? 0, + balance: wallet.snapshot == null ? null : wallet.balance, + onUnlock: () => _unlockEpisode(context, index), + ), + ); + } + + Future _unlockEpisode(BuildContext context, int index) async { + if (!await ensureSignedIn(context) || !context.mounted) return false; + final ok = await logic.unlockEpisode(index); + if (ok || !context.mounted) return ok; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorText(l10n, logic.unlockError, l10n.unlockFailed)), + ), + ); + return false; + } + + Future _reportDrama( + BuildContext context, + Drama drama, + AppLocalizations l10n, + ) async { + if (!await ensureSignedIn(context) || !context.mounted) return; + final draft = await showReportDialog(context, title: l10n.reportDrama); + if (draft == null || !context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await context.read().reportDrama( + drama.id, + reason: draft.reason, + description: draft.description, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.isSuccess + ? l10n.reportSubmitted + : result.errorMessage ?? l10n.reportFailed, + ), + ), + ); + } + + Future _toggleLike( + BuildContext context, + InteractionController interactions, + Drama drama, + ) async { + if (!await ensureSignedIn(context) || !context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await interactions.toggleDramaLike(drama.id); + if (result.isSuccess || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, + ), + ), + ); + } + + Future _toggleSave(BuildContext context, Drama drama) async { + final favorites = context.read(); + if (!await ensureSignedIn(context) || !context.mounted) return; + unawaited(context.read().lightImpact()); + final ok = await favorites.toggle(drama.id, card: drama); + if (ok || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.favoriteToggleFailed), + ), + ); + } +} diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index d183d1b..b23ee01 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -23,6 +23,7 @@ const double _minSheetHeight = 280; const double _maxSheetHeight = 520; const int _maxCommentLength = 300; const double _composerRadius = 22; +const double _sheetRadius = 20; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 class DramaCommentsSheet extends StatefulWidget { @@ -160,15 +161,24 @@ class _DramaCommentsSheetState extends State { 24; return Padding( padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), - child: SizedBox( - height: available.clamp(_minSheetHeight, _maxSheetHeight), - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), - child: ListenableBuilder( - listenable: _controller, - builder: (context, _) => _content(context), + child: Container( + key: const Key('player-comments-sheet'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical( + top: Radius.circular(_sheetRadius), + ), + ), + child: SizedBox( + height: available.clamp(_minSheetHeight, _maxSheetHeight), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) => _content(context), + ), ), ), ), @@ -182,13 +192,35 @@ class _DramaCommentsSheetState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row( + children: [ + Expanded( + child: Text( + count == null ? l10n.comments : l10n.commentsWithCount(count), + key: count == null ? null : const Key('comment-count-label'), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + key: const Key('player-comments-close'), + tooltip: l10n.close, + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded), + ), + ], + ), Text( - count == null ? l10n.comments : '${l10n.comments} · $count', - key: count == null ? null : const Key('comment-count-label'), + widget.drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.cream, - fontSize: 18, - fontWeight: FontWeight.w700, + color: AppColors.muted, + fontSize: 12, + fontWeight: FontWeight.w500, ), ), const SizedBox(height: 12), @@ -312,7 +344,7 @@ class _DramaCommentsSheetState extends State { maxLength: _maxCommentLength, maxLines: 1, style: const TextStyle(color: AppColors.cream, fontSize: 14), - cursorColor: AppColors.gold, + cursorColor: AppColors.blue, decoration: _composerDecoration(l10n.writeComment), onSubmitted: (_) => _post(), ), diff --git a/lib/pages/player/widgets/episode_picker_cell.dart b/lib/pages/player/widgets/episode_picker_cell.dart index b5069b7..d282460 100644 --- a/lib/pages/player/widgets/episode_picker_cell.dart +++ b/lib/pages/player/widgets/episode_picker_cell.dart @@ -10,7 +10,7 @@ const double _numberSize = 15; const double _lockSize = 16; const double _lockGap = 4; const double _lockWeight = 300; -const double _coverAlpha = 0.45; +const double _coverAlpha = 0.55; /// 选集格子。需要解锁时铺一层透明底,锁在上,集数在锁下面。 class EpisodePickerCell extends StatelessWidget { @@ -38,12 +38,19 @@ class EpisodePickerCell extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - if (!episode.locked) Center(child: _EpisodeNumber(episode.number)), + if (!episode.locked) + Center( + child: _EpisodeNumber( + episode.number, + color: selected ? AppColors.onPrimary : AppColors.cream, + ), + ), if (episode.locked) _LockCover( episodeId: episode.id, number: episode.number, label: lockLabel, + selected: selected, ), ], ), @@ -53,16 +60,17 @@ class EpisodePickerCell extends StatelessWidget { } class _EpisodeNumber extends StatelessWidget { - const _EpisodeNumber(this.number); + const _EpisodeNumber(this.number, {required this.color}); final int number; + final Color color; @override Widget build(BuildContext context) { return Text( '$number', - style: const TextStyle( - color: AppColors.cream, + style: TextStyle( + color: color, fontSize: _numberSize, fontWeight: FontWeight.w600, height: 1, @@ -77,11 +85,13 @@ class _LockCover extends StatelessWidget { required this.episodeId, required this.number, required this.label, + required this.selected, }); final String episodeId; final int number; final String label; + final bool selected; @override Widget build(BuildContext context) { @@ -90,7 +100,7 @@ class _LockCover extends StatelessWidget { children: [ ColoredBox( key: ValueKey('episode-lock-cover-$episodeId'), - color: AppColors.ink.withValues(alpha: _coverAlpha), + color: AppColors.card.withValues(alpha: _coverAlpha), ), Center( child: Column( @@ -102,11 +112,14 @@ class _LockCover extends StatelessWidget { size: _lockSize, weight: _lockWeight, fill: 0, - color: AppColors.gold, + color: AppColors.secondary, semanticLabel: label, ), const SizedBox(height: _lockGap), - _EpisodeNumber(number), + _EpisodeNumber( + number, + color: selected ? AppColors.onPrimary : AppColors.cream, + ), ], ), ), diff --git a/lib/pages/player/widgets/episode_picker_sheet.dart b/lib/pages/player/widgets/episode_picker_sheet.dart new file mode 100644 index 0000000..5d05cf3 --- /dev/null +++ b/lib/pages/player/widgets/episode_picker_sheet.dart @@ -0,0 +1,131 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; +import 'episode_picker_cell.dart'; + +const int _columns = 6; +const double _sheetHeightFactor = 0.52; +const double _cellAspect = 1.05; +const double _sheetRadius = 20; + +/// K28 选集面板:六列等宽网格、当前集高亮、锁定集显示锁标。 +class EpisodePickerSheet extends StatelessWidget { + /// 创建选集面板。 + const EpisodePickerSheet({ + super.key, + required this.drama, + required this.currentNumber, + required this.onSelect, + }); + + /// 当前短剧。 + final Drama drama; + + /// 当前集号。 + final int currentNumber; + + /// 点击某集,参数为该集在原始列表中的索引。 + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final episodes = drama.episodes; + return Container( + key: const Key('player-episodes-sheet'), + height: MediaQuery.sizeOf(context).height * _sheetHeightFactor, + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + ), + child: SafeArea( + top: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + l10n.episodes, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + key: const Key('player-episodes-close'), + tooltip: l10n.close, + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Row( + children: [ + Expanded( + child: Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + l10n.episodeCountLabel(episodes.length), + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + ), + ), + ], + ), + ), + Expanded( + child: GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _columns, + mainAxisSpacing: AppDimens.spaceSm, + crossAxisSpacing: AppDimens.spaceSm, + childAspectRatio: _cellAspect, + ), + itemCount: episodes.length, + itemBuilder: (context, index) { + final episode = episodes[index]; + return EpisodePickerCell( + key: Key('player-episode-${episode.number}'), + episode: episode, + selected: episode.number == currentNumber, + lockLabel: l10n.locked, + onTap: () { + Navigator.pop(context); + onSelect(index); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_featured_comment.dart b/lib/pages/player/widgets/player_featured_comment.dart deleted file mode 100644 index 1f1381f..0000000 --- a/lib/pages/player/widgets/player_featured_comment.dart +++ /dev/null @@ -1,158 +0,0 @@ -// Author: fruit.com.cn - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../core/data/models/comment.dart'; -import '../../../core/data/models/drama.dart'; -import '../../../core/data/repositories/comment_repository.dart'; -import '../../../core/data/repositories/interaction_repository.dart'; -import '../../../core/data/services/comment_controller.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../l10n/app_localizations.dart'; - -class PlayerFeaturedComment extends StatefulWidget { - const PlayerFeaturedComment({ - super.key, - required this.drama, - required this.onViewAll, - }); - - final Drama drama; - final VoidCallback onViewAll; - - @override - State createState() => _PlayerFeaturedCommentState(); -} - -class _PlayerFeaturedCommentState extends State { - late final CommentController _controller; - - @override - void initState() { - super.initState(); - _controller = CommentController( - comments: context.read(), - interactions: context.read(), - dramaId: widget.drama.id, - ); - unawaited(_controller.load()); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: _controller, - builder: (context, _) { - if (_controller.comments.isEmpty) return const SizedBox.shrink(); - return _card(context, _controller.comments.first); - }, - ); - } - - Widget _card(BuildContext context, DramaComment comment) { - final l10n = AppLocalizations.of(context)!; - final time = _time(l10n, comment.createdAt); - return InkWell( - key: const Key('player-featured-comment'), - onTap: widget.onViewAll, - borderRadius: BorderRadius.circular(10), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(9), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 18, - height: 18, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.secondary.withValues(alpha: 0.2), - ), - child: Text( - _initial(comment.nickname), - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 8, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: 6), - Expanded( - child: Row( - children: [ - Flexible( - child: Text( - comment.nickname.isEmpty - ? l10n.localViewer - : comment.nickname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 10, - fontWeight: FontWeight.w700, - ), - ), - ), - if (time != null) ...[ - const SizedBox(width: 4), - Text( - '· $time', - style: const TextStyle( - color: AppColors.muted, - fontSize: 9, - ), - ), - ], - ], - ), - ), - ], - ), - const SizedBox(height: 5), - Text( - comment.content, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 10, - height: 1.5, - ), - ), - ], - ), - ), - ); - } - - String? _time(AppLocalizations l10n, DateTime? createdAt) { - if (createdAt == null) return null; - final diff = DateTime.now().difference(createdAt); - if (diff.inMinutes < 1) return null; - return l10n.minutesAgo(diff.inMinutes); - } - - String _initial(String nickname) { - final trimmed = nickname.trim(); - return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); - } -} diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart new file mode 100644 index 0000000..70140d6 --- /dev/null +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -0,0 +1,228 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; +import 'player_text_size_sheet.dart'; +import 'quality_sheet.dart'; + +const double _sheetRadius = 20; + +/// 更多面板运行状态。 +class PlayerMoreState { + /// 创建更多面板状态。 + const PlayerMoreState({ + required this.speed, + required this.qualities, + required this.textScale, + required this.pipAvailable, + }); + + /// 当前倍速。 + final double speed; + + /// 后端下发的清晰度档位。 + final List qualities; + + /// 当前信息文字缩放比例。 + final double textScale; + + /// 设备是否提供画中画入口。 + final bool pipAvailable; +} + +/// 更多面板动作集合。 +class PlayerMoreActions { + /// 创建动作集合。 + const PlayerMoreActions({ + required this.onSpeed, + required this.onQuality, + required this.onDownload, + required this.onTextSize, + required this.onReport, + this.onPip, + }); + + /// 打开倍速面板。 + final VoidCallback onSpeed; + + /// 打开清晰度面板。 + final VoidCallback onQuality; + + /// 下载当前集。 + final VoidCallback onDownload; + + /// 打开文字大小面板。 + final VoidCallback onTextSize; + + /// 举报当前剧。 + final VoidCallback onReport; + + /// 进入画中画。 + final VoidCallback? onPip; +} + +/// K28 播放页更多操作面板。 +class PlayerMoreSheet extends StatelessWidget { + /// 创建更多操作面板。 + const PlayerMoreSheet({ + super.key, + required this.l10n, + required this.state, + required this.actions, + }); + + /// 当前语言包。 + final AppLocalizations l10n; + + /// 面板状态。 + final PlayerMoreState state; + + /// 面板动作。 + final PlayerMoreActions actions; + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('player-more-sheet'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 10), + child: Text( + l10n.more, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + _MoreRow( + key: const Key('player-more-speed'), + icon: Icons.speed_rounded, + title: l10n.playbackSpeed, + value: '${state.speed}x', + action: actions.onSpeed, + ), + if (state.qualities.isNotEmpty) + _MoreRow( + key: const Key('player-more-quality'), + icon: Icons.high_quality_rounded, + title: l10n.playbackQuality, + value: state.qualities.contains(QualitySheet.autoQuality) + ? l10n.qualityAuto + : state.qualities.first, + action: actions.onQuality, + ), + _MoreRow( + key: const Key('player-more-download'), + icon: Icons.download_rounded, + title: l10n.downloads, + value: '', + action: actions.onDownload, + ), + _MoreRow( + key: const Key('player-more-text-size'), + icon: Icons.format_size_rounded, + title: l10n.textSize, + value: playerTextSizeLabel(l10n, state.textScale), + action: actions.onTextSize, + ), + if (state.pipAvailable && actions.onPip != null) + _MoreRow( + key: const Key('player-more-pip'), + icon: Icons.picture_in_picture_alt_rounded, + title: l10n.pictureInPicture, + value: '', + action: actions.onPip!, + ), + _MoreRow( + key: const Key('player-more-report'), + icon: Icons.flag_outlined, + title: l10n.reportDrama, + value: '', + action: actions.onReport, + ), + ], + ), + ), + ), + ); + } +} + +class _MoreRow extends StatelessWidget { + const _MoreRow({ + super.key, + required this.icon, + required this.title, + required this.value, + required this.action, + }); + + final IconData icon; + final String title; + final String value; + final VoidCallback action; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + Navigator.pop(context); + WidgetsBinding.instance.addPostFrameCallback((_) => action()); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 13), + child: Row( + children: [ + Icon(icon, color: AppColors.muted, size: 21), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + if (value.isNotEmpty) + Text( + value, + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 20, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart new file mode 100644 index 0000000..c44d2e1 --- /dev/null +++ b/lib/pages/player/widgets/player_overlay.dart @@ -0,0 +1,416 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; +import 'exit_clear_screen_button.dart'; +import 'player_center_toggle.dart'; +import 'player_overlay_models.dart'; +import 'seek_progress_bar.dart'; + +part 'player_overlay_parts.dart'; + +const double _topGradientHeight = 230; +const double _bottomGradientHeight = 390; +const double _topButtonSize = 36; +const double _titleSize = 24; +const double _dotSize = 7; +const double _railButtonSize = 36; +const double _railGap = 9; +const double _chipRadius = 999; +const double _dotRadius = 3; +const double _railTopFactor = 0.28; +const double _railTopCompactFactor = 0.18; + +/// K28 竖屏播放页浮层。 +class PlayerPlaybackOverlay extends StatelessWidget { + /// 创建播放浮层。 + const PlayerPlaybackOverlay({ + super.key, + required this.data, + required this.actions, + required this.controlsVisible, + }); + + /// 展示数据。 + final PlayerOverlayData data; + + /// 交互动作。 + final PlayerOverlayActions actions; + + /// 控制层是否可见。 + final bool controlsVisible; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + key: const Key('player-video-tap'), + behavior: HitTestBehavior.opaque, + onTap: actions.onTogglePlay, + ), + ), + if (controlsVisible) ...[ + _top(context), + _bottom(context), + _rail(context), + ], + if (!controlsVisible) + Positioned( + top: AppDimens.spaceSm, + right: AppDimens.spaceMd, + child: ExitClearScreenButton( + key: const Key('player-exit-clear-screen'), + tooltip: data.l10n.exitClearScreen, + onPressed: actions.onExitClear, + ), + ), + if (!data.playing) + Center( + child: PlayerCenterToggle( + key: const Key('player-play-toggle'), + playing: false, + onTap: actions.onTogglePlay, + ), + ), + ], + ), + ); + } + + Widget _top(BuildContext context) { + final title = data.episode.title.trim().isEmpty + ? data.l10n.episodeLabel(data.episode.number) + : data.episode.title; + final series = localizedDramaTitle(data.l10n, data.drama); + return Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + key: const Key('player-controls-top'), + height: _topGradientHeight, + padding: const EdgeInsets.fromLTRB(14, 4, 14, 20), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xCC000000), Color(0x00000000)], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (actions.onBack != null) + _TopButton( + key: const Key('player-back'), + tooltip: data.l10n.backToSeries, + icon: Icons.chevron_left_rounded, + onPressed: actions.onBack!, + ) + else + const SizedBox(width: _topButtonSize), + const Spacer(), + _TopButton( + key: const Key('player-clear-screen'), + tooltip: data.l10n.clearScreen, + icon: Icons.fullscreen_rounded, + onPressed: actions.onClear, + ), + const SizedBox(width: AppDimens.spaceSm), + _TopButton( + key: const Key('player-more'), + tooltip: data.l10n.more, + icon: Icons.more_horiz_rounded, + onPressed: actions.onMore, + ), + ], + ), + const SizedBox(height: 18), + Row( + children: [ + Flexible( + child: Text( + title, + key: const Key('player-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.onPrimary, + fontFamily: 'Outfit', + fontSize: _titleSize * data.textScale, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 7), + Container( + key: const Key('player-title-dot'), + width: _dotSize, + height: _dotSize, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + '$series · ${data.l10n.episodeLabel(data.episode.number)}', + key: const Key('player-subtitle'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white70, + fontSize: 14 * data.textScale, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + if (_genre.isNotEmpty) + _infoChip(const Key('player-genre-chip'), _genre), + const Spacer(), + _PageDots( + key: const Key('player-page-dots'), + current: data.currentIndex, + total: data.totalEpisodes, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _rail(BuildContext context) { + final likeCount = data.likeCount; + final compact = MediaQuery.sizeOf(context).height < 700; + return Positioned( + right: AppDimens.spaceMd, + top: + MediaQuery.sizeOf(context).height * + (compact ? _railTopCompactFactor : _railTopFactor), + child: _Rail( + l10n: data.l10n, + liked: data.liked, + saved: data.saved, + likeCount: likeCount, + onLike: actions.onLike, + onComments: actions.onComments, + onSave: actions.onSave, + onShare: actions.onShare, + compact: compact, + ), + ); + } + + Widget _bottom(BuildContext context) { + final series = localizedDramaTitle(data.l10n, data.drama); + final synopsis = localizedDramaSynopsis(data.l10n, data.drama); + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Stack( + children: [ + const Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x00000000), + Color(0xDD000000), + Color(0xF2000000), + ], + ), + ), + ), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: _bottomGradientHeight), + child: Padding( + key: const Key('player-controls-bottom'), + padding: const EdgeInsets.fromLTRB(14, 48, 14, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + series, + key: const Key('player-drama-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.onPrimary, + fontFamily: 'Outfit', + fontSize: 22 * data.textScale, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 8), + Text( + data.l10n.episodeLabel(data.episode.number), + key: const Key('player-episode-label'), + style: TextStyle( + color: AppColors.blue, + fontSize: 12 * data.textScale, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: _genre.isEmpty + ? const SizedBox.shrink() + : _softChip(_genre), + ), + ), + const SizedBox(width: 8), + SizedBox( + height: AppDimens.buttonSmall, + child: FilledButton( + key: const Key('player-follow'), + onPressed: actions.onSave, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + child: Text( + data.saved + ? data.l10n.followingTab + : data.l10n.followSeries, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + synopsis, + key: const Key('player-synopsis'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white70, + fontSize: 12 * data.textScale, + height: 1.45, + ), + ), + const SizedBox(height: 4), + _TimeLabel( + key: const Key('player-time-label'), + controller: data.controller, + ), + SeekProgressBar( + key: const Key('player-progress'), + valueListenable: data.controller, + semanticsLabel: data.l10n.seekVideo, + showTimes: true, + compact: true, + onScrubStart: actions.onScrubStart, + onSeek: actions.onSeek, + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: _ActionButton( + key: const Key('player-episodes'), + icon: Icons.grid_view_rounded, + label: data.l10n.episodes, + onTap: actions.onEpisodes, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ActionButton( + key: const Key('player-next'), + icon: Icons.skip_next_rounded, + label: data.l10n.nextEpisode, + onTap: actions.onNext, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ActionButton( + key: const Key('player-bottom-more'), + icon: Icons.more_horiz_rounded, + label: data.l10n.more, + onTap: actions.onMore, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _infoChip(Key key, String label) => Container( + key: key, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(_chipRadius), + ), + child: Text( + label, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ); + + Widget _softChip(String label) => Container( + constraints: const BoxConstraints(maxWidth: 180), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(_chipRadius), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ); + + String get _genre => + data.drama.genre.isNotEmpty ? data.drama.genre : data.drama.category; +} diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart new file mode 100644 index 0000000..e5a1e15 --- /dev/null +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -0,0 +1,117 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 播放浮层展示数据。 +class PlayerOverlayData { + /// 创建播放浮层数据。 + const PlayerOverlayData({ + required this.l10n, + required this.drama, + required this.episode, + required this.controller, + required this.liked, + required this.likeCount, + required this.saved, + required this.playing, + required this.textScale, + required this.currentIndex, + required this.totalEpisodes, + }); + + /// 当前语言包。 + final AppLocalizations l10n; + + /// 当前短剧。 + final Drama drama; + + /// 当前剧集。 + final Episode episode; + + /// 当前播放器。 + final VideoPlayerController? controller; + + /// 是否已点赞。 + final bool liked; + + /// 点赞数;未知时隐藏。 + final int? likeCount; + + /// 是否已收藏。 + final bool saved; + + /// 是否处于播放状态。 + final bool playing; + + /// 信息文字缩放比例。 + final double textScale; + + /// 当前集索引。 + final int currentIndex; + + /// 可见集数量。 + final int totalEpisodes; +} + +/// 播放浮层动作集合。 +class PlayerOverlayActions { + /// 创建动作集合。 + const PlayerOverlayActions({ + required this.onBack, + required this.onTogglePlay, + required this.onClear, + required this.onExitClear, + required this.onEpisodes, + required this.onComments, + required this.onLike, + required this.onSave, + required this.onShare, + required this.onMore, + required this.onScrubStart, + required this.onSeek, + this.onNext, + }); + + /// 返回上一页。 + final VoidCallback? onBack; + + /// 切换播放状态。 + final VoidCallback onTogglePlay; + + /// 进入清屏。 + final VoidCallback onClear; + + /// 退出清屏。 + final VoidCallback onExitClear; + + /// 打开选集。 + final VoidCallback onEpisodes; + + /// 打开评论。 + final VoidCallback onComments; + + /// 切换点赞。 + final VoidCallback onLike; + + /// 切换收藏。 + final VoidCallback onSave; + + /// 分享。 + final VoidCallback onShare; + + /// 打开更多面板。 + final VoidCallback onMore; + + /// 下一集。 + final VoidCallback? onNext; + + /// 开始拖动进度。 + final VoidCallback? onScrubStart; + + /// 提交进度跳转。 + final Future Function(Duration)? onSeek; +} diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart new file mode 100644 index 0000000..7a2ce76 --- /dev/null +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -0,0 +1,243 @@ +// Author: fruit.com.cn + +part of 'player_overlay.dart'; + +class _TopButton extends StatelessWidget { + const _TopButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: _topButtonSize, + child: IconButton( + tooltip: tooltip, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: AppColors.card, + foregroundColor: AppColors.onSurface, + ), + onPressed: onPressed, + icon: Icon(icon, size: 18), + ), + ); + } +} + +class _Rail extends StatelessWidget { + const _Rail({ + required this.l10n, + required this.liked, + required this.saved, + required this.likeCount, + required this.onLike, + required this.onComments, + required this.onSave, + required this.onShare, + required this.compact, + }); + + final AppLocalizations l10n; + final bool liked; + final bool saved; + final int? likeCount; + final VoidCallback onLike; + final VoidCallback onComments; + final VoidCallback onSave; + final VoidCallback onShare; + final bool compact; + + @override + Widget build(BuildContext context) { + return Column( + key: const Key('player-interaction-rail'), + children: [ + _RailButton( + key: const Key('player-rail-like'), + icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + color: liked ? AppColors.blueSoft : AppColors.onPrimary, + label: likeCount?.toString() ?? l10n.like, + onTap: onLike, + compact: compact, + ), + _RailButton( + key: const Key('player-rail-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: l10n.comments, + onTap: onComments, + compact: compact, + ), + _RailButton( + key: const Key('player-rail-save'), + icon: saved ? Icons.star_rounded : Icons.star_border_rounded, + label: l10n.favorite, + onTap: onSave, + compact: compact, + ), + _RailButton( + key: const Key('player-rail-share'), + icon: Icons.ios_share_rounded, + label: l10n.share, + onTap: onShare, + compact: compact, + ), + ], + ); + } +} + +class _RailButton extends StatelessWidget { + const _RailButton({ + super.key, + required this.icon, + required this.label, + required this.onTap, + required this.compact, + this.color = AppColors.onPrimary, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool compact; + final Color color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: compact ? 2 : _railGap), + child: Column( + children: [ + SizedBox.square( + dimension: _railButtonSize, + child: IconButton( + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: AppColors.overlayDark, + foregroundColor: color, + ), + icon: Icon(icon, size: 18), + ), + ), + SizedBox(height: compact ? 1 : 3), + Text( + label, + maxLines: 1, + style: TextStyle( + color: AppColors.onPrimary, + fontSize: compact ? 8 : 9, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _PageDots extends StatelessWidget { + const _PageDots({super.key, required this.current, required this.total}); + + final int current; + final int total; + + @override + Widget build(BuildContext context) { + if (total <= 1) return const SizedBox.shrink(); + final start = (current - 2).clamp(0, total > 5 ? total - 5 : 0); + final end = (start + 5).clamp(0, total); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = start; index < end; index++) + Container( + width: index == current ? 14 : _dotSize, + height: _dotSize, + margin: const EdgeInsets.only(left: 4), + decoration: BoxDecoration( + color: index == current ? AppColors.blue : Colors.white54, + borderRadius: BorderRadius.circular(_dotRadius), + ), + ), + ], + ); + } +} + +class _TimeLabel extends StatelessWidget { + const _TimeLabel({super.key, required this.controller}); + + final VideoPlayerController? controller; + + @override + Widget build(BuildContext context) { + final source = controller; + if (source == null) return _label(Duration.zero, Duration.zero); + return ValueListenableBuilder( + valueListenable: source, + builder: (context, value, _) => _label(value.position, value.duration), + ); + } + + Widget _label(Duration position, Duration duration) => Text( + '${_format(position)} / ${_format(duration)}', + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ); + + String _format(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + super.key, + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 38, + child: FilledButton.icon( + onPressed: onTap, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + backgroundColor: AppColors.card, + foregroundColor: AppColors.onSurface, + disabledBackgroundColor: Colors.black38, + disabledForegroundColor: Colors.white54, + ), + icon: Icon(icon, size: 16), + label: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w700), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_speed_sheet.dart b/lib/pages/player/widgets/player_speed_sheet.dart new file mode 100644 index 0000000..7a42449 --- /dev/null +++ b/lib/pages/player/widgets/player_speed_sheet.dart @@ -0,0 +1,75 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const List _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; +const double _sheetRadius = 20; + +/// 播放倍速选择弹窗。 +class PlayerSpeedSheet extends StatelessWidget { + /// 创建倍速选择弹窗。 + const PlayerSpeedSheet({super.key, required this.selected}); + + /// 当前倍速。 + final double selected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Material( + key: const Key('player-speed-sheet'), + color: AppColors.card, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(_sheetRadius), + ), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.playbackSpeed, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + for (final value in _speedOptions) _option(context, value), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Widget _option(BuildContext context, double value) { + final active = value == selected; + return ListTile( + key: Key('player-speed-${value.toString()}'), + dense: true, + title: Text( + '${value.toString()}x', + style: TextStyle( + color: active ? AppColors.blue : AppColors.onSurface, + fontSize: 14, + fontWeight: active ? FontWeight.w800 : FontWeight.w500, + ), + ), + trailing: active + ? const Icon(Icons.check_rounded, color: AppColors.blue, size: 20) + : null, + onTap: () => Navigator.pop(context, value), + ); + } +} diff --git a/lib/pages/player/widgets/player_text_size_sheet.dart b/lib/pages/player/widgets/player_text_size_sheet.dart new file mode 100644 index 0000000..fa284f5 --- /dev/null +++ b/lib/pages/player/widgets/player_text_size_sheet.dart @@ -0,0 +1,110 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const double playerTextSizeSmall = 0.9; +const double playerTextSizeStandard = 1.0; +const double playerTextSizeLarge = 1.15; +const double _sheetRadius = 20; + +/// 返回播放信息文字档位的本地化名称。 +String playerTextSizeLabel(AppLocalizations l10n, double scale) { + if (scale <= playerTextSizeSmall) return l10n.textSizeSmall; + if (scale >= playerTextSizeLarge) return l10n.textSizeLarge; + return l10n.textSizeStandard; +} + +/// 播放页文字大小选择弹窗。 +class PlayerTextSizeSheet extends StatelessWidget { + /// 创建文字大小选择弹窗。 + const PlayerTextSizeSheet({super.key, required this.selected}); + + /// 当前文字缩放比例。 + final double selected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final options = [ + (playerTextSizeSmall, 'small', l10n.textSizeSmall), + (playerTextSizeStandard, 'standard', l10n.textSizeStandard), + (playerTextSizeLarge, 'large', l10n.textSizeLarge), + ]; + return Container( + key: const Key('player-text-size-sheet'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.textSize, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 16), + for (final option in options) ...[ + _option(context, option.$1, option.$2, option.$3), + if (option != options.last) const SizedBox(height: 8), + ], + ], + ), + ), + ), + ); + } + + Widget _option( + BuildContext context, + double scale, + String keySuffix, + String label, + ) { + final active = (scale - selected).abs() < 0.01; + return Material( + key: Key('player-text-size-$keySuffix'), + color: active ? AppColors.quick : AppColors.raised, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => Navigator.pop(context, scale), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle( + color: active ? AppColors.blue : AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + if (active) + const Icon( + Icons.check_circle_rounded, + color: AppColors.blue, + size: 20, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_unlock_sheet.dart b/lib/pages/player/widgets/player_unlock_sheet.dart new file mode 100644 index 0000000..4cdedc4 --- /dev/null +++ b/lib/pages/player/widgets/player_unlock_sheet.dart @@ -0,0 +1,373 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/data/models/play_ticket.dart'; +import '../../../core/formatting/error_text.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _cardRadius = 20; +const double _optionRadius = 14; +const double _sheetWidth = 360; + +/// 视频区占位提示:由后端票据决定是解锁、无片源还是会员限制。 +class PlayerBlockedNotice extends StatelessWidget { + /// 创建不可播放提示。 + const PlayerBlockedNotice({ + super.key, + required this.l10n, + required this.episode, + required this.accessType, + required this.unlockCoin, + required this.unlocking, + required this.error, + required this.onOpenUnlock, + }); + + /// 当前语言包。 + final AppLocalizations l10n; + + /// 当前剧集。 + final Episode episode; + + /// 后端下发的内容访问类型。 + final String? accessType; + + /// 金币解锁价格。 + final int unlockCoin; + + /// 是否正在解锁。 + final bool unlocking; + + /// 最近一次解锁失败信息。 + final String? error; + + /// 打开内容解锁面板。 + final VoidCallback? onOpenUnlock; + + bool get _needsUnlock => + accessType == PlayTicket.coinRequired || + accessType == PlayTicket.vipRequired; + + @override + Widget build(BuildContext context) { + final title = _needsUnlock + ? l10n.lockedTitle(episode.number) + : accessType == PlayTicket.noMedia || !episode.locked + ? l10n.noMediaNotice + : l10n.lockedTitle(episode.number); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _sheetWidth), + child: Container( + key: const Key('player-unlock-notice'), + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_cardRadius), + boxShadow: const [ + BoxShadow( + color: AppColors.cardShadow, + blurRadius: 18, + offset: Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _needsUnlock ? Icons.lock_rounded : Icons.movie_filter_outlined, + color: _needsUnlock ? AppColors.blue : AppColors.muted, + size: 30, + ), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + if (_needsUnlock && unlockCoin > 0) ...[ + const SizedBox(height: 10), + _coinLabel(l10n), + ], + if (error != null) ...[ + const SizedBox(height: 8), + Text( + errorText(l10n, error, l10n.unlockFailed), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.badgePinkText, + fontSize: 12, + ), + ), + ], + if (_needsUnlock) ...[ + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton( + key: const Key('player-open-unlock'), + onPressed: unlocking ? null : onOpenUnlock, + child: unlocking + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.unlockEpisode), + ), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _coinLabel(AppLocalizations l10n) => DecoratedBox( + decoration: BoxDecoration( + color: AppColors.goldCard, + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Text( + l10n.walletBalanceShort(unlockCoin), + style: const TextStyle( + color: AppColors.onGold, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ); +} + +/// 内容解锁面板:金币可用,广告与会员在契约缺失时明确禁用。 +class PlayerUnlockSheet extends StatefulWidget { + /// 创建内容解锁面板。 + const PlayerUnlockSheet({ + super.key, + required this.l10n, + required this.episodeNumber, + required this.coinCost, + required this.balance, + required this.onUnlock, + }); + + /// 当前语言包。 + final AppLocalizations l10n; + + /// 当前集号。 + final int episodeNumber; + + /// 金币解锁价格。 + final int coinCost; + + /// 当前金币余额;未知时隐藏。 + final int? balance; + + /// 金币解锁动作,成功后返回 true。 + final Future Function()? onUnlock; + + @override + State createState() => _PlayerUnlockSheetState(); +} + +class _PlayerUnlockSheetState extends State { + bool _busy = false; + String? _error; + + Future _unlock() async { + final action = widget.onUnlock; + if (action == null || _busy) return; + setState(() { + _busy = true; + _error = null; + }); + final success = await action(); + if (!mounted) return; + if (success) { + Navigator.of(context).pop(true); + return; + } + setState(() { + _busy = false; + _error = widget.l10n.unlockFailed; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = widget.l10n; + return Container( + key: const Key('player-unlock-panel'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_cardRadius)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.lockedTitle(widget.episodeNumber), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + key: const Key('player-unlock-close'), + tooltip: l10n.close, + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + const SizedBox(height: 8), + _UnlockOption( + optionKey: const Key('player-unlock-coins'), + icon: Icons.toll_rounded, + title: l10n.unlockWithCoins, + subtitle: widget.balance == null + ? l10n.walletBalanceShort(widget.coinCost) + : '${l10n.walletBalanceShort(widget.coinCost)} · ' + '${l10n.walletBalanceShort(widget.balance!)}', + enabled: widget.onUnlock != null && !_busy, + busy: _busy, + onTap: _unlock, + ), + const SizedBox(height: 10), + _UnlockOption( + optionKey: const Key('player-unlock-ad'), + icon: Icons.ondemand_video_rounded, + title: l10n.watchAdToUnlock, + subtitle: l10n.comingSoon, + enabled: false, + onTap: null, + ), + const SizedBox(height: 10), + _UnlockOption( + optionKey: const Key('player-unlock-vip'), + icon: Icons.workspace_premium_rounded, + title: l10n.unlockWithVip, + subtitle: l10n.comingSoon, + enabled: false, + onTap: null, + ), + if (_error != null) ...[ + const SizedBox(height: 10), + Text( + _error!, + style: const TextStyle( + color: AppColors.badgePinkText, + fontSize: 12, + ), + ), + ], + const SizedBox(height: 14), + Text( + l10n.unlockMethodsVary, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ], + ), + ), + ), + ); + } +} + +class _UnlockOption extends StatelessWidget { + const _UnlockOption({ + required this.optionKey, + required this.icon, + required this.title, + required this.subtitle, + required this.enabled, + this.busy = false, + this.onTap, + }); + + final Key optionKey; + final IconData icon; + final String title; + final String subtitle; + final bool enabled; + final bool busy; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Material( + key: optionKey, + color: enabled ? AppColors.quick : AppColors.raised, + borderRadius: BorderRadius.circular(_optionRadius), + child: InkWell( + borderRadius: BorderRadius.circular(_optionRadius), + onTap: enabled ? onTap : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Icon( + icon, + color: enabled ? AppColors.blue : AppColors.subtle, + size: 22, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: enabled ? AppColors.onSurface : AppColors.subtle, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + ), + ), + ], + ), + ), + if (busy) + const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else if (enabled) + const Icon(Icons.chevron_right_rounded, color: AppColors.blue), + ], + ), + ), + ), + ); + } +} diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 413d19e..5712889 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -68,7 +68,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Second page'), findsOneWidget); - expect(find.text('Comments · 2'), findsOneWidget); + expect(find.text('Comments (2)'), findsOneWidget); expect(repository.pageCalls.last.cursor, '10'); }); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 11b4daa..7464da8 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -6,6 +6,8 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.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'; @@ -287,29 +289,145 @@ void main() { ); expect(bar.showTimes, isTrue); }); + + testWidgets('should_render_k28_player_structure_and_episode_grid', ( + tester, + ) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + + _expectFullscreenOverlay(tester); + await tester.tap(find.byKey(const Key('player-episodes'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget); + expect(find.byKey(const Key('player-episodes-close')), findsOneWidget); + final grid = tester.widget( + find.descendant( + of: find.byKey(const Key('player-episodes-sheet')), + matching: find.byType(GridView), + ), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 6); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_show_locked_episode_in_episode_picker', (tester) async { + await _pumpApp( + tester, + catalog: FakeCatalogRepository(dramas: const [_lockedDrama]), + ); + await _openSeriesPlayer( + tester, + cardKey: const Key('home-drama-card-locked-demo'), + ); + await tester.tap(find.byKey(const Key('player-episodes'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('episode-lock-locked-demo-1')), + findsOneWidget, + ); + expect(find.byKey(const Key('player-episode-2')), findsOneWidget); + }); + + testWidgets('should_open_more_actions_and_change_text_size', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); + expect(find.byKey(const Key('player-more-speed')), findsOneWidget); + expect(find.byKey(const Key('player-more-quality')), findsOneWidget); + expect(find.byKey(const Key('player-more-download')), findsOneWidget); + expect(find.byKey(const Key('player-more-text-size')), findsOneWidget); + expect(find.byKey(const Key('player-more-report')), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-more-text-size'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-text-size-sheet')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-text-size-large'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('player-more-text-size')), + matching: find.text('Large'), + ), + findsOneWidget, + ); + }); + + testWidgets('should_open_unlock_panel_with_honest_method_states', ( + tester, + ) async { + await _pumpApp( + tester, + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'city-lights-demo-1', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ), + ), + ); + await _openSeriesPlayer(tester); + + expect(find.byKey(const Key('player-unlock-notice')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-open-unlock'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-unlock-panel')), findsOneWidget); + expect(find.byKey(const Key('player-unlock-coins')), findsOneWidget); + expect(find.byKey(const Key('player-unlock-ad')), findsOneWidget); + expect(find.byKey(const Key('player-unlock-vip')), findsOneWidget); + expect( + find.text('Available unlock methods vary by title.'), + findsOneWidget, + ); + }); } void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-back')), findsOneWidget); expect(find.byKey(const Key('player-title')), findsOneWidget); + expect(find.byKey(const Key('player-title-dot')), findsOneWidget); + expect(find.byKey(const Key('player-subtitle')), findsOneWidget); + expect(find.byKey(const Key('player-genre-chip')), findsOneWidget); + expect(find.byKey(const Key('player-page-dots')), findsOneWidget); + expect(find.byKey(const Key('player-drama-title')), findsOneWidget); + expect(find.byKey(const Key('player-episode-label')), findsOneWidget); + expect(find.byKey(const Key('player-follow')), findsOneWidget); + expect(find.byKey(const Key('player-synopsis')), findsOneWidget); + expect(find.byKey(const Key('player-time-label')), findsOneWidget); expect(find.byKey(const Key('player-more')), findsOneWidget); for (final key in const [Key('player-back'), Key('player-more')]) { expect(tester.getSize(find.byKey(key)), const Size(36, 36)); } expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(find.byKey(const Key('player-episodes')), findsOneWidget); - expect(find.byKey(const Key('player-comments')), findsOneWidget); + expect(find.byKey(const Key('player-rail-comments')), findsOneWidget); expect(find.byKey(const Key('player-next')), findsOneWidget); expect(find.byType(Slider), findsOneWidget); - expect(find.byKey(const Key('rail-like')), findsNothing); - expect(find.byKey(const Key('rail-save')), findsNothing); + expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); + expect(find.byKey(const Key('player-rail-like')), findsOneWidget); + expect(find.byKey(const Key('player-rail-save')), findsOneWidget); + expect(find.byKey(const Key('player-rail-share')), findsOneWidget); } -Future _openSeriesPlayer(WidgetTester tester) async { +Future _openSeriesPlayer( + WidgetTester tester, { + Key cardKey = const Key('home-drama-card-city-lights-demo'), +}) async { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - final card = find.byKey(const Key('home-drama-card-city-lights-demo')); + final card = find.byKey(cardKey); await tester.scrollUntilVisible( card, 220, @@ -329,20 +447,22 @@ Future _openSeriesPlayer(WidgetTester tester) async { Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), + FakeCatalogRepository? catalog, + FakePlaybackRepository? playback, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final catalog = FakeCatalogRepository(); + final repository = catalog ?? FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, - catalogRepository: catalog, - categoryRepository: FakeCategoryRepository(catalog: catalog), + catalogRepository: repository, + categoryRepository: FakeCategoryRepository(catalog: repository), watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), - playbackRepository: FakePlaybackRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), + playbackRepository: playback ?? FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), @@ -351,3 +471,29 @@ Future _pumpApp( ); await tester.pumpAndSettle(); } + +const _lockedDrama = Drama( + id: 'locked-demo', + title: 'Locked Demo', + synopsis: 'Locked demo synopsis', + posterUrl: '/files/locked-demo.png', + genre: 'Suspense', + detailLoaded: true, + episodes: [ + Episode( + id: 'locked-demo-1', + number: 1, + title: 'Locked Episode', + isFree: false, + locked: true, + videoUrl: 'https://example.test/locked-demo-1.mp4', + ), + Episode( + id: 'locked-demo-2', + number: 2, + title: 'Open Episode', + isFree: true, + videoUrl: 'https://example.test/locked-demo-2.mp4', + ), + ], +); diff --git a/test/widget_test.dart b/test/widget_test.dart index 4a12dd3..709f47c 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -226,7 +226,7 @@ void main() { await tester.pumpAndSettle(); await _openTheaterPlayer(tester); - await tester.tap(find.byKey(const Key('player-comments'))); + await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.text('No comments yet.'), findsOneWidget); await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo'); @@ -236,11 +236,14 @@ void main() { await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - await _tapPlayerMore(tester, const Key('player-more-like')); + await tester.tap(find.byKey(const Key('player-rail-like'))); + await tester.pumpAndSettle(); expect(interactions.likeTargets, contains('DRAMA:city-lights-demo')); - await _tapPlayerMore(tester, const Key('player-more-favorite')); - await _tapPlayerMore(tester, const Key('player-more-share')); + await tester.tap(find.byKey(const Key('player-rail-save'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-rail-share'))); + await tester.pumpAndSettle(); expect(sharedText, contains('City Lights & Velvet Nights')); expect(sharedText, contains('FruitShorts')); expect(tester.takeException(), isNull); @@ -256,7 +259,8 @@ void main() { await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); await tester.pumpAndSettle(); await _openTheaterPlayer(tester); - await _tapPlayerMore(tester, const Key('player-more-like')); + await tester.tap(find.byKey(const Key('player-rail-like'))); + await tester.pumpAndSettle(); expect(find.text('Sign in to continue'), findsOneWidget); expect(interactions.likeTargets, isEmpty); @@ -271,7 +275,8 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); await _openTheaterPlayer(tester); - await _tapPlayerMore(tester, const Key('player-more-favorite')); + await tester.tap(find.byKey(const Key('player-rail-save'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); @@ -303,12 +308,3 @@ Future _openTheaterPlayer(WidgetTester tester) async { await tester.tap(card); await tester.pumpAndSettle(); } - -Future _tapPlayerMore(WidgetTester tester, Key action) async { - await tester.tap(find.byKey(const Key('player-more'))); - await tester.pumpAndSettle(); - await tester.ensureVisible(find.byKey(action)); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(action)); - await tester.pumpAndSettle(); -} From 8c43e9e385ab8a873c798e6bd91154c7defab404 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:01:38 +0800 Subject: [PATCH 025/124] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E9=A1=B5=E5=B9=B6=E5=A2=9E=E5=8A=A0=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 43 +- lib/core/data/services/locale_controller.dart | 53 ++ lib/l10n/app_en.arb | 17 +- lib/l10n/app_id.arb | 17 +- lib/l10n/app_localizations.dart | 80 ++- lib/l10n/app_localizations_en.dart | 41 +- lib/l10n/app_localizations_id.dart | 41 +- lib/l10n/app_localizations_zh.dart | 41 +- lib/l10n/app_zh.arb | 17 +- lib/pages/discover/discover_logic.dart | 8 - lib/pages/discover/discover_types.dart | 3 - lib/pages/discover/discover_view.dart | 33 -- lib/pages/mine/mine_view.dart | 97 +++- lib/pages/mine/widgets/mine_menu.dart | 48 +- lib/pages/player/player_view.dart | 20 +- lib/pages/player/player_view_actions.dart | 33 +- lib/pages/player/widgets/comment_tile.dart | 43 +- .../player/widgets/player_more_sheet.dart | 366 ++++++++++---- lib/pages/player/widgets/player_overlay.dart | 474 ++++++++---------- .../player/widgets/player_overlay_models.dart | 36 +- .../player/widgets/player_overlay_parts.dart | 221 +++++--- .../player/widgets/player_speed_sheet.dart | 2 +- .../player/widgets/player_text_scale.dart | 19 + .../widgets/player_text_size_sheet.dart | 110 ---- lib/pages/player/widgets/quality_sheet.dart | 85 ---- test/comment/drama_comments_sheet_test.dart | 2 +- test/core/locale_controller_test.dart | 39 ++ test/discover/home_all_dramas_grid_test.dart | 2 +- test/home/k28_a_navigation_test.dart | 17 +- test/mine/mine_preview_layout_test.dart | 3 +- test/player/player_fullscreen_test.dart | 127 +++-- test/player/quality_sheet_test.dart | 32 -- test/watch/watch_history_provider_test.dart | 4 +- test/widget_test.dart | 36 +- 34 files changed, 1331 insertions(+), 879 deletions(-) create mode 100644 lib/core/data/services/locale_controller.dart create mode 100644 lib/pages/player/widgets/player_text_scale.dart delete mode 100644 lib/pages/player/widgets/player_text_size_sheet.dart delete mode 100644 lib/pages/player/widgets/quality_sheet.dart create mode 100644 test/core/locale_controller_test.dart delete mode 100644 test/player/quality_sheet_test.dart diff --git a/lib/app.dart b/lib/app.dart index e51d6ba..6ab3ac0 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -40,6 +40,7 @@ import 'core/data/services/category_controller.dart'; import 'core/data/services/download_controller.dart'; import 'core/data/services/favorite_controller.dart'; import 'core/data/services/interaction_controller.dart'; +import 'core/data/services/locale_controller.dart'; import 'core/data/services/playback_session.dart'; import 'core/data/services/video_player_port.dart'; import 'core/data/services/wallet_controller.dart'; @@ -175,11 +176,14 @@ class _LumaDramaAppState extends State { late final InteractionController _interactionController = InteractionController(_interactionRepository); + late final LocaleController _localeController = LocaleController(); + @override void initState() { super.initState(); // 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏 _auth.load(); + unawaited(_localeController.load()); // 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联 _auth.addListener(_onAuthChanged); if (_auth.isSignedIn) unawaited(_favoriteController.load()); @@ -210,6 +214,7 @@ class _LumaDramaAppState extends State { _downloadController.dispose(); _walletController.dispose(); _interactionController.dispose(); + _localeController.dispose(); _apiClient.close(); super.dispose(); } @@ -242,27 +247,31 @@ class _LumaDramaAppState extends State { ChangeNotifierProvider.value(value: _downloadController), ChangeNotifierProvider.value(value: _walletController), ChangeNotifierProvider.value(value: _interactionController), + ChangeNotifierProvider.value(value: _localeController), ChangeNotifierProvider.value(value: _auth), ], - child: MaterialApp( - onGenerateTitle: (context) => AppLocalizations.of(context)!.appName, - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalizations.localizationsDelegates, - localeResolutionCallback: (deviceLocale, _) => - resolveAppLocale(deviceLocale), - debugShowCheckedModeBanner: false, - themeMode: ThemeMode.light, - theme: AppTheme.light(), - darkTheme: AppTheme.light(), - builder: (context, child) => AnnotatedRegion( - value: SystemUiOverlayStyle.dark.copyWith( - statusBarColor: Colors.transparent, - systemNavigationBarColor: AppColors.bar, - systemNavigationBarIconBrightness: Brightness.dark, + child: Consumer( + builder: (context, localeController, _) => MaterialApp( + locale: localeController.locale, + onGenerateTitle: (context) => AppLocalizations.of(context)!.appName, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + localeResolutionCallback: (deviceLocale, _) => + resolveAppLocale(deviceLocale), + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.light, + theme: AppTheme.light(), + darkTheme: AppTheme.light(), + builder: (context, child) => AnnotatedRegion( + value: SystemUiOverlayStyle.dark.copyWith( + statusBarColor: Colors.transparent, + systemNavigationBarColor: AppColors.bar, + systemNavigationBarIconBrightness: Brightness.dark, + ), + child: child ?? const SizedBox.shrink(), ), - child: child ?? const SizedBox.shrink(), + home: const HomePage(), ), - home: const HomePage(), ), ); } diff --git a/lib/core/data/services/locale_controller.dart b/lib/core/data/services/locale_controller.dart new file mode 100644 index 0000000..0eeab22 --- /dev/null +++ b/lib/core/data/services/locale_controller.dart @@ -0,0 +1,53 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 全局界面语言状态。 +/// +/// `null` 表示没有用户选择,继续跟随系统语言;一旦用户在“我的”里选择, +/// 选择结果会持久化并在下一次启动时恢复。 +class LocaleController extends ChangeNotifier { + /// 创建语言状态控制器。 + LocaleController({SharedPreferencesAsync? preferences}) + : _preferences = preferences ?? SharedPreferencesAsync(); + + static const String preferenceKey = 'app.locale'; + static const Set supportedCodes = {'zh', 'en', 'id'}; + + final SharedPreferencesAsync _preferences; + Locale? _locale; + bool _disposed = false; + + /// 用户选择的语言;为空时由 MaterialApp 跟随系统。 + Locale? get locale => _locale; + + /// 当前语言的展示代码。 + String get languageCode => _locale?.languageCode ?? 'system'; + + /// 读取本地语言选择;未设置时保持跟随系统。 + Future load() async { + final code = await _preferences.getString(preferenceKey); + if (code == null || !supportedCodes.contains(code)) return; + _locale = Locale(code); + _notify(); + } + + /// 立即切换并持久化语言。 + Future select(Locale locale) async { + if (!supportedCodes.contains(locale.languageCode)) return; + _locale = locale; + _notify(); + await _preferences.setString(preferenceKey, locale.languageCode); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6913b35..e377d84 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -581,7 +581,7 @@ "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, "episodeCountLabel": "{count} episodes", "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, - "commentsWithCount": "Comments ({count})", + "commentsWithCount": "{count} comments", "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, "textSize": "Text size", "textSizeSmall": "Small", @@ -590,5 +590,18 @@ "watchAdToUnlock": "Watch an ad to unlock", "unlockWithVip": "Unlock with VIP", "unlockMethodsVary": "Available unlock methods vary by title.", - "unlockEpisode": "Unlock this episode" + "unlockEpisode": "Unlock this episode", + "language": "Language", + "fullScreenWatch": "Full screen", + "freeWatch": "Free watch", + "showMore": "More", + "qualityUnavailable": "Unavailable", + "notInterested": "Not interested", + "clearPlayback": "Clear-screen playback", + "danmaku": "Danmaku", + "danmakuUnavailable": "Coming soon", + "danmakuSettings": "Danmaku settings", + "offlineCache": "Offline cache", + "screenCast": "Cast", + "sleepTimer": "Sleep timer" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 586de55..d80f862 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -579,7 +579,7 @@ "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, "episodeCountLabel": "{count} episode", "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, - "commentsWithCount": "Komentar ({count})", + "commentsWithCount": "{count} komentar", "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, "textSize": "Ukuran teks", "textSizeSmall": "Kecil", @@ -588,5 +588,18 @@ "watchAdToUnlock": "Tonton iklan untuk membuka", "unlockWithVip": "Buka dengan VIP", "unlockMethodsVary": "Metode pembukaan tersedia dapat berbeda per judul.", - "unlockEpisode": "Buka episode ini" + "unlockEpisode": "Buka episode ini", + "language": "Bahasa", + "fullScreenWatch": "Layar penuh", + "freeWatch": "Tonton gratis", + "showMore": "Selengkapnya", + "qualityUnavailable": "Tidak tersedia", + "notInterested": "Tidak tertarik", + "clearPlayback": "Putar tanpa kontrol", + "danmaku": "Komentar layar", + "danmakuUnavailable": "Belum tersedia", + "danmakuSettings": "Pengaturan komentar layar", + "offlineCache": "Cache offline", + "screenCast": "Cast", + "sleepTimer": "Timer tidur" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b861cd5..41f5f46 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2581,7 +2581,7 @@ abstract class AppLocalizations { /// No description provided for @commentsWithCount. /// /// In en, this message translates to: - /// **'Comments ({count})'** + /// **'{count} comments'** String commentsWithCount(int count); /// No description provided for @textSize. @@ -2631,6 +2631,84 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Unlock this episode'** String get unlockEpisode; + + /// No description provided for @language. + /// + /// In en, this message translates to: + /// **'Language'** + String get language; + + /// No description provided for @fullScreenWatch. + /// + /// In en, this message translates to: + /// **'Full screen'** + String get fullScreenWatch; + + /// No description provided for @freeWatch. + /// + /// In en, this message translates to: + /// **'Free watch'** + String get freeWatch; + + /// No description provided for @showMore. + /// + /// In en, this message translates to: + /// **'More'** + String get showMore; + + /// No description provided for @qualityUnavailable. + /// + /// In en, this message translates to: + /// **'Unavailable'** + String get qualityUnavailable; + + /// No description provided for @notInterested. + /// + /// In en, this message translates to: + /// **'Not interested'** + String get notInterested; + + /// No description provided for @clearPlayback. + /// + /// In en, this message translates to: + /// **'Clear-screen playback'** + String get clearPlayback; + + /// No description provided for @danmaku. + /// + /// In en, this message translates to: + /// **'Danmaku'** + String get danmaku; + + /// No description provided for @danmakuUnavailable. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get danmakuUnavailable; + + /// No description provided for @danmakuSettings. + /// + /// In en, this message translates to: + /// **'Danmaku settings'** + String get danmakuSettings; + + /// No description provided for @offlineCache. + /// + /// In en, this message translates to: + /// **'Offline cache'** + String get offlineCache; + + /// No description provided for @screenCast. + /// + /// In en, this message translates to: + /// **'Cast'** + String get screenCast; + + /// No description provided for @sleepTimer. + /// + /// In en, this message translates to: + /// **'Sleep timer'** + String get sleepTimer; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a16aca9..9f8819a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1338,7 +1338,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String commentsWithCount(int count) { - return 'Comments ($count)'; + return '$count comments'; } @override @@ -1364,4 +1364,43 @@ class AppLocalizationsEn extends AppLocalizations { @override String get unlockEpisode => 'Unlock this episode'; + + @override + String get language => 'Language'; + + @override + String get fullScreenWatch => 'Full screen'; + + @override + String get freeWatch => 'Free watch'; + + @override + String get showMore => 'More'; + + @override + String get qualityUnavailable => 'Unavailable'; + + @override + String get notInterested => 'Not interested'; + + @override + String get clearPlayback => 'Clear-screen playback'; + + @override + String get danmaku => 'Danmaku'; + + @override + String get danmakuUnavailable => 'Coming soon'; + + @override + String get danmakuSettings => 'Danmaku settings'; + + @override + String get offlineCache => 'Offline cache'; + + @override + String get screenCast => 'Cast'; + + @override + String get sleepTimer => 'Sleep timer'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 690217a..518dad1 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1337,7 +1337,7 @@ class AppLocalizationsId extends AppLocalizations { @override String commentsWithCount(int count) { - return 'Komentar ($count)'; + return '$count komentar'; } @override @@ -1364,4 +1364,43 @@ class AppLocalizationsId extends AppLocalizations { @override String get unlockEpisode => 'Buka episode ini'; + + @override + String get language => 'Bahasa'; + + @override + String get fullScreenWatch => 'Layar penuh'; + + @override + String get freeWatch => 'Tonton gratis'; + + @override + String get showMore => 'Selengkapnya'; + + @override + String get qualityUnavailable => 'Tidak tersedia'; + + @override + String get notInterested => 'Tidak tertarik'; + + @override + String get clearPlayback => 'Putar tanpa kontrol'; + + @override + String get danmaku => 'Komentar layar'; + + @override + String get danmakuUnavailable => 'Belum tersedia'; + + @override + String get danmakuSettings => 'Pengaturan komentar layar'; + + @override + String get offlineCache => 'Cache offline'; + + @override + String get screenCast => 'Cast'; + + @override + String get sleepTimer => 'Timer tidur'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a487f6d..7d344c5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1314,7 +1314,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String commentsWithCount(int count) { - return '评论($count)'; + return '$count 条评论'; } @override @@ -1340,4 +1340,43 @@ class AppLocalizationsZh extends AppLocalizations { @override String get unlockEpisode => '解锁本集'; + + @override + String get language => '语言'; + + @override + String get fullScreenWatch => '全屏观看'; + + @override + String get freeWatch => '免费观看'; + + @override + String get showMore => '展开'; + + @override + String get qualityUnavailable => '暂不可用'; + + @override + String get notInterested => '不感兴趣'; + + @override + String get clearPlayback => '清屏播放'; + + @override + String get danmaku => '弹幕'; + + @override + String get danmakuUnavailable => '暂未开放'; + + @override + String get danmakuSettings => '弹幕设置'; + + @override + String get offlineCache => '离线缓存'; + + @override + String get screenCast => '投屏'; + + @override + String get sleepTimer => '定时关闭'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f8be167..05aa597 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -541,7 +541,7 @@ "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, "episodeCountLabel": "共 {count} 集", "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, - "commentsWithCount": "评论({count})", + "commentsWithCount": "{count} 条评论", "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, "textSize": "文字大小", "textSizeSmall": "小", @@ -550,5 +550,18 @@ "watchAdToUnlock": "看广告解锁", "unlockWithVip": "会员解锁", "unlockMethodsVary": "具体解锁方式以作品而定。", - "unlockEpisode": "解锁本集" + "unlockEpisode": "解锁本集", + "language": "语言", + "fullScreenWatch": "全屏观看", + "freeWatch": "免费观看", + "showMore": "展开", + "qualityUnavailable": "暂不可用", + "notInterested": "不感兴趣", + "clearPlayback": "清屏播放", + "danmaku": "弹幕", + "danmakuUnavailable": "暂未开放", + "danmakuSettings": "弹幕设置", + "offlineCache": "离线缓存", + "screenCast": "投屏", + "sleepTimer": "定时关闭" } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 9556f36..d500ed7 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -28,7 +28,6 @@ class DiscoverLogic extends ChangeNotifier { String _lastRequestedQuery = ''; int _searchRequest = 0; bool _disposed = false; - TheaterLanguage language = TheaterLanguage.all; String? selectedCategoryId; bool get hasSearchQuery => search.text.trim().isNotEmpty; @@ -50,13 +49,6 @@ class DiscoverLogic extends ChangeNotifier { void openSearch() => searchFocus.requestFocus(); - /// 选择语言;当前仅改变视觉选中态,不参与数据过滤。 - void selectLanguage(TheaterLanguage value) { - if (language == value) return; - language = value; - notify(); - } - /// 选择分类;空值表示全部。 void selectCategory(String? categoryId) { if (selectedCategoryId == categoryId) return; diff --git a/lib/pages/discover/discover_types.dart b/lib/pages/discover/discover_types.dart index 5899661..d369cc9 100644 --- a/lib/pages/discover/discover_types.dart +++ b/lib/pages/discover/discover_types.dart @@ -7,9 +7,6 @@ import '../../core/data/models/drama.dart'; /// 剧场页与根导航之间的动作。 enum DiscoverPageAction { home, create, my } -/// 剧场语言筛选的视觉选中态。 -enum TheaterLanguage { all, zh, id, ms, en } - /// 剧场页导航回调;没有 `onAction` 时按独立页面处理。 class DiscoverPageCallbacks { const DiscoverPageCallbacks({ diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 222f127..0970307 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -175,7 +175,6 @@ class _DiscoverPageState extends State { : categories.dramasOf(logic.selectedCategoryId!); return [ _categoryChips(l10n, categories), - _languageChips(l10n), if (dramas.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 40), @@ -222,38 +221,6 @@ class _DiscoverPageState extends State { ); } - Widget _languageChips(AppLocalizations l10n) { - final entries = [ - (TheaterLanguage.all, l10n.allLanguages), - (TheaterLanguage.zh, l10n.simplifiedChinese), - (TheaterLanguage.id, l10n.bahasaIndonesia), - (TheaterLanguage.ms, l10n.bahasaMelayu), - (TheaterLanguage.en, l10n.english), - ]; - return Padding( - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - AppDimens.spaceSm, - AppDimens.pageHorizontal, - 0, - ), - child: Wrap( - key: const Key('theater-language-chips'), - spacing: 10, - runSpacing: 10, - children: [ - for (final entry in entries) - _filterChip( - key: Key('theater-language-${entry.$1.name}'), - label: entry.$2, - selected: logic.language == entry.$1, - onSelected: () => logic.selectLanguage(entry.$1), - ), - ], - ), - ); - } - Widget _filterChip({ required Key key, required String label, diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index b3af0f2..7b2c44d 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/locale_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; @@ -122,7 +123,9 @@ class _MyPageState extends State { Widget _body(BuildContext context, AppLocalizations l10n) { final auth = context.watch(); final wallet = context.watch(); + final locale = context.watch(); return ListView( + key: const Key('mine-page-scroll'), padding: const EdgeInsets.fromLTRB( AppDimens.pageHorizontal, 0, @@ -142,7 +145,11 @@ class _MyPageState extends State { const SizedBox(height: AppDimens.spaceMd), MineVipCard(onOpen: () => _openDestination(MineDestination.vip)), const SizedBox(height: AppDimens.spaceMd), - MineMenuList(onOpen: _openDestination), + MineMenuList( + onOpen: _openDestination, + onLanguage: _openLanguage, + languageLabel: _languageLabel(l10n, locale.locale), + ), ], ); } @@ -203,6 +210,27 @@ class _MyPageState extends State { _openProtected(VipPage(onPurchased: () => unawaited(wallet.load()))); } + String _languageLabel(AppLocalizations l10n, Locale? locale) { + return switch (locale?.languageCode) { + 'zh' => l10n.simplifiedChinese, + 'en' => l10n.english, + 'id' => l10n.bahasaIndonesia, + _ => l10n.language, + }; + } + + Future _openLanguage() async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + showDragHandle: false, + builder: (_) => + _LanguageSheet(selected: context.read().locale), + ); + if (selected == null || !mounted) return; + await context.read().select(selected); + } + void _openRecharge() { final wallet = context.read(); _openProtected(RechargePage(onPurchased: () => unawaited(wallet.load()))); @@ -254,6 +282,73 @@ class _MyPageState extends State { } } +class _LanguageSheet extends StatelessWidget { + const _LanguageSheet({required this.selected}); + + final Locale? selected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final options = [ + ( + const Locale('zh'), + const Key('mine-language-zh'), + l10n.simplifiedChinese, + ), + (const Locale('en'), const Key('mine-language-en'), l10n.english), + (const Locale('id'), const Key('mine-language-id'), l10n.bahasaIndonesia), + ]; + return Material( + key: const Key('mine-language-sheet'), + color: AppColors.card, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.language, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + tooltip: l10n.close, + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + for (final option in options) + ListTile( + key: option.$2, + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + title: Text(option.$3), + trailing: selected?.languageCode == option.$1.languageCode + ? const Icon(Icons.check_rounded, color: AppColors.blue) + : null, + onTap: () => Navigator.pop(context, option.$1), + ), + ], + ), + ), + ), + ); + } +} + enum MinePlaceholderKind { works, creator, help } class _MinePlaceholderPage extends StatelessWidget { diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index 3cda8dd..8fc6270 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -265,9 +265,16 @@ class MineVipCard extends StatelessWidget { /// 我的页功能菜单。 class MineMenuList extends StatelessWidget { - const MineMenuList({super.key, required this.onOpen}); + const MineMenuList({ + super.key, + required this.onOpen, + required this.onLanguage, + required this.languageLabel, + }); final ValueChanged onOpen; + final VoidCallback onLanguage; + final String languageLabel; @override Widget build(BuildContext context) { @@ -313,6 +320,8 @@ class MineMenuList extends StatelessWidget { destination: MineDestination.settings, ), _divider(), + _languageRow(context), + _divider(), _row( key: const Key('mine-help'), icon: Icons.help_outline_rounded, @@ -325,6 +334,43 @@ class MineMenuList extends StatelessWidget { ); } + Widget _languageRow(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return ListTile( + key: const Key('mine-language'), + minTileHeight: 48, + contentPadding: const EdgeInsets.symmetric(horizontal: 14), + leading: const Icon( + Icons.language_rounded, + color: AppColors.blue, + size: 20, + ), + title: Text( + l10n.language, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.body, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + languageLabel, + style: const TextStyle(color: AppColors.muted, fontSize: 11), + ), + const SizedBox(width: 4), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 18, + ), + ], + ), + onTap: onLanguage, + ); + } + Widget _row({ required Key key, required IconData icon, diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 4ae4cc9..7d9da94 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -32,9 +32,7 @@ import 'widgets/player_more_sheet.dart'; import 'widgets/player_overlay.dart'; import 'widgets/player_overlay_models.dart'; import 'widgets/player_speed_sheet.dart'; -import 'widgets/player_text_size_sheet.dart'; import 'widgets/player_unlock_sheet.dart'; -import 'widgets/quality_sheet.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -86,7 +84,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; bool _chromeVisible = true; double _playbackSpeed = 1.0; - double _textScale = playerTextSizeStandard; + double _textScale = 1.0; @override void initState() { @@ -132,6 +130,10 @@ class _PlayerPageState extends State with WidgetsBindingObserver { setState(() => _textScale = value); } + void _hideChrome() { + setState(() => _chromeVisible = false); + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -403,10 +405,12 @@ class _PlayerPageState extends State with WidgetsBindingObserver { controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), likeCount: interactions.likeCount(drama.id), + commentCount: null, + favoriteCount: null, saved: context.watch().isSaved(drama.id), playing: logic.session.userWantsPlay, + playbackSpeed: _playbackSpeed, textScale: _textScale, - currentIndex: logic.session.currentIndex, totalEpisodes: logic.visibleIndices.length, ), actions: PlayerOverlayActions( @@ -417,16 +421,18 @@ class _PlayerPageState extends State with WidgetsBindingObserver { logic.session.setUserWantsPlay(!logic.session.userWantsPlay), onClear: () => setState(() => _chromeVisible = false), onExitClear: () => setState(() => _chromeVisible = true), + onDetail: () => unawaited(logic.openDetail(context)), + onSpeed: () => unawaited(_openSpeedSheet(context)), onEpisodes: () => logic.showEpisodes(context), - onNext: logic.hasNextEpisode - ? () => unawaited(logic.playNextEpisode()) - : null, onComments: () => logic.showComments(context, drama), onLike: () => unawaited(_toggleLike(context, interactions, drama)), onSave: () => unawaited(_toggleSave(context, drama)), onShare: () => logic.shareDrama(context, drama), onMore: () => _openMore(context, drama, episode), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 2149186..0733240 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -15,15 +15,6 @@ extension _PlayerViewActions on _PlayerPageState { await logic.setPlaybackSpeed(selected); } - Future _openQualitySheet(BuildContext context) async { - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - builder: (_) => QualitySheet(qualities: logic.qualities), - ); - } - Future _openMore( BuildContext context, Drama drama, @@ -41,33 +32,21 @@ extension _PlayerViewActions on _PlayerPageState { speed: _playbackSpeed, qualities: logic.qualities, textScale: _textScale, - pipAvailable: logic.pictureInPicture.isPlatformSupported, ), actions: PlayerMoreActions( - onSpeed: () => unawaited(_openSpeedSheet(context)), - onQuality: () => unawaited(_openQualitySheet(context)), + onSpeedChanged: (value) { + _updatePlaybackSpeed(value); + unawaited(logic.setPlaybackSpeed(value)); + }, + onTextScaleChanged: _updateTextScale, + onClear: _hideChrome, onDownload: () => requestEpisodeDownload(context, episode.id), - onTextSize: () => unawaited(_openTextSizeSheet(context)), onReport: () => unawaited(_reportDrama(context, drama, l10n)), - onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(logic.enterPip(context)) - : null, ), ), ); } - Future _openTextSizeSheet(BuildContext context) async { - final selected = await showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - showDragHandle: false, - builder: (_) => PlayerTextSizeSheet(selected: _textScale), - ); - if (selected == null || !mounted) return; - _updateTextScale(selected); - } - Future _openUnlockSheet( BuildContext context, int index, diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart index 56c1c19..7214bbb 100644 --- a/lib/pages/player/widgets/comment_tile.dart +++ b/lib/pages/player/widgets/comment_tile.dart @@ -4,8 +4,10 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/comment.dart'; import '../../../core/data/services/comment_controller.dart'; +import '../../../core/formatting/localized_date_time.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 String _deleteAction = 'delete'; @@ -53,15 +55,7 @@ class CommentTile extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: isReply ? 12 : 16, - backgroundColor: AppColors.raised, - child: Icon( - Icons.person_rounded, - size: isReply ? 14 : 18, - color: AppColors.cream, - ), - ), + _avatar(), const SizedBox(width: 12), Expanded( child: Column( @@ -78,6 +72,15 @@ class CommentTile extends StatelessWidget { ), ), ), + if (comment.createdAt != null) + Text( + formatLocalizedDateTime(context, comment.createdAt!), + style: const TextStyle( + color: AppColors.subtle, + fontSize: 10, + ), + ), + const SizedBox(width: 4), PopupMenuButton( key: Key('comment-menu-${comment.id}'), tooltip: l10n.more, @@ -148,4 +151,26 @@ class CommentTile extends StatelessWidget { ), ); } + + Widget _avatar() { + final size = isReply ? 24.0 : 32.0; + if (comment.avatarUrl.isEmpty) { + return CircleAvatar( + radius: size / 2, + backgroundColor: AppColors.raised, + child: Icon( + Icons.person_rounded, + size: isReply ? 14 : 18, + color: AppColors.cream, + ), + ); + } + return ClipOval( + child: SizedBox( + width: size, + height: size, + child: RemoteImage(comment.avatarUrl, fit: BoxFit.cover), + ), + ); + } } diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart index 70140d6..525bc01 100644 --- a/lib/pages/player/widgets/player_more_sheet.dart +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -4,10 +4,12 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; -import 'player_text_size_sheet.dart'; -import 'quality_sheet.dart'; +import 'player_text_scale.dart'; const double _sheetRadius = 20; +const List _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; +const List _qualityOptions = ['AUTO', '720P', '1080P']; +const String _autoQuality = 'AUTO'; /// 更多面板运行状态。 class PlayerMoreState { @@ -16,7 +18,6 @@ class PlayerMoreState { required this.speed, required this.qualities, required this.textScale, - required this.pipAvailable, }); /// 当前倍速。 @@ -25,47 +26,40 @@ class PlayerMoreState { /// 后端下发的清晰度档位。 final List qualities; - /// 当前信息文字缩放比例。 + /// 当前文字缩放比例。 final double textScale; - - /// 设备是否提供画中画入口。 - final bool pipAvailable; } /// 更多面板动作集合。 class PlayerMoreActions { /// 创建动作集合。 const PlayerMoreActions({ - required this.onSpeed, - required this.onQuality, + required this.onSpeedChanged, + required this.onTextScaleChanged, + required this.onClear, required this.onDownload, - required this.onTextSize, required this.onReport, - this.onPip, }); - /// 打开倍速面板。 - final VoidCallback onSpeed; + /// 倍速变化。 + final ValueChanged onSpeedChanged; - /// 打开清晰度面板。 - final VoidCallback onQuality; + /// 字体大小变化。 + final ValueChanged onTextScaleChanged; - /// 下载当前集。 + /// 进入清屏播放。 + final VoidCallback onClear; + + /// 离线缓存当前集。 final VoidCallback onDownload; - /// 打开文字大小面板。 - final VoidCallback onTextSize; - - /// 举报当前剧。 + /// 投诉当前剧。 final VoidCallback onReport; - - /// 进入画中画。 - final VoidCallback? onPip; } -/// K28 播放页更多操作面板。 -class PlayerMoreSheet extends StatelessWidget { - /// 创建更多操作面板。 +/// K29 播放页白色更多面板。 +class PlayerMoreSheet extends StatefulWidget { + /// 创建更多面板。 const PlayerMoreSheet({ super.key, required this.l10n, @@ -82,78 +76,82 @@ class PlayerMoreSheet extends StatelessWidget { /// 面板动作。 final PlayerMoreActions actions; + @override + State createState() => _PlayerMoreSheetState(); +} + +class _PlayerMoreSheetState extends State { + late double _speed = widget.state.speed; + late double _textScale = widget.state.textScale; + @override Widget build(BuildContext context) { + final l10n = widget.l10n; return Container( key: const Key('player-more-sheet'), + constraints: const BoxConstraints(maxHeight: 620), decoration: const BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), ), child: SafeArea( top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 18), child: Column( - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(4, 0, 4, 10), - child: Text( - l10n.more, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 20, - fontWeight: FontWeight.w800, - ), - ), + _header(l10n), + _sectionTitle(l10n.playbackSpeed), + _speedPicker(), + const SizedBox(height: 16), + _sectionTitle(l10n.playbackQuality), + _qualityPicker(l10n), + const SizedBox(height: 12), + _disabledRow( + key: const Key('player-more-not-interested'), + icon: Icons.thumb_down_alt_outlined, + title: l10n.notInterested, ), - _MoreRow( - key: const Key('player-more-speed'), - icon: Icons.speed_rounded, - title: l10n.playbackSpeed, - value: '${state.speed}x', - action: actions.onSpeed, + _enabledRow( + key: const Key('player-more-clear'), + icon: Icons.fullscreen_rounded, + title: l10n.clearPlayback, + onTap: _clear, ), - if (state.qualities.isNotEmpty) - _MoreRow( - key: const Key('player-more-quality'), - icon: Icons.high_quality_rounded, - title: l10n.playbackQuality, - value: state.qualities.contains(QualitySheet.autoQuality) - ? l10n.qualityAuto - : state.qualities.first, - action: actions.onQuality, - ), - _MoreRow( - key: const Key('player-more-download'), + _disabledSwitchRow( + key: const Key('player-more-danmaku'), + icon: Icons.subtitles_rounded, + title: l10n.danmaku, + unavailable: l10n.danmakuUnavailable, + ), + _disabledRow( + key: const Key('player-more-danmaku-settings'), + icon: Icons.tune_rounded, + title: l10n.danmakuSettings, + ), + _enabledRow( + key: const Key('player-more-offline'), icon: Icons.download_rounded, - title: l10n.downloads, - value: '', - action: actions.onDownload, + title: l10n.offlineCache, + onTap: () => _closeThen(widget.actions.onDownload), ), - _MoreRow( - key: const Key('player-more-text-size'), - icon: Icons.format_size_rounded, - title: l10n.textSize, - value: playerTextSizeLabel(l10n, state.textScale), - action: actions.onTextSize, + _textSizeRow(l10n), + _disabledRow( + key: const Key('player-more-cast'), + icon: Icons.cast_rounded, + title: l10n.screenCast, ), - if (state.pipAvailable && actions.onPip != null) - _MoreRow( - key: const Key('player-more-pip'), - icon: Icons.picture_in_picture_alt_rounded, - title: l10n.pictureInPicture, - value: '', - action: actions.onPip!, - ), - _MoreRow( + _disabledRow( + key: const Key('player-more-timer'), + icon: Icons.timer_outlined, + title: l10n.sleepTimer, + ), + _enabledRow( key: const Key('player-more-report'), icon: Icons.flag_outlined, title: l10n.reportDrama, - value: '', - action: actions.onReport, + onTap: () => _closeThen(widget.actions.onReport), ), ], ), @@ -161,6 +159,177 @@ class PlayerMoreSheet extends StatelessWidget { ), ); } + + Widget _header(AppLocalizations l10n) => Row( + children: [ + Expanded( + child: Text( + l10n.more, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + key: const Key('player-more-close'), + tooltip: l10n.close, + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded), + ), + ], + ); + + Widget _sectionTitle(String title) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ); + + Widget _speedPicker() => Wrap( + key: const Key('player-more-speed'), + spacing: 8, + runSpacing: 8, + children: [ + for (final value in _speedOptions) + ChoiceChip( + key: Key('player-more-speed-$value'), + label: Text('${value}x'), + selected: (_speed - value).abs() < 0.001, + showCheckmark: false, + onSelected: (_) { + setState(() => _speed = value); + widget.actions.onSpeedChanged(value); + }, + ), + ], + ); + + Widget _qualityPicker(AppLocalizations l10n) => Wrap( + key: const Key('player-more-quality'), + spacing: 8, + runSpacing: 8, + children: [ + for (final quality in _qualityOptions) _qualityChip(l10n, quality), + ], + ); + + Widget _qualityChip(AppLocalizations l10n, String quality) { + final enabled = widget.state.qualities.contains(quality); + final selected = + quality == _autoQuality && enabled || + !widget.state.qualities.contains(_autoQuality) && + widget.state.qualities.isNotEmpty && + widget.state.qualities.first == quality; + return ChoiceChip( + key: Key('player-more-quality-$quality'), + label: Text(enabled ? quality : '$quality · ${l10n.qualityUnavailable}'), + selected: selected, + showCheckmark: false, + onSelected: enabled ? (_) {} : null, + ); + } + + Widget _textSizeRow(AppLocalizations l10n) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + key: const Key('player-more-text-size'), + children: [ + const Icon(Icons.format_size_rounded, color: AppColors.muted, size: 21), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.textSize, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + PopupMenuButton( + tooltip: l10n.textSize, + initialValue: _textScale, + onSelected: (value) { + setState(() => _textScale = value); + widget.actions.onTextScaleChanged(value); + }, + itemBuilder: (_) => [ + PopupMenuItem( + key: const Key('player-text-size-small'), + value: playerTextSizeSmall, + child: Text(l10n.textSizeSmall), + ), + PopupMenuItem( + key: const Key('player-text-size-standard'), + value: playerTextSizeStandard, + child: Text(l10n.textSizeStandard), + ), + PopupMenuItem( + key: const Key('player-text-size-large'), + value: playerTextSizeLarge, + child: Text(l10n.textSizeLarge), + ), + ], + child: Text( + playerTextSizeLabel(l10n, _textScale), + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + + Widget _enabledRow({ + required Key key, + required IconData icon, + required String title, + required VoidCallback onTap, + }) => + _MoreRow(key: key, icon: icon, title: title, enabled: true, onTap: onTap); + + Widget _disabledRow({ + required Key key, + required IconData icon, + required String title, + }) => _MoreRow(key: key, icon: icon, title: title, enabled: false); + + Widget _disabledSwitchRow({ + required Key key, + required IconData icon, + required String title, + required String unavailable, + }) => _MoreRow( + key: key, + icon: icon, + title: title, + enabled: false, + value: unavailable, + trailing: const Switch(value: false, onChanged: null), + ); + + void _clear() { + Navigator.pop(context); + WidgetsBinding.instance.addPostFrameCallback( + (_) => widget.actions.onClear(), + ); + } + + void _closeThen(VoidCallback action) { + Navigator.pop(context); + WidgetsBinding.instance.addPostFrameCallback((_) => action()); + } } class _MoreRow extends StatelessWidget { @@ -168,57 +337,50 @@ class _MoreRow extends StatelessWidget { super.key, required this.icon, required this.title, - required this.value, - required this.action, + required this.enabled, + this.value = '', + this.trailing, + this.onTap, }); final IconData icon; final String title; + final bool enabled; final String value; - final VoidCallback action; + final Widget? trailing; + final VoidCallback? onTap; @override Widget build(BuildContext context) { + final color = enabled ? AppColors.onSurface : AppColors.subtle; return Material( color: Colors.transparent, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(10), child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - Navigator.pop(context); - WidgetsBinding.instance.addPostFrameCallback((_) => action()); - }, + borderRadius: BorderRadius.circular(10), + onTap: enabled ? onTap : null, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 13), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11), child: Row( children: [ - Icon(icon, color: AppColors.muted, size: 21), + Icon(icon, color: color, size: 21), const SizedBox(width: 12), Expanded( child: Text( title, - style: const TextStyle( - color: AppColors.onSurface, + style: TextStyle( + color: color, fontSize: 14, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, ), ), ), if (value.isNotEmpty) Text( value, - style: const TextStyle( - color: AppColors.blue, - fontSize: 12, - fontWeight: FontWeight.w700, - ), + style: const TextStyle(color: AppColors.subtle, fontSize: 11), ), - const SizedBox(width: 4), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.subtle, - size: 20, - ), + ?trailing, ], ), ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index c44d2e1..ade1564 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -14,19 +14,14 @@ import 'seek_progress_bar.dart'; part 'player_overlay_parts.dart'; -const double _topGradientHeight = 230; -const double _bottomGradientHeight = 390; +const double _topBarHeight = 58; const double _topButtonSize = 36; -const double _titleSize = 24; -const double _dotSize = 7; const double _railButtonSize = 36; const double _railGap = 9; -const double _chipRadius = 999; -const double _dotRadius = 3; -const double _railTopFactor = 0.28; -const double _railTopCompactFactor = 0.18; +const double _fullScreenPillBottom = 246; +const double _panelRadius = 16; -/// K28 竖屏播放页浮层。 +/// K29 竖屏播放页浮层。 class PlayerPlaybackOverlay extends StatelessWidget { /// 创建播放浮层。 const PlayerPlaybackOverlay({ @@ -61,6 +56,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { _top(context), _bottom(context), _rail(context), + _fullScreenPill(), ], if (!controlsVisible) Positioned( @@ -86,18 +82,14 @@ class PlayerPlaybackOverlay extends StatelessWidget { } Widget _top(BuildContext context) { - final title = data.episode.title.trim().isEmpty - ? data.l10n.episodeLabel(data.episode.number) - : data.episode.title; - final series = localizedDramaTitle(data.l10n, data.drama); return Positioned( top: 0, left: 0, right: 0, child: Container( key: const Key('player-controls-top'), - height: _topGradientHeight, - padding: const EdgeInsets.fromLTRB(14, 4, 14, 20), + height: _topBarHeight, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -105,89 +97,42 @@ class PlayerPlaybackOverlay extends StatelessWidget { colors: [Color(0xCC000000), Color(0x00000000)], ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Row( - children: [ - if (actions.onBack != null) - _TopButton( - key: const Key('player-back'), - tooltip: data.l10n.backToSeries, - icon: Icons.chevron_left_rounded, - onPressed: actions.onBack!, - ) - else - const SizedBox(width: _topButtonSize), - const Spacer(), - _TopButton( - key: const Key('player-clear-screen'), - tooltip: data.l10n.clearScreen, - icon: Icons.fullscreen_rounded, - onPressed: actions.onClear, + if (actions.onBack != null) + _TopButton( + key: const Key('player-back'), + tooltip: data.l10n.backToSeries, + icon: Icons.chevron_left_rounded, + onPressed: actions.onBack!, + ) + else + const SizedBox(width: _topButtonSize), + const SizedBox(width: 8), + Expanded( + child: Text( + data.l10n.episodeLabel(data.episode.number), + key: const Key('player-episode-heading'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, ), - const SizedBox(width: AppDimens.spaceSm), - _TopButton( - key: const Key('player-more'), - tooltip: data.l10n.more, - icon: Icons.more_horiz_rounded, - onPressed: actions.onMore, - ), - ], - ), - const SizedBox(height: 18), - Row( - children: [ - Flexible( - child: Text( - title, - key: const Key('player-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: AppColors.onPrimary, - fontFamily: 'Outfit', - fontSize: _titleSize * data.textScale, - fontWeight: FontWeight.w800, - ), - ), - ), - const SizedBox(width: 7), - Container( - key: const Key('player-title-dot'), - width: _dotSize, - height: _dotSize, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - '$series · ${data.l10n.episodeLabel(data.episode.number)}', - key: const Key('player-subtitle'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.white70, - fontSize: 14 * data.textScale, - fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 10), - Row( - children: [ - if (_genre.isNotEmpty) - _infoChip(const Key('player-genre-chip'), _genre), - const Spacer(), - _PageDots( - key: const Key('player-page-dots'), - current: data.currentIndex, - total: data.totalEpisodes, - ), - ], + _SpeedEntry( + key: const Key('player-speed-entry'), + speed: data.playbackSpeed, + onTap: actions.onSpeed, + ), + const SizedBox(width: 8), + _TopButton( + key: const Key('player-more'), + tooltip: data.l10n.more, + icon: Icons.more_vert_rounded, + onPressed: actions.onMore, ), ], ), @@ -196,18 +141,17 @@ class PlayerPlaybackOverlay extends StatelessWidget { } Widget _rail(BuildContext context) { - final likeCount = data.likeCount; final compact = MediaQuery.sizeOf(context).height < 700; return Positioned( right: AppDimens.spaceMd, - top: - MediaQuery.sizeOf(context).height * - (compact ? _railTopCompactFactor : _railTopFactor), + top: _topBarHeight + (compact ? 18 : 54), child: _Rail( l10n: data.l10n, liked: data.liked, saved: data.saved, - likeCount: likeCount, + likeCount: data.likeCount, + favoriteCount: data.favoriteCount, + commentCount: data.commentCount, onLike: actions.onLike, onComments: actions.onComments, onSave: actions.onSave, @@ -224,193 +168,185 @@ class PlayerPlaybackOverlay extends StatelessWidget { left: 0, right: 0, bottom: 0, - child: Stack( - children: [ - const Positioned.fill( - child: IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0x00000000), - Color(0xDD000000), - Color(0xF2000000), - ], + child: Container( + key: const Key('player-controls-bottom'), + padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), + decoration: const BoxDecoration( + color: Color(0xED000000), + borderRadius: BorderRadius.vertical( + top: Radius.circular(_panelRadius), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailRow(series), + const SizedBox(height: 6), + _EpisodeSynopsis( + key: const Key('player-episode-synopsis'), + l10n: data.l10n, + episodeNumber: data.episode.number, + synopsis: synopsis, + textScale: data.textScale, + ), + if (data.authorNote.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + data.authorNote, + key: const Key('player-author-note'), + style: const TextStyle(color: Colors.white60, fontSize: 11), + ), + ], + const SizedBox(height: 6), + _PlayerTimeLabel( + key: const Key('player-time-label'), + controller: data.controller, + ), + SeekProgressBar( + key: const Key('player-progress'), + valueListenable: data.controller, + semanticsLabel: data.l10n.seekVideo, + showTimes: true, + compact: true, + onScrubStart: actions.onScrubStart, + onSeek: actions.onSeek, + ), + _bottomActions(), + ], + ), + ), + ); + } + + Widget _detailRow(String series) { + return Material( + color: Colors.transparent, + child: InkWell( + key: const Key('player-open-detail'), + onTap: actions.onDetail, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded( + child: Text( + series, + key: const Key('player-drama-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.onPrimary, + fontFamily: 'Outfit', + fontSize: 19 * data.textScale, + fontWeight: FontWeight.w800, ), ), ), + const SizedBox(width: 6), + const Icon( + Icons.chevron_right_rounded, + color: Colors.white70, + size: 20, + ), + ], + ), + ), + ), + ); + } + + Widget _bottomActions() { + return Row( + children: [ + Expanded( + child: Material( + color: Colors.transparent, + child: InkWell( + key: const Key('player-episodes'), + onTap: actions.onEpisodes, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Flexible( + child: Text( + '${data.l10n.episodes} · ' + '${data.l10n.allEpisodesBadge(data.totalEpisodes)} · ' + '${data.l10n.freeWatch}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 3), + const Icon( + Icons.keyboard_arrow_up_rounded, + color: AppColors.onPrimary, + size: 18, + ), + ], + ), + ), ), ), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: _bottomGradientHeight), + ), + if (actions.onPip != null) ...[ + const SizedBox(width: 8), + _TopButton( + key: const Key('player-pip'), + tooltip: data.l10n.pictureInPicture, + icon: Icons.picture_in_picture_alt_rounded, + onPressed: actions.onPip!, + ), + ], + ], + ); + } + + Widget _fullScreenPill() { + return Positioned( + left: 0, + right: 0, + bottom: _fullScreenPillBottom, + child: Center( + child: Material( + color: const Color(0x99000000), + borderRadius: BorderRadius.circular(999), + child: InkWell( + key: const Key('player-fullscreen-toggle'), + borderRadius: BorderRadius.circular(999), + onTap: actions.onClear, child: Padding( - key: const Key('player-controls-bottom'), - padding: const EdgeInsets.fromLTRB(14, 48, 14, 10), - child: Column( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + child: Row( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Flexible( - child: Text( - series, - key: const Key('player-drama-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: AppColors.onPrimary, - fontFamily: 'Outfit', - fontSize: 22 * data.textScale, - fontWeight: FontWeight.w800, - ), - ), - ), - const SizedBox(width: 8), - Text( - data.l10n.episodeLabel(data.episode.number), - key: const Key('player-episode-label'), - style: TextStyle( - color: AppColors.blue, - fontSize: 12 * data.textScale, - fontWeight: FontWeight.w700, - ), - ), - ], + const Icon( + Icons.fullscreen_rounded, + color: AppColors.onPrimary, + size: 16, ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: _genre.isEmpty - ? const SizedBox.shrink() - : _softChip(_genre), - ), - ), - const SizedBox(width: 8), - SizedBox( - height: AppDimens.buttonSmall, - child: FilledButton( - key: const Key('player-follow'), - onPressed: actions.onSave, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 16), - ), - child: Text( - data.saved - ? data.l10n.followingTab - : data.l10n.followSeries, - ), - ), - ), - ], - ), - const SizedBox(height: 8), + const SizedBox(width: 5), Text( - synopsis, - key: const Key('player-synopsis'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.white70, - fontSize: 12 * data.textScale, - height: 1.45, + data.l10n.fullScreenWatch, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, ), ), - const SizedBox(height: 4), - _TimeLabel( - key: const Key('player-time-label'), - controller: data.controller, - ), - SeekProgressBar( - key: const Key('player-progress'), - valueListenable: data.controller, - semanticsLabel: data.l10n.seekVideo, - showTimes: true, - compact: true, - onScrubStart: actions.onScrubStart, - onSeek: actions.onSeek, - ), - const SizedBox(height: 4), - Row( - children: [ - Expanded( - child: _ActionButton( - key: const Key('player-episodes'), - icon: Icons.grid_view_rounded, - label: data.l10n.episodes, - onTap: actions.onEpisodes, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _ActionButton( - key: const Key('player-next'), - icon: Icons.skip_next_rounded, - label: data.l10n.nextEpisode, - onTap: actions.onNext, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _ActionButton( - key: const Key('player-bottom-more'), - icon: Icons.more_horiz_rounded, - label: data.l10n.more, - onTap: actions.onMore, - ), - ), - ], - ), ], ), ), ), - ], + ), ), ); } - - Widget _infoChip(Key key, String label) => Container( - key: key, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(_chipRadius), - ), - child: Text( - label, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 10, - fontWeight: FontWeight.w700, - ), - ), - ); - - Widget _softChip(String label) => Container( - constraints: const BoxConstraints(maxWidth: 180), - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(_chipRadius), - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ); - - String get _genre => - data.drama.genre.isNotEmpty ? data.drama.genre : data.drama.category; } diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index e5a1e15..873753c 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -16,11 +16,14 @@ class PlayerOverlayData { required this.controller, required this.liked, required this.likeCount, + required this.commentCount, + required this.favoriteCount, required this.saved, required this.playing, + required this.playbackSpeed, required this.textScale, - required this.currentIndex, required this.totalEpisodes, + this.authorNote = '', }); /// 当前语言包。 @@ -41,20 +44,29 @@ class PlayerOverlayData { /// 点赞数;未知时隐藏。 final int? likeCount; + /// 评论数;未知时隐藏。 + final int? commentCount; + + /// 收藏数;未知时隐藏。 + final int? favoriteCount; + /// 是否已收藏。 final bool saved; /// 是否处于播放状态。 final bool playing; + /// 当前倍速。 + final double playbackSpeed; + /// 信息文字缩放比例。 final double textScale; - /// 当前集索引。 - final int currentIndex; - /// 可见集数量。 final int totalEpisodes; + + /// 作者声明;没有后端字段时为空。 + final String authorNote; } /// 播放浮层动作集合。 @@ -65,6 +77,8 @@ class PlayerOverlayActions { required this.onTogglePlay, required this.onClear, required this.onExitClear, + required this.onDetail, + required this.onSpeed, required this.onEpisodes, required this.onComments, required this.onLike, @@ -73,7 +87,7 @@ class PlayerOverlayActions { required this.onMore, required this.onScrubStart, required this.onSeek, - this.onNext, + this.onPip, }); /// 返回上一页。 @@ -88,6 +102,12 @@ class PlayerOverlayActions { /// 退出清屏。 final VoidCallback onExitClear; + /// 打开详情。 + final VoidCallback onDetail; + + /// 打开顶部倍速入口。 + final VoidCallback onSpeed; + /// 打开选集。 final VoidCallback onEpisodes; @@ -106,12 +126,12 @@ class PlayerOverlayActions { /// 打开更多面板。 final VoidCallback onMore; - /// 下一集。 - final VoidCallback? onNext; - /// 开始拖动进度。 final VoidCallback? onScrubStart; /// 提交进度跳转。 final Future Function(Duration)? onSeek; + + /// 进入小窗播放。 + final VoidCallback? onPip; } diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 7a2ce76..f472365 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -22,8 +22,8 @@ class _TopButton extends StatelessWidget { tooltip: tooltip, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: AppColors.card, - foregroundColor: AppColors.onSurface, + backgroundColor: const Color(0x80000000), + foregroundColor: AppColors.onPrimary, ), onPressed: onPressed, icon: Icon(icon, size: 18), @@ -32,12 +32,55 @@ class _TopButton extends StatelessWidget { } } +class _SpeedEntry extends StatelessWidget { + const _SpeedEntry({super.key, required this.speed, required this.onTap}); + + final double speed; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0x80000000), + borderRadius: BorderRadius.circular(999), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.speed_rounded, + color: AppColors.onPrimary, + size: 15, + ), + const SizedBox(width: 4), + Text( + '${speed}x', + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } +} + class _Rail extends StatelessWidget { const _Rail({ required this.l10n, required this.liked, required this.saved, required this.likeCount, + required this.favoriteCount, + required this.commentCount, required this.onLike, required this.onComments, required this.onSave, @@ -49,6 +92,8 @@ class _Rail extends StatelessWidget { final bool liked; final bool saved; final int? likeCount; + final int? favoriteCount; + final int? commentCount; final VoidCallback onLike; final VoidCallback onComments; final VoidCallback onSave; @@ -61,25 +106,26 @@ class _Rail extends StatelessWidget { key: const Key('player-interaction-rail'), children: [ _RailButton( - key: const Key('player-rail-like'), - icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - color: liked ? AppColors.blueSoft : AppColors.onPrimary, - label: likeCount?.toString() ?? l10n.like, - onTap: onLike, + key: const Key('player-rail-save'), + icon: saved ? Icons.star_rounded : Icons.star_border_rounded, + label: favoriteCount?.toString() ?? l10n.favorite, + active: saved, + onTap: onSave, compact: compact, ), _RailButton( key: const Key('player-rail-comments'), icon: Icons.chat_bubble_outline_rounded, - label: l10n.comments, + label: commentCount?.toString() ?? l10n.comments, onTap: onComments, compact: compact, ), _RailButton( - key: const Key('player-rail-save'), - icon: saved ? Icons.star_rounded : Icons.star_border_rounded, - label: l10n.favorite, - onTap: onSave, + key: const Key('player-rail-like'), + icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + label: likeCount?.toString() ?? l10n.like, + active: liked, + onTap: onLike, compact: compact, ), _RailButton( @@ -101,19 +147,19 @@ class _RailButton extends StatelessWidget { required this.label, required this.onTap, required this.compact, - this.color = AppColors.onPrimary, + this.active = false, }); final IconData icon; final String label; final VoidCallback onTap; final bool compact; - final Color color; + final bool active; @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.only(bottom: compact ? 2 : _railGap), + padding: EdgeInsets.only(bottom: compact ? 3 : _railGap), child: Column( children: [ SizedBox.square( @@ -122,8 +168,8 @@ class _RailButton extends StatelessWidget { onPressed: onTap, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: AppColors.overlayDark, - foregroundColor: color, + backgroundColor: const Color(0x80000000), + foregroundColor: active ? AppColors.blue : AppColors.onPrimary, ), icon: Icon(icon, size: 18), ), @@ -144,28 +190,78 @@ class _RailButton extends StatelessWidget { } } -class _PageDots extends StatelessWidget { - const _PageDots({super.key, required this.current, required this.total}); +class _EpisodeSynopsis extends StatefulWidget { + const _EpisodeSynopsis({ + super.key, + required this.l10n, + required this.episodeNumber, + required this.synopsis, + required this.textScale, + }); - final int current; - final int total; + final AppLocalizations l10n; + final int episodeNumber; + final String synopsis; + final double textScale; + + @override + State<_EpisodeSynopsis> createState() => _EpisodeSynopsisState(); +} + +class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { + bool _expanded = false; @override Widget build(BuildContext context) { - if (total <= 1) return const SizedBox.shrink(); - final start = (current - 2).clamp(0, total > 5 ? total - 5 : 0); - final end = (start + 5).clamp(0, total); - return Row( - mainAxisSize: MainAxisSize.min, + final canExpand = widget.synopsis.trim().length > 28; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var index = start; index < end; index++) - Container( - width: index == current ? 14 : _dotSize, - height: _dotSize, - margin: const EdgeInsets.only(left: 4), - decoration: BoxDecoration( - color: index == current ? AppColors.blue : Colors.white54, - borderRadius: BorderRadius.circular(_dotRadius), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.l10n.episodeLabel(widget.episodeNumber), + key: const Key('player-episode-label'), + style: TextStyle( + color: AppColors.blue, + fontSize: 11 * widget.textScale, + fontWeight: FontWeight.w700, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('|', style: TextStyle(color: Colors.white38)), + ), + Expanded( + child: Text( + widget.synopsis, + key: const Key('player-synopsis'), + maxLines: _expanded ? 4 : 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white70, + fontSize: 11 * widget.textScale, + height: 1.45, + ), + ), + ), + ], + ), + if (canExpand) + GestureDetector( + key: const Key('player-synopsis-toggle'), + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + _expanded ? widget.l10n.showLess : widget.l10n.showMore, + style: const TextStyle( + color: Colors.white54, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), ), ), ], @@ -173,8 +269,8 @@ class _PageDots extends StatelessWidget { } } -class _TimeLabel extends StatelessWidget { - const _TimeLabel({super.key, required this.controller}); +class _PlayerTimeLabel extends StatelessWidget { + const _PlayerTimeLabel({super.key, required this.controller}); final VideoPlayerController? controller; @@ -188,13 +284,15 @@ class _TimeLabel extends StatelessWidget { ); } - Widget _label(Duration position, Duration duration) => Text( - '${_format(position)} / ${_format(duration)}', - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 10, - fontWeight: FontWeight.w600, + Widget _label(Duration position, Duration duration) => Align( + alignment: Alignment.centerRight, + child: Text( + '${_format(position)} / ${_format(duration)}', + style: const TextStyle( + color: Colors.white60, + fontSize: 9, + fontWeight: FontWeight.w600, + ), ), ); @@ -204,40 +302,3 @@ class _TimeLabel extends StatelessWidget { return '$minutes:$seconds'; } } - -class _ActionButton extends StatelessWidget { - const _ActionButton({ - super.key, - required this.icon, - required this.label, - required this.onTap, - }); - - final IconData icon; - final String label; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 38, - child: FilledButton.icon( - onPressed: onTap, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8), - backgroundColor: AppColors.card, - foregroundColor: AppColors.onSurface, - disabledBackgroundColor: Colors.black38, - disabledForegroundColor: Colors.white54, - ), - icon: Icon(icon, size: 16), - label: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w700), - ), - ), - ); - } -} diff --git a/lib/pages/player/widgets/player_speed_sheet.dart b/lib/pages/player/widgets/player_speed_sheet.dart index 7a42449..475ac5c 100644 --- a/lib/pages/player/widgets/player_speed_sheet.dart +++ b/lib/pages/player/widgets/player_speed_sheet.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; -const List _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; +const List _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; const double _sheetRadius = 20; /// 播放倍速选择弹窗。 diff --git a/lib/pages/player/widgets/player_text_scale.dart b/lib/pages/player/widgets/player_text_scale.dart new file mode 100644 index 0000000..1259f53 --- /dev/null +++ b/lib/pages/player/widgets/player_text_scale.dart @@ -0,0 +1,19 @@ +// Author: fruit.com.cn + +import '../../../l10n/app_localizations.dart'; + +/// 播放信息小号文字比例。 +const double playerTextSizeSmall = 0.9; + +/// 播放信息标准文字比例。 +const double playerTextSizeStandard = 1.0; + +/// 播放信息大号文字比例。 +const double playerTextSizeLarge = 1.15; + +/// 返回播放信息文字档位的本地化名称。 +String playerTextSizeLabel(AppLocalizations l10n, double scale) { + if (scale <= playerTextSizeSmall) return l10n.textSizeSmall; + if (scale >= playerTextSizeLarge) return l10n.textSizeLarge; + return l10n.textSizeStandard; +} diff --git a/lib/pages/player/widgets/player_text_size_sheet.dart b/lib/pages/player/widgets/player_text_size_sheet.dart deleted file mode 100644 index fa284f5..0000000 --- a/lib/pages/player/widgets/player_text_size_sheet.dart +++ /dev/null @@ -1,110 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -import '../../../core/theme/app_colors.dart'; -import '../../../l10n/app_localizations.dart'; - -const double playerTextSizeSmall = 0.9; -const double playerTextSizeStandard = 1.0; -const double playerTextSizeLarge = 1.15; -const double _sheetRadius = 20; - -/// 返回播放信息文字档位的本地化名称。 -String playerTextSizeLabel(AppLocalizations l10n, double scale) { - if (scale <= playerTextSizeSmall) return l10n.textSizeSmall; - if (scale >= playerTextSizeLarge) return l10n.textSizeLarge; - return l10n.textSizeStandard; -} - -/// 播放页文字大小选择弹窗。 -class PlayerTextSizeSheet extends StatelessWidget { - /// 创建文字大小选择弹窗。 - const PlayerTextSizeSheet({super.key, required this.selected}); - - /// 当前文字缩放比例。 - final double selected; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final options = [ - (playerTextSizeSmall, 'small', l10n.textSizeSmall), - (playerTextSizeStandard, 'standard', l10n.textSizeStandard), - (playerTextSizeLarge, 'large', l10n.textSizeLarge), - ]; - return Container( - key: const Key('player-text-size-sheet'), - decoration: const BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), - ), - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.textSize, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 18, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 16), - for (final option in options) ...[ - _option(context, option.$1, option.$2, option.$3), - if (option != options.last) const SizedBox(height: 8), - ], - ], - ), - ), - ), - ); - } - - Widget _option( - BuildContext context, - double scale, - String keySuffix, - String label, - ) { - final active = (scale - selected).abs() < 0.01; - return Material( - key: Key('player-text-size-$keySuffix'), - color: active ? AppColors.quick : AppColors.raised, - borderRadius: BorderRadius.circular(12), - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => Navigator.pop(context, scale), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: TextStyle( - color: active ? AppColors.blue : AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ), - if (active) - const Icon( - Icons.check_circle_rounded, - color: AppColors.blue, - size: 20, - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/player/widgets/quality_sheet.dart b/lib/pages/player/widgets/quality_sheet.dart deleted file mode 100644 index fcb6c50..0000000 --- a/lib/pages/player/widgets/quality_sheet.dart +++ /dev/null @@ -1,85 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -import '../../../core/theme/app_colors.dart'; -import '../../../l10n/app_localizations.dart'; - -/// 清晰度档位面板:展示后端下发档位;HLS 多码率由播放器自动切换。 -class QualitySheet extends StatelessWidget { - const QualitySheet({ - super.key, - required this.qualities, - this.selected = autoQuality, - }); - - static const String autoQuality = 'AUTO'; - - final List qualities; - final String selected; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return SafeArea( - child: Column( - key: const Key('player-quality-sheet'), - mainAxisSize: MainAxisSize.min, - children: [ - _header(l10n), - for (final quality in qualities) _qualityTile(l10n, quality), - _hint(l10n), - ], - ), - ); - } - - Widget _header(AppLocalizations l10n) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - l10n.playbackQuality, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ), - ); - } - - Widget _qualityTile(AppLocalizations l10n, String quality) { - final active = quality == selected; - return ListTile( - key: Key('player-quality-$quality'), - dense: true, - title: Text( - _label(l10n, quality), - style: TextStyle( - color: active ? AppColors.blue : AppColors.cream, - fontSize: 13, - fontWeight: active ? FontWeight.w700 : FontWeight.w500, - ), - ), - trailing: active - ? const Icon(Icons.check_rounded, color: AppColors.blue, size: 18) - : null, - ); - } - - Widget _hint(AppLocalizations l10n) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 6, 20, 14), - child: Text( - l10n.qualityAutoHint, - style: const TextStyle(color: AppColors.muted, fontSize: 11), - ), - ); - } - - String _label(AppLocalizations l10n, String quality) => - quality == autoQuality ? l10n.qualityAuto : quality; -} diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 5712889..933e16e 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -68,7 +68,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Second page'), findsOneWidget); - expect(find.text('Comments (2)'), findsOneWidget); + expect(find.text('2 comments'), findsOneWidget); expect(repository.pageCalls.last.cursor, '10'); }); diff --git a/test/core/locale_controller_test.dart b/test/core/locale_controller_test.dart new file mode 100644 index 0000000..9b3905b --- /dev/null +++ b/test/core/locale_controller_test.dart @@ -0,0 +1,39 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/locale_controller.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'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + test('should_restore_saved_locale_when_controller_loads', () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + LocaleController.preferenceKey: 'zh', + }); + final controller = LocaleController(); + + await controller.load(); + + expect(controller.locale?.languageCode, 'zh'); + controller.dispose(); + }); + + test('should_persist_selected_locale_for_next_launch', () async { + final controller = LocaleController(); + await controller.select(const Locale('id')); + + final restored = LocaleController(); + await restored.load(); + + expect(restored.locale?.languageCode, 'id'); + controller.dispose(); + restored.dispose(); + }); +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 5c638c1..2080cb0 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -32,7 +32,7 @@ void main() { expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(tester.takeException(), isNull); }); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index ede4e89..9a17708 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -86,20 +86,15 @@ void main() { expect(field.focusNode?.hasFocus, isTrue); }); - testWidgets('should_select_theater_language_without_filtering_catalog', ( - tester, - ) async { + testWidgets('should_not_render_theater_language_filters', (tester) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - expect(_chip(tester, 'theater-language-all').selected, isTrue); - expect(_chip(tester, 'theater-language-id').selected, isFalse); - await tester.tap(find.byKey(const Key('theater-language-id'))); - await tester.pumpAndSettle(); - - expect(_chip(tester, 'theater-language-all').selected, isFalse); - expect(_chip(tester, 'theater-language-id').selected, isTrue); + expect(find.byKey(const Key('theater-language-chips')), findsNothing); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('discover-search')), findsOneWidget); + expect(find.byKey(const Key('discover-refresh')), findsOneWidget); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsOneWidget, @@ -202,7 +197,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(tester.takeException(), isNull); }); } diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 4e7047d..62eb05a 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -60,11 +60,12 @@ void main() { Key('mine-creator-center'), Key('mine-notices'), Key('mine-settings'), + Key('mine-language'), Key('mine-help'), ]) { expect(find.byKey(key), findsOneWidget); } - expect(find.byKey(const Key('mine-language')), findsNothing); + expect(find.byKey(const Key('mine-language')), findsOneWidget); expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); expect(find.byKey(const Key('mine-library-tab-1')), findsNothing); expect(find.byKey(const Key('mine-library-tab-2')), findsNothing); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 7464da8..8c5dd9d 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -40,14 +40,9 @@ void main() { expect(find.byType(PageView), findsOneWidget); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-more-quality')), findsOneWidget); - expect(find.byKey(const Key('player-more-download')), findsOneWidget); - expect(find.byKey(const Key('player-more-pip')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-more-quality'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-quality-sheet')), findsOneWidget); - expect(find.text('720P'), findsOneWidget); - await tester.binding.handlePopRoute(); + expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget); + expect(find.byKey(const Key('player-more-offline')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more-close'))); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); @@ -111,7 +106,7 @@ void main() { expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); - await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsNothing); @@ -184,7 +179,7 @@ void main() { await _pumpApp(tester); await _openSeriesPlayer(tester); - await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsNothing); @@ -208,14 +203,11 @@ void main() { await _pumpApp(tester); await _openSeriesPlayer(tester); - await tester.tap(find.byKey(const Key('player-more'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-more-speed')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-more-speed'))); + await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-sheet')), findsOneWidget); - for (final value in ['0.5', '0.75', '1.0', '1.25', '1.5', '2.0']) { + for (final value in ['0.75', '1.0', '1.25', '1.5', '2.0', '3.0']) { expect(find.byKey(Key('player-speed-$value')), findsOneWidget); } @@ -223,11 +215,11 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-sheet')), findsNothing); - await tester.tap(find.byKey(const Key('player-more'))); + await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); expect( find.descendant( - of: find.byKey(const Key('player-more-speed')), + of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.5x'), ), findsOneWidget, @@ -240,38 +232,22 @@ void main() { await _pumpApp(tester); await _openSeriesPlayer(tester); - await tester.tap(find.byKey(const Key('player-more'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-more-speed'))); + await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-speed-1.5'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); await tester.pump(); expect(find.byKey(const Key('player-more')), findsNothing); await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); await tester.pump(); - await tester.tap(find.byKey(const Key('player-more'))); - await tester.pumpAndSettle(); - expect( - find.descendant( - of: find.byKey(const Key('player-more-speed')), - matching: find.text('1.5x'), - ), - findsOneWidget, - ); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-more'))); - await tester.pumpAndSettle(); expect( find.descendant( - of: find.byKey(const Key('player-more-speed')), + of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.5x'), ), findsOneWidget, @@ -341,25 +317,70 @@ void main() { expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); expect(find.byKey(const Key('player-more-speed')), findsOneWidget); - expect(find.byKey(const Key('player-more-quality')), findsOneWidget); - expect(find.byKey(const Key('player-more-download')), findsOneWidget); + expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget); + expect(find.byKey(const Key('player-more-offline')), findsOneWidget); + expect(find.byKey(const Key('player-more-clear')), findsOneWidget); + expect(find.byKey(const Key('player-more-danmaku')), findsOneWidget); + expect( + find.byKey(const Key('player-more-danmaku-settings')), + findsOneWidget, + ); + expect(find.byKey(const Key('player-more-cast')), findsOneWidget); + expect(find.byKey(const Key('player-more-timer')), findsOneWidget); expect(find.byKey(const Key('player-more-text-size')), findsOneWidget); expect(find.byKey(const Key('player-more-report')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-more-text-size'))); + await tester.tap(find.byTooltip('Text size')); await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-text-size-sheet')), findsOneWidget); + expect(find.byKey(const Key('player-text-size-large')), findsOneWidget); await tester.tap(find.byKey(const Key('player-text-size-large'))); await tester.pumpAndSettle(); + expect(find.text('Large'), findsOneWidget); + }); + + testWidgets('should_disable_quality_not_supplied_by_ticket', (tester) async { + await _pumpApp( + tester, + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'city-lights-demo-1', + accessible: true, + accessType: PlayTicket.free, + playUrl: '/files/city-lights-demo-1.mp4', + qualities: ['AUTO'], + ), + ), + ); + await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); + expect( - find.descendant( - of: find.byKey(const Key('player-more-text-size')), - matching: find.text('Large'), - ), - findsOneWidget, + tester + .widget(find.byKey(const Key('player-more-quality-AUTO'))) + .onSelected, + isNotNull, ); + expect( + tester + .widget(find.byKey(const Key('player-more-quality-720P'))) + .onSelected, + isNull, + ); + expect(find.text('720P · Unavailable'), findsOneWidget); + }); + + testWidgets('should_enter_clear_screen_from_more_panel', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more-clear'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); }); testWidgets('should_open_unlock_panel_with_honest_method_states', ( @@ -395,14 +416,10 @@ void main() { void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-back')), findsOneWidget); - expect(find.byKey(const Key('player-title')), findsOneWidget); - expect(find.byKey(const Key('player-title-dot')), findsOneWidget); - expect(find.byKey(const Key('player-subtitle')), findsOneWidget); - expect(find.byKey(const Key('player-genre-chip')), findsOneWidget); - expect(find.byKey(const Key('player-page-dots')), findsOneWidget); + expect(find.byKey(const Key('player-episode-heading')), findsOneWidget); + expect(find.byKey(const Key('player-speed-entry')), findsOneWidget); expect(find.byKey(const Key('player-drama-title')), findsOneWidget); expect(find.byKey(const Key('player-episode-label')), findsOneWidget); - expect(find.byKey(const Key('player-follow')), findsOneWidget); expect(find.byKey(const Key('player-synopsis')), findsOneWidget); expect(find.byKey(const Key('player-time-label')), findsOneWidget); expect(find.byKey(const Key('player-more')), findsOneWidget); @@ -412,8 +429,14 @@ void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(find.byKey(const Key('player-episodes')), findsOneWidget); expect(find.byKey(const Key('player-rail-comments')), findsOneWidget); - expect(find.byKey(const Key('player-next')), findsOneWidget); + expect(find.byKey(const Key('player-fullscreen-toggle')), findsOneWidget); expect(find.byType(Slider), findsOneWidget); + expect(find.byKey(const Key('player-title')), findsNothing); + expect(find.byKey(const Key('player-subtitle')), findsNothing); + expect(find.byKey(const Key('player-genre-chip')), findsNothing); + expect(find.byKey(const Key('player-page-dots')), findsNothing); + expect(find.byKey(const Key('player-clear-screen')), findsNothing); + expect(find.byKey(const Key('player-next')), findsNothing); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byKey(const Key('player-rail-like')), findsOneWidget); diff --git a/test/player/quality_sheet_test.dart b/test/player/quality_sheet_test.dart deleted file mode 100644 index 8ff9f6f..0000000 --- a/test/player/quality_sheet_test.dart +++ /dev/null @@ -1,32 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:fruitshorts/l10n/app_localizations.dart'; -import 'package:fruitshorts/pages/player/widgets/quality_sheet.dart'; - -void main() { - testWidgets('should_list_every_returned_quality_when_sheet_opens', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const Scaffold( - body: QualitySheet(qualities: ['AUTO', '720P', '1080P']), - ), - ), - ); - - expect(find.text('Auto'), findsOneWidget); - expect(find.text('720P'), findsOneWidget); - expect(find.text('1080P'), findsOneWidget); - expect(find.byIcon(Icons.check_rounded), findsOneWidget); - expect( - find.text('HLS adjusts the bitrate automatically for your connection.'), - findsOneWidget, - ); - }); -} diff --git a/test/watch/watch_history_provider_test.dart b/test/watch/watch_history_provider_test.dart index aef7233..31a3fd7 100644 --- a/test/watch/watch_history_provider_test.dart +++ b/test/watch/watch_history_provider_test.dart @@ -42,9 +42,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -520)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Watch History')); + await tester.tap(find.byKey(const Key('mine-history'))); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); diff --git a/test/widget_test.dart b/test/widget_test.dart index 709f47c..40b6905 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -72,7 +72,7 @@ void main() { await tester.pumpAndSettle(); expect( - tester.widget(find.byKey(const Key('player-title'))).data, + tester.widget(find.byKey(const Key('player-episode-heading'))).data, contains('Episode 2'), ); expect(created, hasLength(3)); @@ -135,9 +135,7 @@ void main() { ); }); - testWidgets('should_follow_system_locale_without_language_switch', ( - tester, - ) async { + testWidgets('should_show_language_entry_on_mine_page', (tester) async { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); expect(find.byTooltip('Captions'), findsNothing); @@ -146,7 +144,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Vicky sheng'), findsOneWidget); expect(find.text('Coins'), findsOneWidget); - expect(find.text('App Language'), findsNothing); + expect(find.byKey(const Key('mine-language')), findsOneWidget); expect(find.byKey(const Key('mine-wallet')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-theater'))); @@ -154,6 +152,30 @@ void main() { expect(find.text('Theater'), findsWidgets); }); + testWidgets('should_switch_app_language_from_mine_menu', (tester) async { + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const Key('mine-page-scroll')), + const Offset(0, -320), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-language'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mine-language-sheet')), findsOneWidget); + await tester.tap(find.byKey(const Key('mine-language-zh'))); + await tester.pumpAndSettle(); + + expect(find.text('我的'), findsWidgets); + expect(find.text('语言'), findsOneWidget); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + expect(find.text('剧场'), findsWidgets); + }); + testWidgets('should_fit_compact_viewport_when_player_is_opened', ( tester, ) async { @@ -170,7 +192,7 @@ void main() { await tester.drag(find.byType(PageView), const Offset(0, -500)); await tester.pumpAndSettle(); expect( - tester.widget(find.byKey(const Key('player-title'))).data, + tester.widget(find.byKey(const Key('player-episode-heading'))).data, contains('Episode 2'), ); expect(tester.takeException(), isNull); @@ -191,7 +213,7 @@ void main() { expect(find.byKey(const Key('discover-search')), findsOneWidget); expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect(tester.takeException(), isNull); From 6dbeb3e505486b2f035c3c3500b949efda71ffd9 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:03:53 +0800 Subject: [PATCH 026/124] =?UTF-8?q?fix(flutter):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=85=A8=E5=B1=8F=E8=83=B6=E5=9B=8A=E5=8F=8C=E6=80=81=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/player_overlay.dart | 8 +++++--- test/player/player_fullscreen_test.dart | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index ade1564..ab3de69 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -56,8 +56,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { _top(context), _bottom(context), _rail(context), - _fullScreenPill(), ], + _fullScreenPill(), if (!controlsVisible) Positioned( top: AppDimens.spaceSm, @@ -321,7 +321,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { child: InkWell( key: const Key('player-fullscreen-toggle'), borderRadius: BorderRadius.circular(999), - onTap: actions.onClear, + onTap: controlsVisible ? actions.onClear : actions.onExitClear, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), child: Row( @@ -334,7 +334,9 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), const SizedBox(width: 5), Text( - data.l10n.fullScreenWatch, + controlsVisible + ? data.l10n.fullScreenWatch + : data.l10n.exitClearScreen, style: const TextStyle( color: AppColors.onPrimary, fontSize: 12, diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 8c5dd9d..bc3108d 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -383,6 +383,19 @@ void main() { expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); }); + testWidgets('should_toggle_fullscreen_from_the_same_pill', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-controls-top')), findsNothing); + + await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + }); + testWidgets('should_open_unlock_panel_with_honest_method_states', ( tester, ) async { From cf93d8d794db1bff2cafc576956cf1fe17596514 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:12:12 +0800 Subject: [PATCH 027/124] =?UTF-8?q?fix(flutter):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E9=A1=B5=E5=BA=95=E9=83=A8=E4=B8=8E=E4=BA=92?= =?UTF-8?q?=E5=8A=A8=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 2 - lib/l10n/app_id.arb | 2 - lib/l10n/app_localizations.dart | 12 - lib/l10n/app_localizations_en.dart | 6 - lib/l10n/app_localizations_id.dart | 6 - lib/l10n/app_localizations_zh.dart | 6 - lib/l10n/app_zh.arb | 2 - lib/pages/player/widgets/player_overlay.dart | 86 ++----- .../player/widgets/player_overlay_parts.dart | 216 +++++++++--------- test/player/player_fullscreen_test.dart | 69 ++++-- 10 files changed, 167 insertions(+), 240 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e377d84..ab59bad 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -592,9 +592,7 @@ "unlockMethodsVary": "Available unlock methods vary by title.", "unlockEpisode": "Unlock this episode", "language": "Language", - "fullScreenWatch": "Full screen", "freeWatch": "Free watch", - "showMore": "More", "qualityUnavailable": "Unavailable", "notInterested": "Not interested", "clearPlayback": "Clear-screen playback", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d80f862..19aa673 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -590,9 +590,7 @@ "unlockMethodsVary": "Metode pembukaan tersedia dapat berbeda per judul.", "unlockEpisode": "Buka episode ini", "language": "Bahasa", - "fullScreenWatch": "Layar penuh", "freeWatch": "Tonton gratis", - "showMore": "Selengkapnya", "qualityUnavailable": "Tidak tersedia", "notInterested": "Tidak tertarik", "clearPlayback": "Putar tanpa kontrol", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 41f5f46..4a3e024 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2638,24 +2638,12 @@ abstract class AppLocalizations { /// **'Language'** String get language; - /// No description provided for @fullScreenWatch. - /// - /// In en, this message translates to: - /// **'Full screen'** - String get fullScreenWatch; - /// No description provided for @freeWatch. /// /// In en, this message translates to: /// **'Free watch'** String get freeWatch; - /// No description provided for @showMore. - /// - /// In en, this message translates to: - /// **'More'** - String get showMore; - /// No description provided for @qualityUnavailable. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9f8819a..d1c882b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1368,15 +1368,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get language => 'Language'; - @override - String get fullScreenWatch => 'Full screen'; - @override String get freeWatch => 'Free watch'; - @override - String get showMore => 'More'; - @override String get qualityUnavailable => 'Unavailable'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 518dad1..26635f9 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1368,15 +1368,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get language => 'Bahasa'; - @override - String get fullScreenWatch => 'Layar penuh'; - @override String get freeWatch => 'Tonton gratis'; - @override - String get showMore => 'Selengkapnya'; - @override String get qualityUnavailable => 'Tidak tersedia'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 7d344c5..9df426e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1344,15 +1344,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get language => '语言'; - @override - String get fullScreenWatch => '全屏观看'; - @override String get freeWatch => '免费观看'; - @override - String get showMore => '展开'; - @override String get qualityUnavailable => '暂不可用'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 05aa597..113d618 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -552,9 +552,7 @@ "unlockMethodsVary": "具体解锁方式以作品而定。", "unlockEpisode": "解锁本集", "language": "语言", - "fullScreenWatch": "全屏观看", "freeWatch": "免费观看", - "showMore": "展开", "qualityUnavailable": "暂不可用", "notInterested": "不感兴趣", "clearPlayback": "清屏播放", diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index ab3de69..54e8f1f 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -1,8 +1,6 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; -import 'package:video_player/video_player.dart'; - import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; @@ -14,11 +12,10 @@ import 'seek_progress_bar.dart'; part 'player_overlay_parts.dart'; -const double _topBarHeight = 58; const double _topButtonSize = 36; -const double _railButtonSize = 36; -const double _railGap = 9; -const double _fullScreenPillBottom = 246; +const double _topBarHeight = 58; +const double _railButtonSize = 34; +const double _railGap = 18; const double _panelRadius = 16; /// K29 竖屏播放页浮层。 @@ -57,7 +54,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { _bottom(context), _rail(context), ], - _fullScreenPill(), if (!controlsVisible) Positioned( top: AppDimens.spaceSm, @@ -144,7 +140,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { final compact = MediaQuery.sizeOf(context).height < 700; return Positioned( right: AppDimens.spaceMd, - top: _topBarHeight + (compact ? 18 : 54), + top: MediaQuery.sizeOf(context).height * (compact ? 0.42 : 0.55), child: _Rail( l10n: data.l10n, liked: data.liked, @@ -156,7 +152,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { onComments: actions.onComments, onSave: actions.onSave, onShare: actions.onShare, - compact: compact, ), ); } @@ -170,7 +165,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { bottom: 0, child: Container( key: const Key('player-controls-bottom'), - padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), + padding: const EdgeInsets.fromLTRB(14, 8, 14, 6), decoration: const BoxDecoration( color: Color(0xED000000), borderRadius: BorderRadius.vertical( @@ -182,27 +177,16 @@ class PlayerPlaybackOverlay extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _detailRow(series), - const SizedBox(height: 6), + const SizedBox(height: 4), _EpisodeSynopsis( key: const Key('player-episode-synopsis'), l10n: data.l10n, episodeNumber: data.episode.number, synopsis: synopsis, + authorNote: data.authorNote, textScale: data.textScale, ), - if (data.authorNote.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - data.authorNote, - key: const Key('player-author-note'), - style: const TextStyle(color: Colors.white60, fontSize: 11), - ), - ], - const SizedBox(height: 6), - _PlayerTimeLabel( - key: const Key('player-time-label'), - controller: data.controller, - ), + const SizedBox(height: 2), SeekProgressBar( key: const Key('player-progress'), valueListenable: data.controller, @@ -226,7 +210,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { key: const Key('player-open-detail'), onTap: actions.onDetail, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2), + padding: const EdgeInsets.symmetric(vertical: 1), child: Row( children: [ Expanded( @@ -238,7 +222,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { style: TextStyle( color: AppColors.onPrimary, fontFamily: 'Outfit', - fontSize: 19 * data.textScale, + fontSize: 18 * data.textScale, fontWeight: FontWeight.w800, ), ), @@ -267,7 +251,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { onTap: actions.onEpisodes, borderRadius: BorderRadius.circular(8), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Flexible( @@ -288,7 +272,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { const Icon( Icons.keyboard_arrow_up_rounded, color: AppColors.onPrimary, - size: 18, + size: 16, ), ], ), @@ -298,57 +282,13 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), if (actions.onPip != null) ...[ const SizedBox(width: 8), - _TopButton( + _PipButton( key: const Key('player-pip'), tooltip: data.l10n.pictureInPicture, - icon: Icons.picture_in_picture_alt_rounded, onPressed: actions.onPip!, ), ], ], ); } - - Widget _fullScreenPill() { - return Positioned( - left: 0, - right: 0, - bottom: _fullScreenPillBottom, - child: Center( - child: Material( - color: const Color(0x99000000), - borderRadius: BorderRadius.circular(999), - child: InkWell( - key: const Key('player-fullscreen-toggle'), - borderRadius: BorderRadius.circular(999), - onTap: controlsVisible ? actions.onClear : actions.onExitClear, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.fullscreen_rounded, - color: AppColors.onPrimary, - size: 16, - ), - const SizedBox(width: 5), - Text( - controlsVisible - ? data.l10n.fullScreenWatch - : data.l10n.exitClearScreen, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } } diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index f472365..99c1452 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -73,6 +73,30 @@ class _SpeedEntry extends StatelessWidget { } } +class _PipButton extends StatelessWidget { + const _PipButton({super.key, required this.tooltip, required this.onPressed}); + + final String tooltip; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 30, + child: IconButton( + tooltip: tooltip, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0x80000000), + foregroundColor: AppColors.onPrimary, + ), + onPressed: onPressed, + icon: const Icon(Icons.picture_in_picture_alt_rounded, size: 17), + ), + ); + } +} + class _Rail extends StatelessWidget { const _Rail({ required this.l10n, @@ -85,7 +109,6 @@ class _Rail extends StatelessWidget { required this.onComments, required this.onSave, required this.onShare, - required this.compact, }); final AppLocalizations l10n; @@ -98,7 +121,6 @@ class _Rail extends StatelessWidget { final VoidCallback onComments; final VoidCallback onSave; final VoidCallback onShare; - final bool compact; @override Widget build(BuildContext context) { @@ -111,29 +133,28 @@ class _Rail extends StatelessWidget { label: favoriteCount?.toString() ?? l10n.favorite, active: saved, onTap: onSave, - compact: compact, ), + const SizedBox(height: _railGap), _RailButton( key: const Key('player-rail-comments'), icon: Icons.chat_bubble_outline_rounded, label: commentCount?.toString() ?? l10n.comments, onTap: onComments, - compact: compact, ), + const SizedBox(height: _railGap), _RailButton( key: const Key('player-rail-like'), icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, label: likeCount?.toString() ?? l10n.like, active: liked, onTap: onLike, - compact: compact, ), + const SizedBox(height: _railGap), _RailButton( key: const Key('player-rail-share'), icon: Icons.ios_share_rounded, label: l10n.share, onTap: onShare, - compact: compact, ), ], ); @@ -146,46 +167,41 @@ class _RailButton extends StatelessWidget { required this.icon, required this.label, required this.onTap, - required this.compact, this.active = false, }); final IconData icon; final String label; final VoidCallback onTap; - final bool compact; final bool active; @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(bottom: compact ? 3 : _railGap), - child: Column( - children: [ - SizedBox.square( - dimension: _railButtonSize, - child: IconButton( - onPressed: onTap, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: const Color(0x80000000), - foregroundColor: active ? AppColors.blue : AppColors.onPrimary, - ), - icon: Icon(icon, size: 18), + return Column( + children: [ + SizedBox.square( + dimension: _railButtonSize, + child: IconButton( + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0x80000000), + foregroundColor: active ? AppColors.blue : AppColors.onPrimary, ), + icon: Icon(icon, size: 24), ), - SizedBox(height: compact ? 1 : 3), - Text( - label, - maxLines: 1, - style: TextStyle( - color: AppColors.onPrimary, - fontSize: compact ? 8 : 9, - fontWeight: FontWeight.w600, - ), + ), + Text( + label, + maxLines: 1, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 11, + height: 1, + fontWeight: FontWeight.w600, ), - ], - ), + ), + ], ); } } @@ -196,12 +212,14 @@ class _EpisodeSynopsis extends StatefulWidget { required this.l10n, required this.episodeNumber, required this.synopsis, + required this.authorNote, required this.textScale, }); final AppLocalizations l10n; final int episodeNumber; final String synopsis; + final String authorNote; final double textScale; @override @@ -213,92 +231,64 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { @override Widget build(BuildContext context) { - final canExpand = widget.synopsis.trim().length > 28; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return Material( + color: Colors.transparent, + child: InkWell( + key: const Key('player-synopsis-toggle'), + onTap: () => setState(() => _expanded = !_expanded), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - widget.l10n.episodeLabel(widget.episodeNumber), - key: const Key('player-episode-label'), - style: TextStyle( - color: AppColors.blue, - fontSize: 11 * widget.textScale, - fontWeight: FontWeight.w700, - ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 6), - child: Text('|', style: TextStyle(color: Colors.white38)), - ), - Expanded( - child: Text( - widget.synopsis, - key: const Key('player-synopsis'), - maxLines: _expanded ? 4 : 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.white70, - fontSize: 11 * widget.textScale, - height: 1.45, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.l10n.episodeLabel(widget.episodeNumber), + key: const Key('player-episode-label'), + style: TextStyle( + color: AppColors.blue, + fontSize: 11 * widget.textScale, + fontWeight: FontWeight.w700, + ), ), - ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('|', style: TextStyle(color: Colors.white38)), + ), + Expanded( + child: Text( + widget.synopsis, + key: const Key('player-synopsis'), + maxLines: _expanded ? 3 : 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white70, + fontSize: 11 * widget.textScale, + height: 1.35, + ), + ), + ), + const SizedBox(width: 4), + Icon( + _expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + color: Colors.white54, + size: 16, + ), + ], ), + if (_expanded && widget.authorNote.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + widget.authorNote, + key: const Key('player-author-note'), + style: const TextStyle(color: Colors.white54, fontSize: 10), + ), + ], ], ), - if (canExpand) - GestureDetector( - key: const Key('player-synopsis-toggle'), - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - _expanded ? widget.l10n.showLess : widget.l10n.showMore, - style: const TextStyle( - color: Colors.white54, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ); - } -} - -class _PlayerTimeLabel extends StatelessWidget { - const _PlayerTimeLabel({super.key, required this.controller}); - - final VideoPlayerController? controller; - - @override - Widget build(BuildContext context) { - final source = controller; - if (source == null) return _label(Duration.zero, Duration.zero); - return ValueListenableBuilder( - valueListenable: source, - builder: (context, value, _) => _label(value.position, value.duration), - ); - } - - Widget _label(Duration position, Duration duration) => Align( - alignment: Alignment.centerRight, - child: Text( - '${_format(position)} / ${_format(duration)}', - style: const TextStyle( - color: Colors.white60, - fontSize: 9, - fontWeight: FontWeight.w600, ), - ), - ); - - String _format(Duration value) { - final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; + ); } } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index bc3108d..ec41d51 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -106,7 +106,7 @@ void main() { expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); - await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); + await _clearFromMore(tester); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsNothing); @@ -173,13 +173,53 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('should_keep_compact_bottom_panel_and_lower_rail', ( + tester, + ) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + + final screenHeight = tester.view.physicalSize.height; + final bottom = tester.getRect( + find.byKey(const Key('player-controls-bottom')), + ); + final rail = tester.getRect( + find.byKey(const Key('player-interaction-rail')), + ); + + expect(bottom.height, lessThanOrEqualTo(200)); + expect(bottom.top, greaterThanOrEqualTo(screenHeight * 0.78)); + expect(rail.top, greaterThanOrEqualTo(screenHeight * 0.55 - 2)); + expect(rail.bottom, lessThanOrEqualTo(bottom.top)); + expect(find.byKey(const Key('player-author-note')), findsNothing); + expect( + tester + .getSize( + find.descendant( + of: find.byKey(const Key('player-rail-comments')), + matching: find.byIcon(Icons.chat_bubble_outline_rounded), + ), + ) + .width, + 24, + ); + + await tester.tap(find.byKey(const Key('player-synopsis-toggle'))); + await tester.pumpAndSettle(); + final expandedBottom = tester.getRect( + find.byKey(const Key('player-controls-bottom')), + ); + expect(expandedBottom.height, lessThanOrEqualTo(200)); + expect(expandedBottom.top, greaterThanOrEqualTo(screenHeight * 0.78)); + }); + testWidgets('should_keep_series_clear_state_when_swiping_next_episode', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); - await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); + await _clearFromMore(tester); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsNothing); @@ -237,7 +277,7 @@ void main() { await tester.tap(find.byKey(const Key('player-speed-1.5'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); + await _clearFromMore(tester); await tester.pump(); expect(find.byKey(const Key('player-more')), findsNothing); @@ -383,19 +423,6 @@ void main() { expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); }); - testWidgets('should_toggle_fullscreen_from_the_same_pill', (tester) async { - await _pumpApp(tester); - await _openSeriesPlayer(tester); - await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-controls-top')), findsNothing); - - await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-controls-top')), findsOneWidget); - expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); - }); - testWidgets('should_open_unlock_panel_with_honest_method_states', ( tester, ) async { @@ -434,7 +461,6 @@ void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-drama-title')), findsOneWidget); expect(find.byKey(const Key('player-episode-label')), findsOneWidget); expect(find.byKey(const Key('player-synopsis')), findsOneWidget); - expect(find.byKey(const Key('player-time-label')), findsOneWidget); expect(find.byKey(const Key('player-more')), findsOneWidget); for (final key in const [Key('player-back'), Key('player-more')]) { expect(tester.getSize(find.byKey(key)), const Size(36, 36)); @@ -442,7 +468,7 @@ void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(find.byKey(const Key('player-episodes')), findsOneWidget); expect(find.byKey(const Key('player-rail-comments')), findsOneWidget); - expect(find.byKey(const Key('player-fullscreen-toggle')), findsOneWidget); + expect(find.byKey(const Key('player-fullscreen-toggle')), findsNothing); expect(find.byType(Slider), findsOneWidget); expect(find.byKey(const Key('player-title')), findsNothing); expect(find.byKey(const Key('player-subtitle')), findsNothing); @@ -457,6 +483,13 @@ void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-rail-share')), findsOneWidget); } +Future _clearFromMore(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more-clear'))); + await tester.pumpAndSettle(); +} + Future _openSeriesPlayer( WidgetTester tester, { Key cardKey = const Key('home-drama-card-city-lights-demo'), From e2162773e2723f398924da6ffe95e7004d7d92c6 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:14:08 +0800 Subject: [PATCH 028/124] =?UTF-8?q?fix(flutter):=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=B5=85=E8=89=B2=E6=92=AD=E6=94=BE=E9=A1=B5=E5=BA=95=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 3 ++- lib/pages/player/widgets/player_overlay.dart | 13 +++++++------ lib/pages/player/widgets/player_overlay_parts.dart | 12 ++++++------ test/player/player_fullscreen_test.dart | 11 +++++++++++ 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 7d9da94..3d81078 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -216,7 +216,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { fit: StackFit.expand, children: [ ColoredBox( - color: AppColors.onSurface, + key: const Key('player-background'), + color: AppColors.ink, child: widget.homeMode ? null : RemoteImage( diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 54e8f1f..9b9be7e 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -167,7 +167,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { key: const Key('player-controls-bottom'), padding: const EdgeInsets.fromLTRB(14, 8, 14, 6), decoration: const BoxDecoration( - color: Color(0xED000000), + color: AppColors.card, + border: Border(top: BorderSide(color: AppColors.border)), borderRadius: BorderRadius.vertical( top: Radius.circular(_panelRadius), ), @@ -220,7 +221,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: AppColors.onPrimary, + color: AppColors.onSurface, fontFamily: 'Outfit', fontSize: 18 * data.textScale, fontWeight: FontWeight.w800, @@ -230,7 +231,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { const SizedBox(width: 6), const Icon( Icons.chevron_right_rounded, - color: Colors.white70, + color: AppColors.muted, size: 20, ), ], @@ -262,8 +263,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 12, + color: AppColors.onSurface, + fontSize: 11, fontWeight: FontWeight.w700, ), ), @@ -271,7 +272,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { const SizedBox(width: 3), const Icon( Icons.keyboard_arrow_up_rounded, - color: AppColors.onPrimary, + color: AppColors.onSurface, size: 16, ), ], diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 99c1452..bcc217e 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -87,8 +87,8 @@ class _PipButton extends StatelessWidget { tooltip: tooltip, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: const Color(0x80000000), - foregroundColor: AppColors.onPrimary, + backgroundColor: AppColors.raised, + foregroundColor: AppColors.onSurface, ), onPressed: onPressed, icon: const Icon(Icons.picture_in_picture_alt_rounded, size: 17), @@ -253,7 +253,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { ), const Padding( padding: EdgeInsets.symmetric(horizontal: 6), - child: Text('|', style: TextStyle(color: Colors.white38)), + child: Text('|', style: TextStyle(color: AppColors.border)), ), Expanded( child: Text( @@ -262,7 +262,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { maxLines: _expanded ? 3 : 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: Colors.white70, + color: AppColors.muted, fontSize: 11 * widget.textScale, height: 1.35, ), @@ -273,7 +273,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { _expanded ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded, - color: Colors.white54, + color: AppColors.subtle, size: 16, ), ], @@ -283,7 +283,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { Text( widget.authorNote, key: const Key('player-author-note'), - style: const TextStyle(color: Colors.white54, fontSize: 10), + style: const TextStyle(color: AppColors.muted, fontSize: 10), ), ], ], diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index ec41d51..8fe2c00 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -11,6 +11,7 @@ import 'package:fruitshorts/core/data/models/play_ticket.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/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; @@ -186,6 +187,16 @@ void main() { final rail = tester.getRect( find.byKey(const Key('player-interaction-rail')), ); + expect( + tester + .widget(find.byKey(const Key('player-background'))) + .color, + AppColors.ink, + ); + final panel = tester.widget( + find.byKey(const Key('player-controls-bottom')), + ); + expect((panel.decoration! as BoxDecoration).color, AppColors.card); expect(bottom.height, lessThanOrEqualTo(200)); expect(bottom.top, greaterThanOrEqualTo(screenHeight * 0.78)); From 1666bc220e4ceead0c82ca83c5cd9a5e3e4c108c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:19:14 +0800 Subject: [PATCH 029/124] =?UTF-8?q?fix(flutter):=20=E5=BA=95=E6=A0=8F?= =?UTF-8?q?=E5=8F=B3=E4=BE=A7=E6=94=B9=E4=B8=BA=E6=B8=85=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 3 --- lib/pages/player/player_view_actions.dart | 4 ++++ lib/pages/player/widgets/player_more_sheet.dart | 15 +++++++++++++++ lib/pages/player/widgets/player_overlay.dart | 15 +++++++-------- .../player/widgets/player_overlay_models.dart | 4 ---- .../player/widgets/player_overlay_parts.dart | 12 +++++++++--- test/player/player_fullscreen_test.dart | 16 ++++++++++++++++ 7 files changed, 51 insertions(+), 18 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 3d81078..f0793d0 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -431,9 +431,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onSave: () => unawaited(_toggleSave(context, drama)), onShare: () => logic.shareDrama(context, drama), onMore: () => _openMore(context, drama, episode), - onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(logic.enterPip(context)) - : null, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 0733240..ac3c172 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -32,6 +32,7 @@ extension _PlayerViewActions on _PlayerPageState { speed: _playbackSpeed, qualities: logic.qualities, textScale: _textScale, + pipAvailable: logic.pictureInPicture.isPlatformSupported, ), actions: PlayerMoreActions( onSpeedChanged: (value) { @@ -42,6 +43,9 @@ extension _PlayerViewActions on _PlayerPageState { onClear: _hideChrome, onDownload: () => requestEpisodeDownload(context, episode.id), onReport: () => unawaited(_reportDrama(context, drama, l10n)), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, ), ), ); diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart index 525bc01..6a885e6 100644 --- a/lib/pages/player/widgets/player_more_sheet.dart +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -18,6 +18,7 @@ class PlayerMoreState { required this.speed, required this.qualities, required this.textScale, + required this.pipAvailable, }); /// 当前倍速。 @@ -28,6 +29,9 @@ class PlayerMoreState { /// 当前文字缩放比例。 final double textScale; + + /// 设备是否提供画中画入口。 + final bool pipAvailable; } /// 更多面板动作集合。 @@ -39,6 +43,7 @@ class PlayerMoreActions { required this.onClear, required this.onDownload, required this.onReport, + this.onPip, }); /// 倍速变化。 @@ -55,6 +60,9 @@ class PlayerMoreActions { /// 投诉当前剧。 final VoidCallback onReport; + + /// 进入画中画;平台不支持时为空。 + final VoidCallback? onPip; } /// K29 播放页白色更多面板。 @@ -136,6 +144,13 @@ class _PlayerMoreSheetState extends State { title: l10n.offlineCache, onTap: () => _closeThen(widget.actions.onDownload), ), + if (widget.state.pipAvailable && widget.actions.onPip != null) + _enabledRow( + key: const Key('player-more-pip'), + icon: Icons.picture_in_picture_alt_rounded, + title: l10n.pictureInPicture, + onTap: () => _closeThen(widget.actions.onPip!), + ), _textSizeRow(l10n), _disabledRow( key: const Key('player-more-cast'), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 9b9be7e..ce65340 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -281,14 +281,13 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), ), ), - if (actions.onPip != null) ...[ - const SizedBox(width: 8), - _PipButton( - key: const Key('player-pip'), - tooltip: data.l10n.pictureInPicture, - onPressed: actions.onPip!, - ), - ], + const SizedBox(width: 8), + _BottomIconButton( + key: const Key('player-bottom-clear-screen'), + tooltip: data.l10n.clearScreen, + icon: Icons.fullscreen_rounded, + onPressed: actions.onClear, + ), ], ); } diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 873753c..7c43d8c 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -87,7 +87,6 @@ class PlayerOverlayActions { required this.onMore, required this.onScrubStart, required this.onSeek, - this.onPip, }); /// 返回上一页。 @@ -131,7 +130,4 @@ class PlayerOverlayActions { /// 提交进度跳转。 final Future Function(Duration)? onSeek; - - /// 进入小窗播放。 - final VoidCallback? onPip; } diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index bcc217e..8fec70a 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -73,10 +73,16 @@ class _SpeedEntry extends StatelessWidget { } } -class _PipButton extends StatelessWidget { - const _PipButton({super.key, required this.tooltip, required this.onPressed}); +class _BottomIconButton extends StatelessWidget { + const _BottomIconButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); final String tooltip; + final IconData icon; final VoidCallback onPressed; @override @@ -91,7 +97,7 @@ class _PipButton extends StatelessWidget { foregroundColor: AppColors.onSurface, ), onPressed: onPressed, - icon: const Icon(Icons.picture_in_picture_alt_rounded, size: 17), + icon: Icon(icon, size: 17), ), ); } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 8fe2c00..45e48d7 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -434,6 +434,20 @@ void main() { expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); }); + testWidgets('should_enter_clear_screen_from_bottom_button', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + + expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); + expect(find.byKey(const Key('player-pip')), findsNothing); + await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + }); + testWidgets('should_open_unlock_panel_with_honest_method_states', ( tester, ) async { @@ -478,6 +492,8 @@ void _expectFullscreenOverlay(WidgetTester tester) { } expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(find.byKey(const Key('player-episodes')), findsOneWidget); + expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); + expect(find.byKey(const Key('player-pip')), findsNothing); expect(find.byKey(const Key('player-rail-comments')), findsOneWidget); expect(find.byKey(const Key('player-fullscreen-toggle')), findsNothing); expect(find.byType(Slider), findsOneWidget); From 1dbe2cb8e9a4da6ee55db04f8a483b7a6d829abd Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:25:28 +0800 Subject: [PATCH 030/124] =?UTF-8?q?fix(flutter):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=99=A8=E6=B7=B1=E8=89=B2=E5=A4=96=E8=A7=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 15 ++++++++++++ lib/pages/player/player_view.dart | 21 +--------------- lib/pages/player/widgets/player_overlay.dart | 24 ++++++++++++------- .../player/widgets/player_overlay_parts.dart | 18 +++++++++----- .../player/widgets/seek_progress_bar.dart | 4 +++- test/player/player_fullscreen_test.dart | 4 ++-- 6 files changed, 48 insertions(+), 38 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 26b8f4e..c4e8e60 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -91,6 +91,21 @@ abstract final class AppColors { /// 导航未选中。 static const navUnselected = muted; + /// 播放器专用深色页面背景。 + static const playerBackground = Color(0xFF0D0D10); + + /// 播放器底部信息面板,保留轻微视频透出。 + static const playerPanel = Color(0xED0D0D10); + + /// 播放器次要文字。 + static const playerMuted = Color(0xFFBBBBBB); + + /// 播放器底栏胶囊。 + static const playerChip = Color(0xFF1C1C1E); + + /// 播放器面板分割线。 + static const playerDivider = Color(0xFF2A2A2D); + static const outlineDark = border; static const outlineVariantDark = border; static const outlineLight = muted; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index f0793d0..428be04 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -217,7 +217,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { children: [ ColoredBox( key: const Key('player-background'), - color: AppColors.ink, + color: AppColors.playerBackground, child: widget.homeMode ? null : RemoteImage( @@ -227,25 +227,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ?video, - if (!logic.chromeHidden && (!widget.homeMode || _chromeVisible)) - IgnorePointer( - key: widget.homeMode ? const Key('home-feed-scrim') : null, - child: const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0x66000000), - Color(0x00000000), - Color(0x33000000), - Color(0xE6000000), - ], - stops: [0, 0.16, 0.55, 1], - ), - ), - ), - ), if (widget.homeMode && _chromeVisible && !logic.chromeHidden) Positioned.fill( child: GestureDetector( diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index ce65340..91b097c 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -167,8 +167,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { key: const Key('player-controls-bottom'), padding: const EdgeInsets.fromLTRB(14, 8, 14, 6), decoration: const BoxDecoration( - color: AppColors.card, - border: Border(top: BorderSide(color: AppColors.border)), + color: AppColors.playerPanel, + border: Border(top: BorderSide(color: AppColors.playerDivider)), borderRadius: BorderRadius.vertical( top: Radius.circular(_panelRadius), ), @@ -194,6 +194,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { semanticsLabel: data.l10n.seekVideo, showTimes: true, compact: true, + inactiveTrackColor: Colors.white24, onScrubStart: actions.onScrubStart, onSeek: actions.onSeek, ), @@ -221,7 +222,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: AppColors.onSurface, + color: AppColors.onPrimary, fontFamily: 'Outfit', fontSize: 18 * data.textScale, fontWeight: FontWeight.w800, @@ -231,7 +232,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { const SizedBox(width: 6), const Icon( Icons.chevron_right_rounded, - color: AppColors.muted, + color: AppColors.playerMuted, size: 20, ), ], @@ -246,13 +247,18 @@ class PlayerPlaybackOverlay extends StatelessWidget { children: [ Expanded( child: Material( - color: Colors.transparent, + color: AppColors.playerChip, + borderRadius: BorderRadius.circular(999), + clipBehavior: Clip.antiAlias, child: InkWell( key: const Key('player-episodes'), onTap: actions.onEpisodes, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(999), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), child: Row( children: [ Flexible( @@ -263,7 +269,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.onSurface, + color: AppColors.onPrimary, fontSize: 11, fontWeight: FontWeight.w700, ), @@ -272,7 +278,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { const SizedBox(width: 3), const Icon( Icons.keyboard_arrow_up_rounded, - color: AppColors.onSurface, + color: AppColors.onPrimary, size: 16, ), ], diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 8fec70a..d41b36b 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -93,8 +93,8 @@ class _BottomIconButton extends StatelessWidget { tooltip: tooltip, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: AppColors.raised, - foregroundColor: AppColors.onSurface, + backgroundColor: AppColors.playerChip, + foregroundColor: AppColors.onPrimary, ), onPressed: onPressed, icon: Icon(icon, size: 17), @@ -259,7 +259,10 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { ), const Padding( padding: EdgeInsets.symmetric(horizontal: 6), - child: Text('|', style: TextStyle(color: AppColors.border)), + child: Text( + '|', + style: TextStyle(color: AppColors.playerDivider), + ), ), Expanded( child: Text( @@ -268,7 +271,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { maxLines: _expanded ? 3 : 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: AppColors.muted, + color: AppColors.playerMuted, fontSize: 11 * widget.textScale, height: 1.35, ), @@ -279,7 +282,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { _expanded ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded, - color: AppColors.subtle, + color: AppColors.playerMuted, size: 16, ), ], @@ -289,7 +292,10 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { Text( widget.authorNote, key: const Key('player-author-note'), - style: const TextStyle(color: AppColors.muted, fontSize: 10), + style: const TextStyle( + color: AppColors.playerMuted, + fontSize: 10, + ), ), ], ], diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 57f7503..45195d5 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -27,6 +27,7 @@ class SeekProgressBar extends StatefulWidget { this.onSeek, this.showTimes = true, this.compact = false, + this.inactiveTrackColor = AppColors.border, }); final ValueListenable? valueListenable; @@ -35,6 +36,7 @@ class SeekProgressBar extends StatefulWidget { final Future Function(Duration)? onSeek; final bool showTimes; final bool compact; + final Color inactiveTrackColor; @override State createState() => _SeekProgressBarState(); @@ -127,7 +129,7 @@ class _SeekProgressBarState extends State { ? (scrubbing ? 3 : 1.5) : (scrubbing ? 4 : 3), activeTrackColor: AppColors.blue, - inactiveTrackColor: AppColors.border, + inactiveTrackColor: widget.inactiveTrackColor, thumbColor: AppColors.onPrimary, thumbShape: RoundSliderThumbShape( enabledThumbRadius: widget.compact diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 45e48d7..f4a78e7 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -191,12 +191,12 @@ void main() { tester .widget(find.byKey(const Key('player-background'))) .color, - AppColors.ink, + AppColors.playerBackground, ); final panel = tester.widget( find.byKey(const Key('player-controls-bottom')), ); - expect((panel.decoration! as BoxDecoration).color, AppColors.card); + expect((panel.decoration! as BoxDecoration).color, AppColors.playerPanel); expect(bottom.height, lessThanOrEqualTo(200)); expect(bottom.top, greaterThanOrEqualTo(screenHeight * 0.78)); From 0af68f24558794bc27267a9a5f7e0beb2c566d09 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:29:02 +0800 Subject: [PATCH 031/124] =?UTF-8?q?fix(flutter):=20=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E9=A1=B5=E6=94=B9=E4=B8=BA=E9=80=8F=E6=98=8E=E5=8F=A0=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 13 ++------ lib/pages/player/player_view.dart | 23 ++++++-------- lib/pages/player/widgets/player_overlay.dart | 28 +++++++---------- .../player/widgets/player_overlay_parts.dart | 15 +++++++-- test/player/player_fullscreen_test.dart | 31 +++++++++++++++---- 5 files changed, 60 insertions(+), 50 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index c4e8e60..131a8d5 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -91,20 +91,11 @@ abstract final class AppColors { /// 导航未选中。 static const navUnselected = muted; - /// 播放器专用深色页面背景。 - static const playerBackground = Color(0xFF0D0D10); - - /// 播放器底部信息面板,保留轻微视频透出。 - static const playerPanel = Color(0xED0D0D10); - /// 播放器次要文字。 static const playerMuted = Color(0xFFBBBBBB); - /// 播放器底栏胶囊。 - static const playerChip = Color(0xFF1C1C1E); - - /// 播放器面板分割线。 - static const playerDivider = Color(0xFF2A2A2D); + /// 播放器控件胶囊与圆形底色。 + static const playerOverlayPill = Color(0xB3000000); static const outlineDark = border; static const outlineVariantDark = border; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 428be04..a09199c 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -20,7 +20,6 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; -import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../interaction/widgets/report_dialog.dart'; import 'player_binding.dart'; @@ -168,7 +167,11 @@ class _PlayerPageState extends State with WidgetsBindingObserver { data: AppTheme.light(), child: widget.embedded ? content - : Scaffold(backgroundColor: AppColors.ink, body: content), + : Scaffold( + key: const Key('player-scaffold'), + backgroundColor: Colors.transparent, + body: content, + ), ), ); } @@ -215,17 +218,11 @@ class _PlayerPageState extends State with WidgetsBindingObserver { return Stack( fit: StackFit.expand, children: [ - ColoredBox( - key: const Key('player-background'), - color: AppColors.playerBackground, - child: widget.homeMode - ? null - : RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - ), + if (!ready) + const ColoredBox( + key: Key('player-fallback-background'), + color: Colors.black, + ), ?video, if (widget.homeMode && _chromeVisible && !logic.chromeHidden) Positioned.fill( diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 91b097c..4e3128a 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -16,7 +16,9 @@ const double _topButtonSize = 36; const double _topBarHeight = 58; const double _railButtonSize = 34; const double _railGap = 18; -const double _panelRadius = 16; +const List _playerTextShadows = [ + Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), +]; /// K29 竖屏播放页浮层。 class PlayerPlaybackOverlay extends StatelessWidget { @@ -86,13 +88,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { key: const Key('player-controls-top'), height: _topBarHeight, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xCC000000), Color(0x00000000)], - ), - ), child: Row( children: [ if (actions.onBack != null) @@ -115,6 +110,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { color: AppColors.onPrimary, fontSize: 15, fontWeight: FontWeight.w700, + shadows: _playerTextShadows, ), ), ), @@ -163,16 +159,9 @@ class PlayerPlaybackOverlay extends StatelessWidget { left: 0, right: 0, bottom: 0, - child: Container( + child: Padding( key: const Key('player-controls-bottom'), padding: const EdgeInsets.fromLTRB(14, 8, 14, 6), - decoration: const BoxDecoration( - color: AppColors.playerPanel, - border: Border(top: BorderSide(color: AppColors.playerDivider)), - borderRadius: BorderRadius.vertical( - top: Radius.circular(_panelRadius), - ), - ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -226,6 +215,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { fontFamily: 'Outfit', fontSize: 18 * data.textScale, fontWeight: FontWeight.w800, + shadows: _playerTextShadows, ), ), ), @@ -234,6 +224,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { Icons.chevron_right_rounded, color: AppColors.playerMuted, size: 20, + shadows: _playerTextShadows, ), ], ), @@ -247,7 +238,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { children: [ Expanded( child: Material( - color: AppColors.playerChip, + key: const Key('player-episodes-pill'), + color: AppColors.playerOverlayPill, borderRadius: BorderRadius.circular(999), clipBehavior: Clip.antiAlias, child: InkWell( @@ -272,6 +264,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { color: AppColors.onPrimary, fontSize: 11, fontWeight: FontWeight.w700, + shadows: _playerTextShadows, ), ), ), @@ -280,6 +273,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { Icons.keyboard_arrow_up_rounded, color: AppColors.onPrimary, size: 16, + shadows: _playerTextShadows, ), ], ), diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index d41b36b..f50403f 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -55,6 +55,7 @@ class _SpeedEntry extends StatelessWidget { Icons.speed_rounded, color: AppColors.onPrimary, size: 15, + shadows: _playerTextShadows, ), const SizedBox(width: 4), Text( @@ -63,6 +64,7 @@ class _SpeedEntry extends StatelessWidget { color: AppColors.onPrimary, fontSize: 12, fontWeight: FontWeight.w700, + shadows: _playerTextShadows, ), ), ], @@ -93,7 +95,7 @@ class _BottomIconButton extends StatelessWidget { tooltip: tooltip, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: AppColors.playerChip, + backgroundColor: AppColors.playerOverlayPill, foregroundColor: AppColors.onPrimary, ), onPressed: onPressed, @@ -252,16 +254,20 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { widget.l10n.episodeLabel(widget.episodeNumber), key: const Key('player-episode-label'), style: TextStyle( - color: AppColors.blue, + color: AppColors.onPrimary, fontSize: 11 * widget.textScale, fontWeight: FontWeight.w700, + shadows: _playerTextShadows, ), ), const Padding( padding: EdgeInsets.symmetric(horizontal: 6), child: Text( '|', - style: TextStyle(color: AppColors.playerDivider), + style: TextStyle( + color: Colors.white54, + shadows: _playerTextShadows, + ), ), ), Expanded( @@ -274,6 +280,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { color: AppColors.playerMuted, fontSize: 11 * widget.textScale, height: 1.35, + shadows: _playerTextShadows, ), ), ), @@ -284,6 +291,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { : Icons.keyboard_arrow_down_rounded, color: AppColors.playerMuted, size: 16, + shadows: _playerTextShadows, ), ], ), @@ -295,6 +303,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { style: const TextStyle( color: AppColors.playerMuted, fontSize: 10, + shadows: _playerTextShadows, ), ), ], diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index f4a78e7..47eb77e 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -187,16 +187,35 @@ void main() { final rail = tester.getRect( find.byKey(const Key('player-interaction-rail')), ); + expect(find.byKey(const Key('player-background')), findsNothing); + final fallback = find.byKey(const Key('player-fallback-background')); + if (fallback.evaluate().isNotEmpty) { + expect(tester.widget(fallback).color, Colors.black); + } expect( tester - .widget(find.byKey(const Key('player-background'))) + .widget(find.byKey(const Key('player-scaffold'))) + .backgroundColor, + Colors.transparent, + ); + expect( + tester + .widget(find.byKey(const Key('player-controls-top'))) + .decoration, + isNull, + ); + expect( + tester + .widget(find.byKey(const Key('player-controls-bottom'))) + .child, + isNotNull, + ); + expect( + tester + .widget(find.byKey(const Key('player-episodes-pill'))) .color, - AppColors.playerBackground, + AppColors.playerOverlayPill, ); - final panel = tester.widget( - find.byKey(const Key('player-controls-bottom')), - ); - expect((panel.decoration! as BoxDecoration).color, AppColors.playerPanel); expect(bottom.height, lessThanOrEqualTo(200)); expect(bottom.top, greaterThanOrEqualTo(screenHeight * 0.78)); From 3068eb99da3a18af4b17d2132920ddcb962fbd23 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:33:23 +0800 Subject: [PATCH 032/124] =?UTF-8?q?fix(flutter):=20=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E6=B8=85=E5=B1=8F=E8=8C=83=E5=9B=B4=E5=B9=B6=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=BA=95=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/player_overlay.dart | 195 ++++++++++--------- test/player/player_fullscreen_test.dart | 75 +++---- 2 files changed, 136 insertions(+), 134 deletions(-) diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 4e3128a..ae72e42 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -5,7 +5,6 @@ import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; -import 'exit_clear_screen_button.dart'; import 'player_center_toggle.dart'; import 'player_overlay_models.dart'; import 'seek_progress_bar.dart'; @@ -51,21 +50,9 @@ class PlayerPlaybackOverlay extends StatelessWidget { onTap: actions.onTogglePlay, ), ), - if (controlsVisible) ...[ - _top(context), - _bottom(context), - _rail(context), - ], - if (!controlsVisible) - Positioned( - top: AppDimens.spaceSm, - right: AppDimens.spaceMd, - child: ExitClearScreenButton( - key: const Key('player-exit-clear-screen'), - tooltip: data.l10n.exitClearScreen, - onPressed: actions.onExitClear, - ), - ), + _top(context), + _bottom(context), + if (controlsVisible) _rail(context), if (!data.playing) Center( child: PlayerCenterToggle( @@ -153,43 +140,49 @@ class PlayerPlaybackOverlay extends StatelessWidget { } Widget _bottom(BuildContext context) { - final series = localizedDramaTitle(data.l10n, data.drama); - final synopsis = localizedDramaSynopsis(data.l10n, data.drama); return Positioned( left: 0, right: 0, bottom: 0, - child: Padding( - key: const Key('player-controls-bottom'), - padding: const EdgeInsets.fromLTRB(14, 8, 14, 6), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _detailRow(series), - const SizedBox(height: 4), - _EpisodeSynopsis( - key: const Key('player-episode-synopsis'), - l10n: data.l10n, - episodeNumber: data.episode.number, - synopsis: synopsis, - authorNote: data.authorNote, - textScale: data.textScale, - ), - const SizedBox(height: 2), - SeekProgressBar( - key: const Key('player-progress'), - valueListenable: data.controller, - semanticsLabel: data.l10n.seekVideo, - showTimes: true, - compact: true, - inactiveTrackColor: Colors.white24, - onScrubStart: actions.onScrubStart, - onSeek: actions.onSeek, - ), - _bottomActions(), - ], - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [if (controlsVisible) _bottomInfo(context), _bottomActions()], + ), + ); + } + + Widget _bottomInfo(BuildContext context) { + final series = localizedDramaTitle(data.l10n, data.drama); + final synopsis = localizedDramaSynopsis(data.l10n, data.drama); + return Padding( + key: const Key('player-controls-bottom'), + padding: const EdgeInsets.fromLTRB(14, 8, 14, 0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailRow(series), + const SizedBox(height: 4), + _EpisodeSynopsis( + key: const Key('player-episode-synopsis'), + l10n: data.l10n, + episodeNumber: data.episode.number, + synopsis: synopsis, + authorNote: data.authorNote, + textScale: data.textScale, + ), + const SizedBox(height: 2), + SeekProgressBar( + key: const Key('player-progress'), + valueListenable: data.controller, + semanticsLabel: data.l10n.seekVideo, + showTimes: true, + compact: true, + inactiveTrackColor: Colors.white24, + onScrubStart: actions.onScrubStart, + onSeek: actions.onSeek, + ), + ], ), ); } @@ -234,61 +227,69 @@ class PlayerPlaybackOverlay extends StatelessWidget { } Widget _bottomActions() { - return Row( - children: [ - Expanded( - child: Material( - key: const Key('player-episodes-pill'), - color: AppColors.playerOverlayPill, - borderRadius: BorderRadius.circular(999), - clipBehavior: Clip.antiAlias, - child: InkWell( - key: const Key('player-episodes'), - onTap: actions.onEpisodes, + return Padding( + key: const Key('player-bottom-actions'), + padding: const EdgeInsets.fromLTRB(14, 6, 14, 6), + child: Row( + children: [ + Expanded( + child: Material( + key: const Key('player-episodes-pill'), + color: AppColors.playerOverlayPill, borderRadius: BorderRadius.circular(999), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - child: Row( - children: [ - Flexible( - child: Text( - '${data.l10n.episodes} · ' - '${data.l10n.allEpisodesBadge(data.totalEpisodes)} · ' - '${data.l10n.freeWatch}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 11, - fontWeight: FontWeight.w700, - shadows: _playerTextShadows, + clipBehavior: Clip.antiAlias, + child: InkWell( + key: const Key('player-episodes'), + onTap: actions.onEpisodes, + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + child: Row( + children: [ + Flexible( + child: Text( + '${data.l10n.episodes} · ' + '${data.l10n.allEpisodesBadge(data.totalEpisodes)} · ' + '${data.l10n.freeWatch}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 11, + fontWeight: FontWeight.w700, + shadows: _playerTextShadows, + ), ), ), - ), - const SizedBox(width: 3), - const Icon( - Icons.keyboard_arrow_up_rounded, - color: AppColors.onPrimary, - size: 16, - shadows: _playerTextShadows, - ), - ], + const SizedBox(width: 3), + const Icon( + Icons.keyboard_arrow_up_rounded, + color: AppColors.onPrimary, + size: 16, + shadows: _playerTextShadows, + ), + ], + ), ), ), ), ), - ), - const SizedBox(width: 8), - _BottomIconButton( - key: const Key('player-bottom-clear-screen'), - tooltip: data.l10n.clearScreen, - icon: Icons.fullscreen_rounded, - onPressed: actions.onClear, - ), - ], + const SizedBox(width: 8), + _BottomIconButton( + key: const Key('player-bottom-clear-screen'), + tooltip: controlsVisible + ? data.l10n.clearScreen + : data.l10n.exitClearScreen, + icon: controlsVisible + ? Icons.fullscreen_rounded + : Icons.fullscreen_exit_rounded, + onPressed: controlsVisible ? actions.onClear : actions.onExitClear, + ), + ], + ), ); } } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 47eb77e..6f24abd 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -110,8 +110,10 @@ void main() { await _clearFromMore(tester); await tester.pump(); - expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); + expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byType(PlayerCenterToggle), findsOneWidget); expect( tester @@ -122,45 +124,30 @@ void main() { isFalse, ); expect(find.byKey(const Key('player-video-tap')), findsOneWidget); - expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); + expect(find.byTooltip('Exit clear screen'), findsOneWidget); expect( - tester.getSize(find.byKey(const Key('player-exit-clear-screen'))), - const Size(36, 36), + tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), + const Size(30, 30), ); await tester.tapAt(const Offset(80, 300)); await tester.pump(); - expect(find.byKey(const Key('player-controls-top')), findsNothing); - expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsNothing); await tester.tapAt(const Offset(80, 300)); await tester.pump(); - expect(find.byKey(const Key('player-controls-top')), findsNothing); - expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('player-play-toggle')), - ) - .playing, - isFalse, - ); + expect(find.byType(PlayerCenterToggle), findsOneWidget); - await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); + await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('player-play-toggle')), - ) - .playing, - isFalse, - ); - expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); + expect(find.byTooltip('Clear screen'), findsOneWidget); }); testWidgets('should_fit_player_header_on_320_width', (tester) async { @@ -251,20 +238,22 @@ void main() { await _clearFromMore(tester); await tester.pump(); - expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); - expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); + expect(find.byKey(const Key('player-interaction-rail')), findsNothing); + expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); + await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); - expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); + expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); }); testWidgets('should_open_speed_sheet_and_apply_selected_speed', ( @@ -309,9 +298,9 @@ void main() { await _clearFromMore(tester); await tester.pump(); - expect(find.byKey(const Key('player-more')), findsNothing); + expect(find.byKey(const Key('player-more')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); + await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pump(); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); @@ -448,9 +437,11 @@ void main() { await tester.tap(find.byKey(const Key('player-more-clear'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); - expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); + expect(find.byKey(const Key('player-interaction-rail')), findsNothing); + expect(find.byTooltip('Exit clear screen'), findsOneWidget); }); testWidgets('should_enter_clear_screen_from_bottom_button', (tester) async { @@ -462,9 +453,19 @@ void main() { await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); - expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); + expect(find.byKey(const Key('player-interaction-rail')), findsNothing); + expect(find.byType(PlayerCenterToggle), findsNothing); + expect(find.byTooltip('Exit clear screen'), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsNothing); + expect(find.byTooltip('Clear screen'), findsOneWidget); }); testWidgets('should_open_unlock_panel_with_honest_method_states', ( From d849834ce661e0b2ef96490fb69afe4a980d90e8 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:35:48 +0800 Subject: [PATCH 033/124] =?UTF-8?q?fix(flutter):=20=E6=B8=85=E5=B1=8F?= =?UTF-8?q?=E6=97=B6=E4=BF=9D=E7=95=99=E8=BF=9B=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/player_overlay.dart | 34 +++++++++++++------- test/player/player_fullscreen_test.dart | 4 +++ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index ae72e42..c5054cc 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -146,7 +146,11 @@ class PlayerPlaybackOverlay extends StatelessWidget { bottom: 0, child: Column( mainAxisSize: MainAxisSize.min, - children: [if (controlsVisible) _bottomInfo(context), _bottomActions()], + children: [ + if (controlsVisible) _bottomInfo(context), + _bottomProgress(), + _bottomActions(), + ], ), ); } @@ -171,22 +175,28 @@ class PlayerPlaybackOverlay extends StatelessWidget { authorNote: data.authorNote, textScale: data.textScale, ), - const SizedBox(height: 2), - SeekProgressBar( - key: const Key('player-progress'), - valueListenable: data.controller, - semanticsLabel: data.l10n.seekVideo, - showTimes: true, - compact: true, - inactiveTrackColor: Colors.white24, - onScrubStart: actions.onScrubStart, - onSeek: actions.onSeek, - ), ], ), ); } + Widget _bottomProgress() { + return Padding( + key: const Key('player-progress-container'), + padding: EdgeInsets.fromLTRB(14, controlsVisible ? 2 : 8, 14, 0), + child: SeekProgressBar( + key: const Key('player-progress'), + valueListenable: data.controller, + semanticsLabel: data.l10n.seekVideo, + showTimes: true, + compact: true, + inactiveTrackColor: Colors.white24, + onScrubStart: actions.onScrubStart, + onSeek: actions.onSeek, + ), + ); + } + Widget _detailRow(String series) { return Material( color: Colors.transparent, diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 6f24abd..07ea8f4 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -113,6 +113,7 @@ void main() { expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); + expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byType(PlayerCenterToggle), findsOneWidget); expect( @@ -246,6 +247,7 @@ void main() { expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); + expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); @@ -440,6 +442,7 @@ void main() { expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); + expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); }); @@ -456,6 +459,7 @@ void main() { expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); + expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byType(PlayerCenterToggle), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); From c4797cd239c79f5bd7a547419dd9c8c4a8353c33 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:40:15 +0800 Subject: [PATCH 034/124] =?UTF-8?q?fix(flutter):=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E9=A1=B5=E6=9B=B4=E5=A4=9A=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 9 +- lib/pages/player/player_view_actions.dart | 39 +- .../player/widgets/player_more_sheet.dart | 344 +++++++----------- lib/pages/player/widgets/player_overlay.dart | 3 +- .../player/widgets/player_overlay_models.dart | 4 - .../player/widgets/player_overlay_parts.dart | 6 +- .../player/widgets/player_text_scale.dart | 19 - test/player/player_fullscreen_test.dart | 61 +++- 8 files changed, 175 insertions(+), 310 deletions(-) delete mode 100644 lib/pages/player/widgets/player_text_scale.dart diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index a09199c..431461b 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -21,7 +21,6 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; -import '../interaction/widgets/report_dialog.dart'; import 'player_binding.dart'; import 'player_logic.dart'; import 'widgets/home_feed_overlay.dart'; @@ -83,7 +82,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; bool _chromeVisible = true; double _playbackSpeed = 1.0; - double _textScale = 1.0; @override void initState() { @@ -125,10 +123,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { setState(() => _playbackSpeed = value); } - void _updateTextScale(double value) { - setState(() => _textScale = value); - } - void _hideChrome() { setState(() => _chromeVisible = false); } @@ -389,7 +383,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { saved: context.watch().isSaved(drama.id), playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, - textScale: _textScale, totalEpisodes: logic.visibleIndices.length, ), actions: PlayerOverlayActions( @@ -408,7 +401,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { unawaited(_toggleLike(context, interactions, drama)), onSave: () => unawaited(_toggleSave(context, drama)), onShare: () => logic.shareDrama(context, drama), - onMore: () => _openMore(context, drama, episode), + onMore: () => _openMore(context, episode), onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index ac3c172..748cc50 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -15,11 +15,7 @@ extension _PlayerViewActions on _PlayerPageState { await logic.setPlaybackSpeed(selected); } - Future _openMore( - BuildContext context, - Drama drama, - Episode episode, - ) async { + Future _openMore(BuildContext context, Episode episode) async { final l10n = AppLocalizations.of(context)!; await showModalBottomSheet( context: context, @@ -31,21 +27,14 @@ extension _PlayerViewActions on _PlayerPageState { state: PlayerMoreState( speed: _playbackSpeed, qualities: logic.qualities, - textScale: _textScale, - pipAvailable: logic.pictureInPicture.isPlatformSupported, ), actions: PlayerMoreActions( onSpeedChanged: (value) { _updatePlaybackSpeed(value); unawaited(logic.setPlaybackSpeed(value)); }, - onTextScaleChanged: _updateTextScale, onClear: _hideChrome, onDownload: () => requestEpisodeDownload(context, episode.id), - onReport: () => unawaited(_reportDrama(context, drama, l10n)), - onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(logic.enterPip(context)) - : null, ), ), ); @@ -86,32 +75,6 @@ extension _PlayerViewActions on _PlayerPageState { return false; } - Future _reportDrama( - BuildContext context, - Drama drama, - AppLocalizations l10n, - ) async { - if (!await ensureSignedIn(context) || !context.mounted) return; - final draft = await showReportDialog(context, title: l10n.reportDrama); - if (draft == null || !context.mounted) return; - unawaited(context.read().lightImpact()); - final result = await context.read().reportDrama( - drama.id, - reason: draft.reason, - description: draft.description, - ); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.isSuccess - ? l10n.reportSubmitted - : result.errorMessage ?? l10n.reportFailed, - ), - ), - ); - } - Future _toggleLike( BuildContext context, InteractionController interactions, diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart index 6a885e6..6764b47 100644 --- a/lib/pages/player/widgets/player_more_sheet.dart +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -4,9 +4,14 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; -import 'player_text_scale.dart'; const double _sheetRadius = 20; +const double _rowHeight = 44; +const double _groupGap = 12; +const double _segmentHeight = 28; +const double _segmentRadius = 8; +const double _segmentPadding = 10; +const double _segmentGap = 6; const List _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; const List _qualityOptions = ['AUTO', '720P', '1080P']; const String _autoQuality = 'AUTO'; @@ -14,24 +19,13 @@ const String _autoQuality = 'AUTO'; /// 更多面板运行状态。 class PlayerMoreState { /// 创建更多面板状态。 - const PlayerMoreState({ - required this.speed, - required this.qualities, - required this.textScale, - required this.pipAvailable, - }); + const PlayerMoreState({required this.speed, required this.qualities}); /// 当前倍速。 final double speed; /// 后端下发的清晰度档位。 final List qualities; - - /// 当前文字缩放比例。 - final double textScale; - - /// 设备是否提供画中画入口。 - final bool pipAvailable; } /// 更多面板动作集合。 @@ -39,33 +33,21 @@ class PlayerMoreActions { /// 创建动作集合。 const PlayerMoreActions({ required this.onSpeedChanged, - required this.onTextScaleChanged, required this.onClear, required this.onDownload, - required this.onReport, - this.onPip, }); /// 倍速变化。 final ValueChanged onSpeedChanged; - /// 字体大小变化。 - final ValueChanged onTextScaleChanged; - /// 进入清屏播放。 final VoidCallback onClear; /// 离线缓存当前集。 final VoidCallback onDownload; - - /// 投诉当前剧。 - final VoidCallback onReport; - - /// 进入画中画;平台不支持时为空。 - final VoidCallback? onPip; } -/// K29 播放页白色更多面板。 +/// K29.6 精简版播放页更多面板。 class PlayerMoreSheet extends StatefulWidget { /// 创建更多面板。 const PlayerMoreSheet({ @@ -90,84 +72,55 @@ class PlayerMoreSheet extends StatefulWidget { class _PlayerMoreSheetState extends State { late double _speed = widget.state.speed; - late double _textScale = widget.state.textScale; @override Widget build(BuildContext context) { final l10n = widget.l10n; - return Container( + return Material( key: const Key('player-more-sheet'), - constraints: const BoxConstraints(maxHeight: 620), - decoration: const BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + color: AppColors.card, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(_sheetRadius), ), + clipBehavior: Clip.antiAlias, child: SafeArea( top: false, child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(18, 14, 18, 18), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Center( + child: Container( + key: const Key('player-more-drag-handle'), + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 8), _header(l10n), _sectionTitle(l10n.playbackSpeed), _speedPicker(), - const SizedBox(height: 16), + const SizedBox(height: _groupGap), _sectionTitle(l10n.playbackQuality), _qualityPicker(l10n), - const SizedBox(height: 12), - _disabledRow( - key: const Key('player-more-not-interested'), - icon: Icons.thumb_down_alt_outlined, - title: l10n.notInterested, - ), - _enabledRow( + const SizedBox(height: _groupGap), + _actionRow( key: const Key('player-more-clear'), icon: Icons.fullscreen_rounded, title: l10n.clearPlayback, onTap: _clear, ), - _disabledSwitchRow( - key: const Key('player-more-danmaku'), - icon: Icons.subtitles_rounded, - title: l10n.danmaku, - unavailable: l10n.danmakuUnavailable, - ), - _disabledRow( - key: const Key('player-more-danmaku-settings'), - icon: Icons.tune_rounded, - title: l10n.danmakuSettings, - ), - _enabledRow( + _actionRow( key: const Key('player-more-offline'), icon: Icons.download_rounded, title: l10n.offlineCache, onTap: () => _closeThen(widget.actions.onDownload), ), - if (widget.state.pipAvailable && widget.actions.onPip != null) - _enabledRow( - key: const Key('player-more-pip'), - icon: Icons.picture_in_picture_alt_rounded, - title: l10n.pictureInPicture, - onTap: () => _closeThen(widget.actions.onPip!), - ), - _textSizeRow(l10n), - _disabledRow( - key: const Key('player-more-cast'), - icon: Icons.cast_rounded, - title: l10n.screenCast, - ), - _disabledRow( - key: const Key('player-more-timer'), - icon: Icons.timer_outlined, - title: l10n.sleepTimer, - ), - _enabledRow( - key: const Key('player-more-report'), - icon: Icons.flag_outlined, - title: l10n.reportDrama, - onTap: () => _closeThen(widget.actions.onReport), - ), ], ), ), @@ -182,44 +135,47 @@ class _PlayerMoreSheetState extends State { l10n.more, style: const TextStyle( color: AppColors.onSurface, - fontSize: 20, - fontWeight: FontWeight.w800, + fontSize: 15, + fontWeight: FontWeight.w700, ), ), ), - IconButton( - key: const Key('player-more-close'), - tooltip: l10n.close, - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close_rounded), + SizedBox.square( + dimension: 32, + child: IconButton( + key: const Key('player-more-close'), + tooltip: l10n.close, + padding: EdgeInsets.zero, + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded, size: 18), + ), ), ], ); Widget _sectionTitle(String title) => Padding( - padding: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.only(bottom: 8, top: 2), child: Text( title, style: const TextStyle( - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w800, + color: AppColors.muted, + fontSize: 12, + fontWeight: FontWeight.w600, ), ), ); Widget _speedPicker() => Wrap( key: const Key('player-more-speed'), - spacing: 8, - runSpacing: 8, + spacing: _segmentGap, + runSpacing: _segmentGap, children: [ for (final value in _speedOptions) - ChoiceChip( + _SegmentButton( key: Key('player-more-speed-$value'), - label: Text('${value}x'), + label: '${value}x', selected: (_speed - value).abs() < 0.001, - showCheckmark: false, - onSelected: (_) { + onTap: () { setState(() => _speed = value); widget.actions.onSpeedChanged(value); }, @@ -229,110 +185,69 @@ class _PlayerMoreSheetState extends State { Widget _qualityPicker(AppLocalizations l10n) => Wrap( key: const Key('player-more-quality'), - spacing: 8, - runSpacing: 8, + spacing: _segmentGap, + runSpacing: _segmentGap, children: [ - for (final quality in _qualityOptions) _qualityChip(l10n, quality), + for (final quality in _qualityOptions) _qualitySegment(l10n, quality), ], ); - Widget _qualityChip(AppLocalizations l10n, String quality) { + Widget _qualitySegment(AppLocalizations l10n, String quality) { final enabled = widget.state.qualities.contains(quality); final selected = quality == _autoQuality && enabled || !widget.state.qualities.contains(_autoQuality) && widget.state.qualities.isNotEmpty && widget.state.qualities.first == quality; - return ChoiceChip( + return _SegmentButton( key: Key('player-more-quality-$quality'), - label: Text(enabled ? quality : '$quality · ${l10n.qualityUnavailable}'), + label: enabled ? quality : '$quality · ${l10n.qualityUnavailable}', selected: selected, - showCheckmark: false, - onSelected: enabled ? (_) {} : null, + enabled: enabled, + onTap: enabled ? () {} : null, ); } - Widget _textSizeRow(AppLocalizations l10n) => Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - key: const Key('player-more-text-size'), - children: [ - const Icon(Icons.format_size_rounded, color: AppColors.muted, size: 21), - const SizedBox(width: 12), - Expanded( - child: Text( - l10n.textSize, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ), - PopupMenuButton( - tooltip: l10n.textSize, - initialValue: _textScale, - onSelected: (value) { - setState(() => _textScale = value); - widget.actions.onTextScaleChanged(value); - }, - itemBuilder: (_) => [ - PopupMenuItem( - key: const Key('player-text-size-small'), - value: playerTextSizeSmall, - child: Text(l10n.textSizeSmall), - ), - PopupMenuItem( - key: const Key('player-text-size-standard'), - value: playerTextSizeStandard, - child: Text(l10n.textSizeStandard), - ), - PopupMenuItem( - key: const Key('player-text-size-large'), - value: playerTextSizeLarge, - child: Text(l10n.textSizeLarge), - ), - ], - child: Text( - playerTextSizeLabel(l10n, _textScale), - style: const TextStyle( - color: AppColors.blue, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - ); - - Widget _enabledRow({ + Widget _actionRow({ required Key key, required IconData icon, required String title, required VoidCallback onTap, - }) => - _MoreRow(key: key, icon: icon, title: title, enabled: true, onTap: onTap); - - Widget _disabledRow({ - required Key key, - required IconData icon, - required String title, - }) => _MoreRow(key: key, icon: icon, title: title, enabled: false); - - Widget _disabledSwitchRow({ - required Key key, - required IconData icon, - required String title, - required String unavailable, - }) => _MoreRow( - key: key, - icon: icon, - title: title, - enabled: false, - value: unavailable, - trailing: const Switch(value: false, onChanged: null), - ); + }) { + return Material( + key: key, + color: Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: SizedBox( + height: _rowHeight, + child: Row( + children: [ + Icon(icon, color: AppColors.muted, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 18, + ), + ], + ), + ), + ), + ); + } void _clear() { Navigator.pop(context); @@ -347,56 +262,53 @@ class _PlayerMoreSheetState extends State { } } -class _MoreRow extends StatelessWidget { - const _MoreRow({ +class _SegmentButton extends StatelessWidget { + const _SegmentButton({ super.key, - required this.icon, - required this.title, - required this.enabled, - this.value = '', - this.trailing, + required this.label, + required this.selected, + this.enabled = true, this.onTap, }); - final IconData icon; - final String title; + final String label; + final bool selected; final bool enabled; - final String value; - final Widget? trailing; final VoidCallback? onTap; @override Widget build(BuildContext context) { - final color = enabled ? AppColors.onSurface : AppColors.subtle; + final background = selected + ? AppColors.blue + : enabled + ? AppColors.raised + : AppColors.cardAlt; + final foreground = selected + ? AppColors.onPrimary + : enabled + ? AppColors.onSurface + : AppColors.subtle; return Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(10), + color: background, + borderRadius: BorderRadius.circular(_segmentRadius), child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: enabled ? onTap : null, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11), - child: Row( - children: [ - Icon(icon, color: color, size: 21), - const SizedBox(width: 12), - Expanded( - child: Text( - title, - style: TextStyle( - color: color, - fontSize: 14, - fontWeight: FontWeight.w600, - ), + onTap: onTap, + borderRadius: BorderRadius.circular(_segmentRadius), + child: SizedBox( + height: _segmentHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _segmentPadding), + child: Center( + child: Text( + label, + maxLines: 1, + style: TextStyle( + color: foreground, + fontSize: 12, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, ), ), - if (value.isNotEmpty) - Text( - value, - style: const TextStyle(color: AppColors.subtle, fontSize: 11), - ), - ?trailing, - ], + ), ), ), ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index c5054cc..bc6773d 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -173,7 +173,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { episodeNumber: data.episode.number, synopsis: synopsis, authorNote: data.authorNote, - textScale: data.textScale, ), ], ), @@ -216,7 +215,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { style: TextStyle( color: AppColors.onPrimary, fontFamily: 'Outfit', - fontSize: 18 * data.textScale, + fontSize: 18, fontWeight: FontWeight.w800, shadows: _playerTextShadows, ), diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 7c43d8c..4849fa3 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -21,7 +21,6 @@ class PlayerOverlayData { required this.saved, required this.playing, required this.playbackSpeed, - required this.textScale, required this.totalEpisodes, this.authorNote = '', }); @@ -59,9 +58,6 @@ class PlayerOverlayData { /// 当前倍速。 final double playbackSpeed; - /// 信息文字缩放比例。 - final double textScale; - /// 可见集数量。 final int totalEpisodes; diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index f50403f..034ec5b 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -221,14 +221,12 @@ class _EpisodeSynopsis extends StatefulWidget { required this.episodeNumber, required this.synopsis, required this.authorNote, - required this.textScale, }); final AppLocalizations l10n; final int episodeNumber; final String synopsis; final String authorNote; - final double textScale; @override State<_EpisodeSynopsis> createState() => _EpisodeSynopsisState(); @@ -255,7 +253,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { key: const Key('player-episode-label'), style: TextStyle( color: AppColors.onPrimary, - fontSize: 11 * widget.textScale, + fontSize: 11, fontWeight: FontWeight.w700, shadows: _playerTextShadows, ), @@ -278,7 +276,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { overflow: TextOverflow.ellipsis, style: TextStyle( color: AppColors.playerMuted, - fontSize: 11 * widget.textScale, + fontSize: 11, height: 1.35, shadows: _playerTextShadows, ), diff --git a/lib/pages/player/widgets/player_text_scale.dart b/lib/pages/player/widgets/player_text_scale.dart deleted file mode 100644 index 1259f53..0000000 --- a/lib/pages/player/widgets/player_text_scale.dart +++ /dev/null @@ -1,19 +0,0 @@ -// Author: fruit.com.cn - -import '../../../l10n/app_localizations.dart'; - -/// 播放信息小号文字比例。 -const double playerTextSizeSmall = 0.9; - -/// 播放信息标准文字比例。 -const double playerTextSizeStandard = 1.0; - -/// 播放信息大号文字比例。 -const double playerTextSizeLarge = 1.15; - -/// 返回播放信息文字档位的本地化名称。 -String playerTextSizeLabel(AppLocalizations l10n, double scale) { - if (scale <= playerTextSizeSmall) return l10n.textSizeSmall; - if (scale >= playerTextSizeLarge) return l10n.textSizeLarge; - return l10n.textSizeStandard; -} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 07ea8f4..4c239f9 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -370,7 +370,7 @@ void main() { expect(find.byKey(const Key('player-episode-2')), findsOneWidget); }); - testWidgets('should_open_more_actions_and_change_text_size', (tester) async { + testWidgets('should_open_compact_more_actions', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); @@ -381,22 +381,35 @@ void main() { expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget); expect(find.byKey(const Key('player-more-offline')), findsOneWidget); expect(find.byKey(const Key('player-more-clear')), findsOneWidget); - expect(find.byKey(const Key('player-more-danmaku')), findsOneWidget); + expect(find.byKey(const Key('player-more-drag-handle')), findsOneWidget); + for (final removed in const [ + Key('player-more-not-interested'), + Key('player-more-danmaku'), + Key('player-more-danmaku-settings'), + Key('player-more-text-size'), + Key('player-more-cast'), + Key('player-more-timer'), + Key('player-more-pip'), + Key('player-more-report'), + ]) { + expect(find.byKey(removed), findsNothing); + } expect( - find.byKey(const Key('player-more-danmaku-settings')), - findsOneWidget, + tester.getSize(find.byKey(const Key('player-more-speed-1.0'))).height, + 28, + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('player-more-speed-1.0')), + matching: find.text('1.0x'), + ), + ) + .style + ?.fontSize, + 12, ); - expect(find.byKey(const Key('player-more-cast')), findsOneWidget); - expect(find.byKey(const Key('player-more-timer')), findsOneWidget); - expect(find.byKey(const Key('player-more-text-size')), findsOneWidget); - expect(find.byKey(const Key('player-more-report')), findsOneWidget); - - await tester.tap(find.byTooltip('Text size')); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-text-size-large')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-text-size-large'))); - await tester.pumpAndSettle(); - expect(find.text('Large'), findsOneWidget); }); testWidgets('should_disable_quality_not_supplied_by_ticket', (tester) async { @@ -418,14 +431,24 @@ void main() { expect( tester - .widget(find.byKey(const Key('player-more-quality-AUTO'))) - .onSelected, + .widget( + find.descendant( + of: find.byKey(const Key('player-more-quality-AUTO')), + matching: find.byType(InkWell), + ), + ) + .onTap, isNotNull, ); expect( tester - .widget(find.byKey(const Key('player-more-quality-720P'))) - .onSelected, + .widget( + find.descendant( + of: find.byKey(const Key('player-more-quality-720P')), + matching: find.byType(InkWell), + ), + ) + .onTap, isNull, ); expect(find.text('720P · Unavailable'), findsOneWidget); From f083a161e66c3d72c4d275e6f8ad44b58d5c480f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:44:42 +0800 Subject: [PATCH 035/124] =?UTF-8?q?fix(flutter):=20=E6=9B=B4=E5=A4=9A?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=80=8D=E9=80=9F=E6=B8=85=E6=99=B0=E5=BA=A6?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8D=95=E8=A1=8C=E6=8E=92=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/widgets/player_more_sheet.dart | 91 ++++++++++++++----- test/player/player_fullscreen_test.dart | 52 +++++++++++ 2 files changed, 118 insertions(+), 25 deletions(-) diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart index 6764b47..53cf4df 100644 --- a/lib/pages/player/widgets/player_more_sheet.dart +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -103,10 +103,8 @@ class _PlayerMoreSheetState extends State { ), const SizedBox(height: 8), _header(l10n), - _sectionTitle(l10n.playbackSpeed), _speedPicker(), - const SizedBox(height: _groupGap), - _sectionTitle(l10n.playbackQuality), + const SizedBox(height: 4), _qualityPicker(l10n), const SizedBox(height: _groupGap), _actionRow( @@ -153,23 +151,12 @@ class _PlayerMoreSheetState extends State { ], ); - Widget _sectionTitle(String title) => Padding( - padding: const EdgeInsets.only(bottom: 8, top: 2), - child: Text( - title, - style: const TextStyle( - color: AppColors.muted, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ); - - Widget _speedPicker() => Wrap( - key: const Key('player-more-speed'), - spacing: _segmentGap, - runSpacing: _segmentGap, - children: [ + Widget _speedPicker() => _optionRow( + rowKey: const Key('player-more-speed-row'), + optionsKey: const Key('player-more-speed'), + icon: Icons.speed_rounded, + title: widget.l10n.playbackSpeed, + options: [ for (final value in _speedOptions) _SegmentButton( key: Key('player-more-speed-$value'), @@ -183,15 +170,69 @@ class _PlayerMoreSheetState extends State { ], ); - Widget _qualityPicker(AppLocalizations l10n) => Wrap( - key: const Key('player-more-quality'), - spacing: _segmentGap, - runSpacing: _segmentGap, - children: [ + Widget _qualityPicker(AppLocalizations l10n) => _optionRow( + rowKey: const Key('player-more-quality-row'), + optionsKey: const Key('player-more-quality'), + icon: Icons.high_quality_rounded, + title: l10n.playbackQuality, + options: [ for (final quality in _qualityOptions) _qualitySegment(l10n, quality), ], ); + Widget _optionRow({ + required Key rowKey, + required Key optionsKey, + required IconData icon, + required String title, + required List options, + }) { + return SizedBox( + key: rowKey, + height: _rowHeight, + child: Row( + children: [ + SizedBox( + width: 88, + child: Row( + children: [ + Icon(icon, color: AppColors.muted, size: 18), + const SizedBox(width: 6), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 6), + Expanded( + child: SingleChildScrollView( + key: optionsKey, + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var index = 0; index < options.length; index++) ...[ + if (index > 0) const SizedBox(width: _segmentGap), + options[index], + ], + ], + ), + ), + ), + ], + ), + ); + } + Widget _qualitySegment(AppLocalizations l10n, String quality) { final enabled = widget.state.qualities.contains(quality); final selected = diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 4c239f9..6db27eb 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -398,6 +398,30 @@ void main() { tester.getSize(find.byKey(const Key('player-more-speed-1.0'))).height, 28, ); + expect( + tester.getSize(find.byKey(const Key('player-more-speed-row'))).height, + 44, + ); + expect( + tester.getSize(find.byKey(const Key('player-more-quality-row'))).height, + 44, + ); + expect( + tester + .widget( + find.byKey(const Key('player-more-speed')), + ) + .scrollDirection, + Axis.horizontal, + ); + expect( + tester + .widget( + find.byKey(const Key('player-more-quality')), + ) + .scrollDirection, + Axis.horizontal, + ); expect( tester .widget( @@ -410,6 +434,34 @@ void main() { ?.fontSize, 12, ); + await tester.ensureVisible(find.byKey(const Key('player-more-speed-1.5'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more-speed-1.5'))); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('player-more-speed-1.5')), + matching: find.text('1.5x'), + ), + ) + .style + ?.fontWeight, + FontWeight.w700, + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('player-more-speed-1.0')), + matching: find.text('1.0x'), + ), + ) + .style + ?.fontWeight, + FontWeight.w500, + ); }); testWidgets('should_disable_quality_not_supplied_by_ticket', (tester) async { From 0af5de9641564c59086e1851b0bfe4f4a05ab710 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:47:58 +0800 Subject: [PATCH 036/124] =?UTF-8?q?fix(flutter):=20=E6=9B=B4=E5=A4=9A?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=A2=9E=E5=8A=A0=E5=B0=8F=E7=AA=97=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view_actions.dart | 3 + .../player/widgets/player_more_sheet.dart | 11 +++ test/player/player_fullscreen_test.dart | 25 +++++- test/player/player_more_sheet_test.dart | 77 +++++++++++++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 test/player/player_more_sheet_test.dart diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 748cc50..ca874c1 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -35,6 +35,9 @@ extension _PlayerViewActions on _PlayerPageState { }, onClear: _hideChrome, onDownload: () => requestEpisodeDownload(context, episode.id), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, ), ), ); diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart index 53cf4df..49eb80f 100644 --- a/lib/pages/player/widgets/player_more_sheet.dart +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -35,6 +35,7 @@ class PlayerMoreActions { required this.onSpeedChanged, required this.onClear, required this.onDownload, + this.onPip, }); /// 倍速变化。 @@ -45,6 +46,9 @@ class PlayerMoreActions { /// 离线缓存当前集。 final VoidCallback onDownload; + + /// 进入小窗播放;平台不支持时为 null。 + final VoidCallback? onPip; } /// K29.6 精简版播放页更多面板。 @@ -113,6 +117,13 @@ class _PlayerMoreSheetState extends State { title: l10n.clearPlayback, onTap: _clear, ), + if (widget.actions.onPip != null) + _actionRow( + key: const Key('player-more-pip'), + icon: Icons.picture_in_picture_alt_rounded, + title: l10n.pictureInPicture, + onTap: () => _closeThen(widget.actions.onPip!), + ), _actionRow( key: const Key('player-more-offline'), icon: Icons.download_rounded, diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 6db27eb..9b9d1ee 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -389,7 +389,6 @@ void main() { Key('player-more-text-size'), Key('player-more-cast'), Key('player-more-timer'), - Key('player-more-pip'), Key('player-more-report'), ]) { expect(find.byKey(removed), findsNothing); @@ -506,6 +505,30 @@ void main() { expect(find.text('720P · Unavailable'), findsOneWidget); }); + testWidgets('should_show_pip_action_when_platform_supported', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-more-pip')), findsOneWidget); + expect(find.byKey(const Key('player-more-clear')), findsOneWidget); + expect(find.byKey(const Key('player-more-offline')), findsOneWidget); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('should_hide_pip_action_when_platform_unsupported', ( + tester, + ) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-more-pip')), findsNothing); + expect(find.byKey(const Key('player-more-clear')), findsOneWidget); + expect(find.byKey(const Key('player-more-offline')), findsOneWidget); + }, variant: TargetPlatformVariant.only(TargetPlatform.macOS)); + testWidgets('should_enter_clear_screen_from_more_panel', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); diff --git a/test/player/player_more_sheet_test.dart b/test/player/player_more_sheet_test.dart new file mode 100644 index 0000000..3929b53 --- /dev/null +++ b/test/player/player_more_sheet_test.dart @@ -0,0 +1,77 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/player_more_sheet.dart'; + +void main() { + testWidgets('should_hide_pip_row_when_platform_unsupported', (tester) async { + await _openSheet(tester); + + expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); + expect(find.byKey(const Key('player-more-clear')), findsOneWidget); + expect(find.byKey(const Key('player-more-offline')), findsOneWidget); + expect(find.byKey(const Key('player-more-pip')), findsNothing); + }); + + testWidgets('should_trigger_pip_and_close_sheet_when_tapped', (tester) async { + var pipCalls = 0; + await _openSheet(tester, onPip: () => pipCalls++); + + expect(find.byKey(const Key('player-more-pip')), findsOneWidget); + expect( + tester.getTopLeft(find.byKey(const Key('player-more-clear'))).dy < + tester.getTopLeft(find.byKey(const Key('player-more-pip'))).dy, + isTrue, + ); + expect( + tester.getTopLeft(find.byKey(const Key('player-more-pip'))).dy < + tester.getTopLeft(find.byKey(const Key('player-more-offline'))).dy, + isTrue, + ); + + await tester.tap(find.byKey(const Key('player-more-pip'))); + await tester.pumpAndSettle(); + + expect(pipCalls, 1); + expect(find.byKey(const Key('player-more-sheet')), findsNothing); + }); +} + +Future _openSheet(WidgetTester tester, {VoidCallback? onPip}) async { + await tester.pumpWidget( + MaterialApp( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: TextButton( + key: const Key('open-more-sheet'), + onPressed: () => showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => PlayerMoreSheet( + l10n: AppLocalizations.of(context)!, + state: const PlayerMoreState(speed: 1.0, qualities: ['AUTO']), + actions: PlayerMoreActions( + onSpeedChanged: (_) {}, + onClear: () {}, + onDownload: () {}, + onPip: onPip, + ), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.byKey(const Key('open-more-sheet'))); + await tester.pumpAndSettle(); +} From d25519bed7c3a3a5b4c0f48e33ec099c1abea707 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:55:28 +0800 Subject: [PATCH 037/124] =?UTF-8?q?fix(flutter):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E9=A1=B5=E5=BA=95=E9=83=A8=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_id.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_zh.arb | 1 + lib/pages/player/widgets/player_overlay.dart | 18 +-- .../player/widgets/player_overlay_parts.dart | 152 ++++++++++-------- .../player/widgets/seek_progress_bar.dart | 2 + test/player/player_fullscreen_test.dart | 123 +++++++++++++- 11 files changed, 236 insertions(+), 77 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ab59bad..a22ee33 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -41,6 +41,7 @@ "seasonOneOriginalRelease": "Season 1 • Original Release", "startWatching": "Start Watching", "showLess": "Less", + "expandSynopsis": "More", "views": "views", "episodeOfTotal": "Episode {current} / {total}", "@episodeOfTotal": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 19aa673..c4deb62 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -41,6 +41,7 @@ "seasonOneOriginalRelease": "Musim 1 • Rilis Orisinal", "startWatching": "Mulai Menonton", "showLess": "Lebih sedikit", + "expandSynopsis": "Selengkapnya", "views": "tayangan", "episodeOfTotal": "Episode {current} / {total}", "@episodeOfTotal": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4a3e024..425f8b8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -346,6 +346,12 @@ abstract class AppLocalizations { /// **'Less'** String get showLess; + /// No description provided for @expandSynopsis. + /// + /// In en, this message translates to: + /// **'More'** + String get expandSynopsis; + /// No description provided for @views. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d1c882b..f9512b8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -134,6 +134,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get showLess => 'Less'; + @override + String get expandSynopsis => 'More'; + @override String get views => 'views'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 26635f9..fad8f89 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -134,6 +134,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get showLess => 'Lebih sedikit'; + @override + String get expandSynopsis => 'Selengkapnya'; + @override String get views => 'tayangan'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9df426e..1fb5965 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -131,6 +131,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get showLess => '收起'; + @override + String get expandSynopsis => '展开'; + @override String get views => '次播放'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 113d618..65d93b3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -41,6 +41,7 @@ "seasonOneOriginalRelease": "第 1 季 · 原创上线", "startWatching": "开始观看", "showLess": "收起", + "expandSynopsis": "展开", "views": "次播放", "episodeOfTotal": "第 {current} 集 / 共 {total} 集", "@episodeOfTotal": { diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index bc6773d..7f1cf7c 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -206,7 +206,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 1), child: Row( children: [ - Expanded( + Flexible( child: Text( series, key: const Key('player-drama-title'), @@ -221,7 +221,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), ), ), - const SizedBox(width: 6), + const SizedBox(width: 4), const Icon( Icons.chevron_right_rounded, color: AppColors.playerMuted, @@ -245,16 +245,16 @@ class PlayerPlaybackOverlay extends StatelessWidget { child: Material( key: const Key('player-episodes-pill'), color: AppColors.playerOverlayPill, - borderRadius: BorderRadius.circular(999), + borderRadius: BorderRadius.circular(7), clipBehavior: Clip.antiAlias, child: InkWell( key: const Key('player-episodes'), onTap: actions.onEpisodes, - borderRadius: BorderRadius.circular(999), + borderRadius: BorderRadius.circular(7), child: Padding( padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, + horizontal: 14, + vertical: 8, ), child: Row( children: [ @@ -267,8 +267,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onPrimary, - fontSize: 11, - fontWeight: FontWeight.w700, + fontSize: 13.5, + fontWeight: FontWeight.w800, shadows: _playerTextShadows, ), ), @@ -277,7 +277,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { const Icon( Icons.keyboard_arrow_up_rounded, color: AppColors.onPrimary, - size: 16, + size: 18, shadows: _playerTextShadows, ), ], diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 034ec5b..9d6712b 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -90,7 +90,7 @@ class _BottomIconButton extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox.square( - dimension: 30, + dimension: 40, child: IconButton( tooltip: tooltip, padding: EdgeInsets.zero, @@ -99,7 +99,7 @@ class _BottomIconButton extends StatelessWidget { foregroundColor: AppColors.onPrimary, ), onPressed: onPressed, - icon: Icon(icon, size: 17), + icon: Icon(icon, size: 22), ), ); } @@ -235,78 +235,100 @@ class _EpisodeSynopsis extends StatefulWidget { class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { bool _expanded = false; + static const TextStyle _labelStyle = TextStyle( + color: AppColors.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + shadows: _playerTextShadows, + ); + + static const TextStyle _synopsisStyle = TextStyle( + color: AppColors.playerMuted, + fontSize: 12, + height: 1.35, + shadows: _playerTextShadows, + ); + @override Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( - key: const Key('player-synopsis-toggle'), - onTap: () => setState(() => _expanded = !_expanded), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.l10n.episodeLabel(widget.episodeNumber), - key: const Key('player-episode-label'), - style: TextStyle( - color: AppColors.onPrimary, - fontSize: 11, - fontWeight: FontWeight.w700, - shadows: _playerTextShadows, - ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 6), - child: Text( - '|', - style: TextStyle( - color: Colors.white54, - shadows: _playerTextShadows, - ), - ), - ), - Expanded( - child: Text( - widget.synopsis, - key: const Key('player-synopsis'), - maxLines: _expanded ? 3 : 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: AppColors.playerMuted, - fontSize: 11, - height: 1.35, - shadows: _playerTextShadows, - ), - ), - ), - const SizedBox(width: 4), - Icon( - _expanded - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - color: AppColors.playerMuted, - size: 16, - shadows: _playerTextShadows, - ), - ], + Text( + widget.l10n.episodeLabel(widget.episodeNumber), + key: const Key('player-episode-label'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _labelStyle, ), - if (_expanded && widget.authorNote.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - widget.authorNote, - key: const Key('player-author-note'), - style: const TextStyle( - color: AppColors.playerMuted, - fontSize: 10, + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text( + '|', + maxLines: 1, + style: TextStyle( + color: Colors.white54, + fontSize: 12, shadows: _playerTextShadows, ), ), - ], + ), + Flexible( + child: Text( + widget.synopsis, + key: const Key('player-synopsis'), + maxLines: _expanded ? null : 1, + overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis, + style: _synopsisStyle, + ), + ), + if (!_expanded) _toggle(widget.l10n.expandSynopsis), ], ), + if (_expanded) ...[ + if (widget.authorNote.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + widget.authorNote, + key: const Key('player-author-note'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.playerMuted, + fontSize: 10, + shadows: _playerTextShadows, + ), + ), + ], + _toggle(widget.l10n.showLess), + ], + ], + ); + } + + Widget _toggle(String label) { + return InkWell( + key: const Key('player-synopsis-toggle'), + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w700, + shadows: _playerTextShadows, + ), + ), ), ); } diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 45195d5..faa2e85 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -144,6 +144,8 @@ class _SeekProgressBarState extends State { label: widget.semanticsLabel, child: Slider( value: fraction, + // 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。 + padding: EdgeInsets.zero, onChangeStart: enabled ? (value) { _previewFraction.value = value; diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 9b9d1ee..5348892 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -129,7 +129,7 @@ void main() { expect(find.byTooltip('Exit clear screen'), findsOneWidget); expect( tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), - const Size(30, 30), + const Size(40, 40), ); await tester.tapAt(const Offset(80, 300)); @@ -204,6 +204,31 @@ void main() { .color, AppColors.playerOverlayPill, ); + expect( + tester + .widget(find.byKey(const Key('player-episodes-pill'))) + .borderRadius, + BorderRadius.circular(7), + ); + expect( + tester + .widget( + find + .descendant( + of: find.byKey(const Key('player-episodes-pill')), + matching: find.byType(Text), + ) + .first, + ) + .style + ?.fontSize, + 13.5, + ); + expect( + tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), + const Size(40, 40), + ); + expect(tester.widget(find.byType(Slider)).padding, EdgeInsets.zero); expect(bottom.height, lessThanOrEqualTo(200)); expect(bottom.top, greaterThanOrEqualTo(screenHeight * 0.78)); @@ -227,8 +252,10 @@ void main() { final expandedBottom = tester.getRect( find.byKey(const Key('player-controls-bottom')), ); - expect(expandedBottom.height, lessThanOrEqualTo(200)); - expect(expandedBottom.top, greaterThanOrEqualTo(screenHeight * 0.78)); + // K29.9 展开态展示简介全文,高度会增长,但仍需留在画面下半区。 + expect(expandedBottom.height, lessThanOrEqualTo(330)); + expect(expandedBottom.top, greaterThanOrEqualTo(screenHeight * 0.5)); + expect(tester.takeException(), isNull); }); testWidgets('should_keep_series_clear_state_when_swiping_next_episode', ( @@ -258,6 +285,96 @@ void main() { expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); }); + testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + + final toggle = find.byKey(const Key('player-synopsis-toggle')); + final synopsis = find.byKey(const Key('player-synopsis')); + expect( + find.descendant(of: toggle, matching: find.text('More')), + findsOneWidget, + ); + expect(tester.widget(synopsis).maxLines, 1); + expect( + tester.getRect(toggle).left - tester.getRect(synopsis).right, + lessThanOrEqualTo(4), + ); + + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect( + find.descendant(of: toggle, matching: find.text('Less')), + findsOneWidget, + ); + expect(tester.widget(synopsis).maxLines, isNull); + + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect( + find.descendant(of: toggle, matching: find.text('More')), + findsOneWidget, + ); + expect(tester.widget(synopsis).maxLines, 1); + }); + + testWidgets('should_fit_long_title_and_synopsis_at_320_width', ( + tester, + ) async { + final longCopy = Drama( + id: 'long-copy-demo', + title: + 'An Extremely Long Drama Title That Keeps Going Beyond Any ' + 'Sensible Width On A Narrow Screen', + synopsis: + 'A very long synopsis that keeps describing the story far beyond ' + 'the single line space available in the compact bottom panel of ' + 'the fullscreen player.', + posterUrl: '/files/long-copy-demo.png', + genre: 'Drama', + detailLoaded: true, + episodes: const [ + Episode( + id: 'long-copy-demo-1', + number: 1, + title: 'Episode 1', + isFree: true, + videoUrl: 'https://example.test/long-demo-1.mp4', + ), + ], + ); + await _pumpApp( + tester, + size: const Size(320, 568), + catalog: FakeCatalogRepository(dramas: [longCopy]), + ); + await _openSeriesPlayer( + tester, + cardKey: const Key('home-drama-card-long-copy-demo'), + ); + + expect(tester.takeException(), isNull); + final title = tester.getRect(find.byKey(const Key('player-drama-title'))); + final chevron = tester.getRect( + find.descendant( + of: find.byKey(const Key('player-open-detail')), + matching: find.byIcon(Icons.chevron_right_rounded), + ), + ); + expect(chevron.left - title.right, lessThanOrEqualTo(6)); + expect(chevron.right, lessThanOrEqualTo(320 - 14)); + final synopsis = tester.widget( + find.byKey(const Key('player-synopsis')), + ); + expect(synopsis.maxLines, 1); + expect(synopsis.overflow, TextOverflow.ellipsis); + expect( + tester.getRect(find.byKey(const Key('player-episodes-pill'))).right, + lessThanOrEqualTo(320 - 14 - 40 - 8), + ); + expect(tester.takeException(), isNull); + }); + testWidgets('should_open_speed_sheet_and_apply_selected_speed', ( tester, ) async { From bed6bbb75bf552e476256d726de3aa28c6a19b7a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:06:21 +0800 Subject: [PATCH 038/124] =?UTF-8?q?feat(flutter):=20=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E9=A1=B5=E4=BA=92=E5=8A=A8=E6=95=B0=E9=87=8F=E4=B8=8E=E8=AF=84?= =?UTF-8?q?=E8=AE=BA=E9=9D=A2=E6=9D=BF=E6=94=B6=E7=B4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/models/drama.dart | 16 +++++ lib/core/formatting/count_format.dart | 9 +++ lib/pages/player/player_view.dart | 19 +++++- lib/pages/player/player_view_actions.dart | 10 ++- lib/pages/player/widgets/comment_tile.dart | 10 ++- .../player/widgets/drama_comments_sheet.dart | 64 +++++++++++-------- lib/pages/player/widgets/player_overlay.dart | 1 + .../player/widgets/player_overlay_parts.dart | 12 +++- test/comment/drama_comments_sheet_test.dart | 48 ++++++++++++++ test/core/count_format_test.dart | 18 ++++++ test/core/json_id_parsing_test.dart | 23 ++++++- test/player/player_fullscreen_test.dart | 41 +++++++++++- test/support/fake_catalog.dart | 53 +++++++++------ 13 files changed, 265 insertions(+), 59 deletions(-) create mode 100644 lib/core/formatting/count_format.dart create mode 100644 test/core/count_format_test.dart diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index d1b41c3..adf6f75 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -65,6 +65,9 @@ class Drama { this.freeEpisodes = 0, this.isHot = false, this.viewCount = 0, + this.likeCount, + this.favoriteCount, + this.commentCount, this.language = '', this.detailLoaded = false, }); @@ -81,6 +84,11 @@ class Drama { final bool isHot; final int viewCount; + /// 详情下发的互动数量;卡片或旧接口没有这些字段时为 null(客户端不显示数字)。 + final int? likeCount; + final int? favoriteCount; + final int? commentCount; + /// 卡片语种(如 zh-CN / id),用于剧场语言筛选。 final String language; @@ -108,6 +116,9 @@ class Drama { isHot: json['isHot'] == true, // viewCount 后端是 Long,按字符串下发(见 json_numbers.dart) viewCount: jsonToInt(json['viewCount']), + likeCount: _optionalInt(json['likeCount']), + favoriteCount: _optionalInt(json['favoriteCount']), + commentCount: _optionalInt(json['commentCount']), language: json['language'] as String? ?? '', ); } @@ -125,6 +136,9 @@ class Drama { freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0, isHot: json['isHot'] == true, viewCount: jsonToInt(json['viewCount']), + likeCount: _optionalInt(json['likeCount']), + favoriteCount: _optionalInt(json['favoriteCount']), + commentCount: _optionalInt(json['commentCount']), language: json['language'] as String? ?? '', detailLoaded: true, episodes: (json['episodes'] as List? ?? const []) @@ -134,3 +148,5 @@ class Drama { ); } } + +int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value); diff --git a/lib/core/formatting/count_format.dart b/lib/core/formatting/count_format.dart new file mode 100644 index 0000000..c360d7e --- /dev/null +++ b/lib/core/formatting/count_format.dart @@ -0,0 +1,9 @@ +// Author: fruit.com.cn + +/// 互动数量展示:不足一万显示原数,一万及以上显示一位小数的「万」。 +/// +/// 例:`3200` → `3200`,`12400` → `1.2万`。 +String formatInteractionCount(int count) { + if (count < 10000) return '$count'; + return '${(count / 10000).toStringAsFixed(1)}万'; +} diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 431461b..3eeb45d 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -83,6 +83,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { bool _chromeVisible = true; double _playbackSpeed = 1.0; + /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 + final Map _favoriteDelta = {}; + @override void initState() { super.initState(); @@ -127,6 +130,14 @@ class _PlayerPageState extends State with WidgetsBindingObserver { setState(() => _chromeVisible = false); } + /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 + void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { + setState(() { + _favoriteDelta[dramaId] = + (_favoriteDelta[dramaId] ?? 0) + (nowSaved ? 1 : -1); + }); + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -377,9 +388,11 @@ class _PlayerPageState extends State with WidgetsBindingObserver { episode: episode, controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), - likeCount: interactions.likeCount(drama.id), - commentCount: null, - favoriteCount: null, + likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, + commentCount: drama.commentCount, + favoriteCount: drama.favoriteCount == null + ? null + : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), saved: context.watch().isSaved(drama.id), playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index ca874c1..3f9fd81 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -100,8 +100,16 @@ extension _PlayerViewActions on _PlayerPageState { final favorites = context.read(); if (!await ensureSignedIn(context) || !context.mounted) return; unawaited(context.read().lightImpact()); + final wasSaved = favorites.isSaved(drama.id); final ok = await favorites.toggle(drama.id, card: drama); - if (ok || !context.mounted) return; + if (!context.mounted) return; + if (ok) { + final nowSaved = favorites.isSaved(drama.id); + if (nowSaved != wasSaved) { + _applyFavoriteDelta(drama.id, nowSaved: nowSaved); + } + return; + } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.favoriteToggleFailed), diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart index 7214bbb..9409a3c 100644 --- a/lib/pages/player/widgets/comment_tile.dart +++ b/lib/pages/player/widgets/comment_tile.dart @@ -107,7 +107,11 @@ class CommentTile extends StatelessWidget { ), Text( comment.content, - style: const TextStyle(color: AppColors.cream, fontSize: 14), + style: const TextStyle( + color: AppColors.cream, + fontSize: 13, + height: 1.2, + ), ), if (comment.isPending) Padding( @@ -153,14 +157,14 @@ class CommentTile extends StatelessWidget { } Widget _avatar() { - final size = isReply ? 24.0 : 32.0; + final size = isReply ? 22.0 : 28.0; if (comment.avatarUrl.isEmpty) { return CircleAvatar( radius: size / 2, backgroundColor: AppColors.raised, child: Icon( Icons.person_rounded, - size: isReply ? 14 : 18, + size: isReply ? 13 : 16, color: AppColors.cream, ), ); diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index b23ee01..9bb397c 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -22,7 +22,9 @@ import 'comment_tile.dart'; const double _minSheetHeight = 280; const double _maxSheetHeight = 520; const int _maxCommentLength = 300; -const double _composerRadius = 22; +const double _composerRadius = 20; +const double _composerHeight = 40; +const double _sendButtonSize = 32; const double _sheetRadius = 20; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 @@ -70,6 +72,7 @@ class _DramaCommentsSheetState extends State { if (result.isSuccess) { _input.clear(); setState(() => _replyTo = null); + FocusScope.of(context).unfocus(); return; } _showMessage( @@ -174,7 +177,7 @@ class _DramaCommentsSheetState extends State { child: SafeArea( top: false, child: Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), child: ListenableBuilder( listenable: _controller, builder: (context, _) => _content(context), @@ -200,7 +203,7 @@ class _DramaCommentsSheetState extends State { key: count == null ? null : const Key('comment-count-label'), style: const TextStyle( color: AppColors.onSurface, - fontSize: 20, + fontSize: 18, fontWeight: FontWeight.w800, ), ), @@ -223,7 +226,7 @@ class _DramaCommentsSheetState extends State { fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), Expanded(child: _list(l10n)), if (_replyTo != null) _replyBanner(l10n, _replyTo!), _composer(l10n), @@ -323,11 +326,12 @@ class _DramaCommentsSheetState extends State { ); return InputDecoration( hintText: hint, - hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), + hintStyle: const TextStyle(color: AppColors.muted, fontSize: 13), counterText: '', filled: true, fillColor: AppColors.raised, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + isDense: true, border: clear, enabledBorder: clear, focusedBorder: clear, @@ -337,29 +341,37 @@ class _DramaCommentsSheetState extends State { Widget _composer(AppLocalizations l10n) => Row( children: [ Expanded( - child: TextField( - key: const Key('comment-input'), - controller: _input, - focusNode: _inputFocus, - maxLength: _maxCommentLength, - maxLines: 1, - style: const TextStyle(color: AppColors.cream, fontSize: 14), - cursorColor: AppColors.blue, - decoration: _composerDecoration(l10n.writeComment), - onSubmitted: (_) => _post(), + child: SizedBox( + height: _composerHeight, + child: TextField( + key: const Key('comment-input'), + controller: _input, + focusNode: _inputFocus, + maxLength: _maxCommentLength, + maxLines: 1, + style: const TextStyle(color: AppColors.cream, fontSize: 13), + cursorColor: AppColors.blue, + decoration: _composerDecoration(l10n.writeComment), + onSubmitted: (_) => _post(), + ), ), ), const SizedBox(width: 8), - IconButton.filled( - key: const Key('comment-post'), - tooltip: l10n.postComment, - onPressed: _controller.submitting ? null : _post, - icon: _controller.submitting - ? const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.send_rounded), + SizedBox.square( + dimension: _sendButtonSize, + child: IconButton.filled( + key: const Key('comment-post'), + tooltip: l10n.postComment, + padding: EdgeInsets.zero, + iconSize: 18, + onPressed: _controller.submitting ? null : _post, + icon: _controller.submitting + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send_rounded), + ), ), ], ); diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 7f1cf7c..1553013 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; +import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 9d6712b..a849d67 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -138,7 +138,9 @@ class _Rail extends StatelessWidget { _RailButton( key: const Key('player-rail-save'), icon: saved ? Icons.star_rounded : Icons.star_border_rounded, - label: favoriteCount?.toString() ?? l10n.favorite, + label: favoriteCount == null + ? l10n.favorite + : formatInteractionCount(favoriteCount!), active: saved, onTap: onSave, ), @@ -146,14 +148,18 @@ class _Rail extends StatelessWidget { _RailButton( key: const Key('player-rail-comments'), icon: Icons.chat_bubble_outline_rounded, - label: commentCount?.toString() ?? l10n.comments, + label: commentCount == null + ? l10n.comments + : formatInteractionCount(commentCount!), onTap: onComments, ), const SizedBox(height: _railGap), _RailButton( key: const Key('player-rail-like'), icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - label: likeCount?.toString() ?? l10n.like, + label: likeCount == null + ? l10n.like + : formatInteractionCount(likeCount!), active: liked, onTap: onLike, ), diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 933e16e..7fab8df 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -106,9 +106,57 @@ void main() { find.byKey(const Key('comment-input')), ); expect(field.controller?.text, 'blocked text'); + // 发送失败时保留输入与焦点,用户可以直接改完再发。 + expect(field.focusNode?.hasFocus, isTrue); }, ); + testWidgets('should_unfocus_input_after_successful_post', (tester) async { + final repository = FakeCommentRepository( + pages: [const CommentPage()], + publishResult: commentFixture(id: '11', content: 'Nice demo', mine: true), + ); + + await _pumpSheet(tester, repository: repository); + final input = find.byKey(const Key('comment-input')); + await tester.tap(input); + await tester.pump(); + expect(tester.widget(input).focusNode?.hasFocus, isTrue); + await tester.enterText(input, 'Nice demo'); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); + + expect(tester.widget(input).focusNode?.hasFocus, isFalse); + }); + + testWidgets('should_use_compact_comment_sizes', (tester) async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [commentFixture(id: '10', content: 'Great episode')], + ), + ], + ); + + await _pumpSheet(tester, repository: repository); + + expect( + tester.getSize(find.byKey(const Key('comment-post'))), + const Size(32, 32), + ); + expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 40); + final avatar = tester.widget( + find.descendant( + of: find.byKey(const Key('comment-item-10')), + matching: find.byType(CircleAvatar), + ), + ); + expect(avatar.radius, 14); + final body = tester.widget(find.text('Great episode')); + expect(body.style?.fontSize, 13); + expect(body.style?.height, 1.2); + }); + testWidgets('should_show_review_badge_and_server_replaced_content', ( tester, ) async { diff --git a/test/core/count_format_test.dart b/test/core/count_format_test.dart new file mode 100644 index 0000000..e80781b --- /dev/null +++ b/test/core/count_format_test.dart @@ -0,0 +1,18 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/formatting/count_format.dart'; + +void main() { + test('should_keep_raw_number_below_ten_thousand', () { + expect(formatInteractionCount(0), '0'); + expect(formatInteractionCount(96), '96'); + expect(formatInteractionCount(9999), '9999'); + }); + + test('should_use_one_decimal_wan_from_ten_thousand', () { + expect(formatInteractionCount(10000), '1.0万'); + expect(formatInteractionCount(12400), '1.2万'); + expect(formatInteractionCount(123456), '12.3万'); + }); +} diff --git a/test/core/json_id_parsing_test.dart b/test/core/json_id_parsing_test.dart index 2fbaa11..9974d7f 100644 --- a/test/core/json_id_parsing_test.dart +++ b/test/core/json_id_parsing_test.dart @@ -46,10 +46,18 @@ void main() { }); test('should_parse_view_count_when_backend_sends_number', () { - final drama = Drama.fromCardJson({'id': 5, 'title': 'T', 'viewCount': 1200}); + final drama = Drama.fromCardJson({ + 'id': 5, + 'title': 'T', + 'viewCount': 1200, + }); expect(drama.id, '5'); expect(drama.viewCount, 1200); + // 卡片没有互动数量时保持未知,界面才回退到文字标签。 + expect(drama.likeCount, isNull); + expect(drama.favoriteCount, isNull); + expect(drama.commentCount, isNull); }); test('should_parse_detail_payload_when_ids_are_strings', () { @@ -59,14 +67,25 @@ void main() { 'totalEpisodes': 12, 'freeEpisodes': 3, 'viewCount': '99', + 'likeCount': '12400', + 'favoriteCount': '3200', + 'commentCount': '96', 'episodes': [ - {'id': '2111012753262120963', 'number': 1, 'title': 'E1', 'isFree': true}, + { + 'id': '2111012753262120963', + 'number': 1, + 'title': 'E1', + 'isFree': true, + }, ], }); expect(drama.id, '2111012753262120962'); expect(drama.freeEpisodes, 3); expect(drama.viewCount, 99); + expect(drama.likeCount, 12400); + expect(drama.favoriteCount, 3200); + expect(drama.commentCount, 96); expect(drama.episodes.first.id, '2111012753262120963'); expect(drama.episodes.first.number, 1); }); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 5348892..3928ed4 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/interaction.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; @@ -285,6 +286,43 @@ void main() { expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); }); + testWidgets('should_show_rail_counts_from_detail_and_follow_server_results', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', + }); + await _pumpApp( + tester, + interactions: FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 23000), + ), + ); + await _openSeriesPlayer(tester); + + String railLabel(Key key) => tester + .widget( + find.descendant(of: find.byKey(key), matching: find.byType(Text)), + ) + .data!; + expect(railLabel(const Key('player-rail-like')), '1.2万'); + expect(railLabel(const Key('player-rail-save')), '3200'); + expect(railLabel(const Key('player-rail-comments')), '96'); + // 分享没有数量,仍是文字标签。 + expect(railLabel(const Key('player-rail-share')), 'Share'); + + await tester.tap(find.byKey(const Key('player-rail-like'))); + await tester.pumpAndSettle(); + expect(railLabel(const Key('player-rail-like')), '2.3万'); + + await tester.tap(find.byKey(const Key('player-rail-save'))); + await tester.pumpAndSettle(); + expect(railLabel(const Key('player-rail-save')), '3201'); + }); + testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); @@ -784,6 +822,7 @@ Future _pumpApp( Size size = const Size(390, 844), FakeCatalogRepository? catalog, FakePlaybackRepository? playback, + FakeInteractionRepository? interactions, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -799,7 +838,7 @@ Future _pumpApp( favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), playbackRepository: playback ?? FakePlaybackRepository(), commentRepository: FakeCommentRepository(), - interactionRepository: FakeInteractionRepository(), + interactionRepository: interactions ?? FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), haptics: FakeHaptics(), ), diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 4b49b52..799ac78 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -50,27 +50,40 @@ final List demoDramas = [ 'City Lights & Velvet Nights', 'Urban Romance', format: 8, + likeCount: 12400, + favoriteCount: 3200, + commentCount: 96, ), _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, - language: 'id', - 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, + int? likeCount, + int? favoriteCount, + int? commentCount, +}) => Drama( + id: id, + title: title, + synopsis: '$title synopsis', + posterUrl: '/files/$id.png', + genre: genre, + language: 'id', + detailLoaded: true, + likeCount: likeCount, + favoriteCount: favoriteCount, + commentCount: commentCount, + 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 9493ad23bb491f623f2a0ed3760e0a3a4a6f653c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:11:44 +0800 Subject: [PATCH 039/124] =?UTF-8?q?fix(flutter):=20=E9=87=8D=E5=81=9A?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E9=9D=A2=E6=9D=BF=E8=A7=86=E8=A7=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 3 ++ lib/pages/player/widgets/comment_tile.dart | 40 +++++++++++---- .../player/widgets/drama_comments_sheet.dart | 51 ++++++++++++++----- test/comment/drama_comments_sheet_test.dart | 23 ++++++--- 4 files changed, 89 insertions(+), 28 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 131a8d5..46df55a 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -55,6 +55,9 @@ abstract final class AppColors { static const raised = Color(0xFFFFF1E5); static const quick = Color(0xFFFFF0E1); static const input = Color(0xFFFFF4EA); + + /// 评论输入框底色。 + static const commentInput = Color(0xFFF5EFE8); static const goldCard = Color(0xFFFFF4DF); /// 正文颜色。 diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart index 9409a3c..3c567f4 100644 --- a/lib/pages/player/widgets/comment_tile.dart +++ b/lib/pages/player/widgets/comment_tile.dart @@ -51,12 +51,12 @@ class CommentTile extends StatelessWidget { : comment.nickname; return Padding( key: Key('comment-item-${comment.id}'), - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _avatar(), - const SizedBox(width: 12), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -67,8 +67,9 @@ class CommentTile extends StatelessWidget { child: Text( name, style: const TextStyle( - color: AppColors.muted, - fontSize: 12, + color: AppColors.cream, + fontSize: 13, + fontWeight: FontWeight.w700, ), ), ), @@ -77,7 +78,7 @@ class CommentTile extends StatelessWidget { formatLocalizedDateTime(context, comment.createdAt!), style: const TextStyle( color: AppColors.subtle, - fontSize: 10, + fontSize: 11, ), ), const SizedBox(width: 4), @@ -109,8 +110,8 @@ class CommentTile extends StatelessWidget { comment.content, style: const TextStyle( color: AppColors.cream, - fontSize: 13, - height: 1.2, + fontSize: 14, + height: 1.4, ), ), if (comment.isPending) @@ -129,6 +130,12 @@ class CommentTile extends StatelessWidget { children: [ TextButton.icon( key: Key('comment-like-${comment.id}'), + style: TextButton.styleFrom( + foregroundColor: AppColors.muted, + padding: const EdgeInsets.symmetric(horizontal: 4), + minimumSize: const Size(0, 28), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), onPressed: controller.likingComment(comment.id) ? null : () => handlers.onLike(comment), @@ -138,11 +145,24 @@ class CommentTile extends StatelessWidget { : Icons.favorite_border_rounded, size: 16, ), - label: Text('${comment.likeCount}'), + label: Text( + '${comment.likeCount}', + style: const TextStyle(fontSize: 11), + ), ), if (!isReply) TextButton( key: Key('comment-reply-${comment.id}'), + style: TextButton.styleFrom( + foregroundColor: AppColors.blue, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + minimumSize: const Size(0, 28), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), onPressed: () => handlers.onReply(comment), child: Text(l10n.reply), ), @@ -157,14 +177,14 @@ class CommentTile extends StatelessWidget { } Widget _avatar() { - final size = isReply ? 22.0 : 28.0; + final size = isReply ? 24.0 : 32.0; if (comment.avatarUrl.isEmpty) { return CircleAvatar( radius: size / 2, backgroundColor: AppColors.raised, child: Icon( Icons.person_rounded, - size: isReply ? 13 : 16, + size: isReply ? 14 : 18, color: AppColors.cream, ), ); diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 9bb397c..e93892b 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -22,9 +22,9 @@ import 'comment_tile.dart'; const double _minSheetHeight = 280; const double _maxSheetHeight = 520; const int _maxCommentLength = 300; -const double _composerRadius = 20; -const double _composerHeight = 40; -const double _sendButtonSize = 32; +const double _composerRadius = 22; +const double _composerHeight = 44; +const double _sendButtonSize = 36; const double _sheetRadius = 20; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 @@ -195,6 +195,18 @@ class _DramaCommentsSheetState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Center( + child: Container( + key: const Key('player-comments-drag-handle'), + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 8), Row( children: [ Expanded( @@ -203,8 +215,8 @@ class _DramaCommentsSheetState extends State { key: count == null ? null : const Key('comment-count-label'), style: const TextStyle( color: AppColors.onSurface, - fontSize: 18, - fontWeight: FontWeight.w800, + fontSize: 15, + fontWeight: FontWeight.w700, ), ), ), @@ -212,7 +224,8 @@ class _DramaCommentsSheetState extends State { key: const Key('player-comments-close'), tooltip: l10n.close, onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close_rounded), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close_rounded, size: 20), ), ], ), @@ -326,11 +339,11 @@ class _DramaCommentsSheetState extends State { ); return InputDecoration( hintText: hint, - hintStyle: const TextStyle(color: AppColors.muted, fontSize: 13), + hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), counterText: '', filled: true, - fillColor: AppColors.raised, - contentPadding: const EdgeInsets.symmetric(horizontal: 12), + fillColor: AppColors.commentInput, + contentPadding: const EdgeInsets.symmetric(horizontal: 14), isDense: true, border: clear, enabledBorder: clear, @@ -349,7 +362,8 @@ class _DramaCommentsSheetState extends State { focusNode: _inputFocus, maxLength: _maxCommentLength, maxLines: 1, - style: const TextStyle(color: AppColors.cream, fontSize: 13), + textAlignVertical: TextAlignVertical.center, + style: const TextStyle(color: AppColors.cream, fontSize: 14), cursorColor: AppColors.blue, decoration: _composerDecoration(l10n.writeComment), onSubmitted: (_) => _post(), @@ -402,10 +416,20 @@ class _CommentThread extends StatelessWidget { ), if (comment.replyCount > 0 || replies.isNotEmpty) Padding( - padding: const EdgeInsets.only(left: 44, bottom: 8), + padding: const EdgeInsets.only(left: 42, bottom: 4), child: TextButton( key: Key('comment-replies-${comment.id}'), onPressed: () => _toggleReplies(context), + style: TextButton.styleFrom( + foregroundColor: AppColors.blue, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + minimumSize: const Size(0, 28), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), child: Text( expanded ? AppLocalizations.of(context)!.hideReplies @@ -440,7 +464,10 @@ class _CommentThread extends StatelessWidget { ], ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), + const Padding( + padding: EdgeInsets.only(left: 42), + child: Divider(height: 1, color: AppColors.border), + ), ], ); } diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 7fab8df..305c646 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -129,7 +129,7 @@ void main() { expect(tester.widget(input).focusNode?.hasFocus, isFalse); }); - testWidgets('should_use_compact_comment_sizes', (tester) async { + testWidgets('should_use_polished_comment_sizes', (tester) async { final repository = FakeCommentRepository( pages: [ CommentPage( @@ -142,19 +142,30 @@ void main() { expect( tester.getSize(find.byKey(const Key('comment-post'))), - const Size(32, 32), + const Size(36, 36), + ); + expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 44); + expect( + find.byKey(const Key('player-comments-drag-handle')), + findsOneWidget, ); - expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 40); final avatar = tester.widget( find.descendant( of: find.byKey(const Key('comment-item-10')), matching: find.byType(CircleAvatar), ), ); - expect(avatar.radius, 14); + expect(avatar.radius, 16); final body = tester.widget(find.text('Great episode')); - expect(body.style?.fontSize, 13); - expect(body.style?.height, 1.2); + expect(body.style?.fontSize, 14); + expect(body.style?.height, 1.4); + expect( + tester + .widget(find.byKey(const Key('comment-input'))) + .decoration + ?.fillColor, + AppColors.commentInput, + ); }); testWidgets('should_show_review_badge_and_server_replaced_content', ( From 8709b64dec03663af5be00021585328fca1f8c52 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:16:03 +0800 Subject: [PATCH 040/124] =?UTF-8?q?fix(flutter):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E8=BE=93=E5=85=A5=E6=A1=86=E9=AB=98=E5=BA=A6?= =?UTF-8?q?=E4=B8=8E=E5=9C=86=E8=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/drama_comments_sheet.dart | 8 ++++---- test/comment/drama_comments_sheet_test.dart | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index e93892b..6576a35 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -22,9 +22,9 @@ import 'comment_tile.dart'; const double _minSheetHeight = 280; const double _maxSheetHeight = 520; const int _maxCommentLength = 300; -const double _composerRadius = 22; -const double _composerHeight = 44; -const double _sendButtonSize = 36; +const double _composerRadius = 12; +const double _composerHeight = 52; +const double _sendButtonSize = 40; const double _sheetRadius = 20; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 @@ -343,7 +343,7 @@ class _DramaCommentsSheetState extends State { counterText: '', filled: true, fillColor: AppColors.commentInput, - contentPadding: const EdgeInsets.symmetric(horizontal: 14), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), isDense: true, border: clear, enabledBorder: clear, diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 305c646..f23ad4a 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -142,9 +142,9 @@ void main() { expect( tester.getSize(find.byKey(const Key('comment-post'))), - const Size(36, 36), + const Size(40, 40), ); - expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 44); + expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 52); expect( find.byKey(const Key('player-comments-drag-handle')), findsOneWidget, From 90769de3b7964b162a5e657c89892ad8498ae3fb Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:19:20 +0800 Subject: [PATCH 041/124] =?UTF-8?q?fix(flutter):=20=E9=80=89=E9=9B=86?= =?UTF-8?q?=E6=9D=A1=E7=AE=AD=E5=A4=B4=E9=9D=A0=E5=8F=B3=E6=8E=92=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/player_overlay.dart | 4 ++-- test/player/player_fullscreen_test.dart | 24 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 1553013..6b404c8 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -259,7 +259,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), child: Row( children: [ - Flexible( + Expanded( child: Text( '${data.l10n.episodes} · ' '${data.l10n.allEpisodesBadge(data.totalEpisodes)} · ' @@ -274,7 +274,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), ), ), - const SizedBox(width: 3), + const SizedBox(width: 8), const Icon( Icons.keyboard_arrow_up_rounded, color: AppColors.onPrimary, diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 3928ed4..9a938f0 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -286,6 +286,30 @@ void main() { expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); }); + testWidgets('should_push_episodes_arrow_to_pill_right', (tester) async { + // 宽屏下文案不会撑满胶囊,才能验证箭头是否被推到最右侧。 + await _pumpApp(tester, size: const Size(900, 844)); + await _openSeriesPlayer(tester); + + final pill = tester.getRect(find.byKey(const Key('player-episodes-pill'))); + final label = tester.getRect( + find + .descendant( + of: find.byKey(const Key('player-episodes-pill')), + matching: find.byType(Text), + ) + .first, + ); + final arrow = tester.getRect( + find.descendant( + of: find.byKey(const Key('player-episodes-pill')), + matching: find.byIcon(Icons.keyboard_arrow_up_rounded), + ), + ); + expect(pill.right - arrow.right, closeTo(14, 0.5)); + expect(arrow.left - label.right, closeTo(8, 0.5)); + }); + testWidgets('should_show_rail_counts_from_detail_and_follow_server_results', ( tester, ) async { From 02763c3dfbe70be02492e70974894682055c4d42 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:24:37 +0800 Subject: [PATCH 042/124] =?UTF-8?q?fix(flutter):=20=E7=AE=80=E4=BB=8B?= =?UTF-8?q?=E7=81=B0=E5=AD=97=E7=BB=9F=E4=B8=80=E5=B9=B6=E9=80=8F=E6=98=8E?= =?UTF-8?q?=E6=9A=82=E5=81=9C=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/widgets/player_center_toggle.dart | 6 +---- .../player/widgets/player_overlay_parts.dart | 25 +++++++------------ test/player/player_center_toggle_test.dart | 8 +++--- test/player/player_fullscreen_test.dart | 24 ++++++++++++++++++ 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/lib/pages/player/widgets/player_center_toggle.dart b/lib/pages/player/widgets/player_center_toggle.dart index c939301..c0bd4d5 100644 --- a/lib/pages/player/widgets/player_center_toggle.dart +++ b/lib/pages/player/widgets/player_center_toggle.dart @@ -22,13 +22,9 @@ class PlayerCenterToggle extends StatelessWidget { label: playing ? 'Pause' : 'Play', child: GestureDetector( onTap: onTap, - child: Container( + child: SizedBox( width: 52, height: 52, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), child: Icon( playing ? Icons.pause_rounded : Icons.play_arrow_rounded, color: AppColors.onPrimary, diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index a849d67..c78b216 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -241,16 +241,11 @@ class _EpisodeSynopsis extends StatefulWidget { class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { bool _expanded = false; - static const TextStyle _labelStyle = TextStyle( - color: AppColors.onPrimary, - fontSize: 12, - fontWeight: FontWeight.w700, - shadows: _playerTextShadows, - ); - - static const TextStyle _synopsisStyle = TextStyle( + /// 「第 n 集」、分隔符与简介正文共用同一套灰字规格。 + static const TextStyle _lineStyle = TextStyle( color: AppColors.playerMuted, fontSize: 12, + fontWeight: FontWeight.w500, height: 1.35, shadows: _playerTextShadows, ); @@ -270,17 +265,15 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { key: const Key('player-episode-label'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: _labelStyle, + style: _lineStyle, ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( '|', maxLines: 1, - style: TextStyle( - color: Colors.white54, - fontSize: 12, - shadows: _playerTextShadows, + style: _lineStyle.copyWith( + color: AppColors.playerMuted.withValues(alpha: 0.6), ), ), ), @@ -290,7 +283,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { key: const Key('player-synopsis'), maxLines: _expanded ? null : 1, overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis, - style: _synopsisStyle, + style: _lineStyle, ), ), if (!_expanded) _toggle(widget.l10n.expandSynopsis), diff --git a/test/player/player_center_toggle_test.dart b/test/player/player_center_toggle_test.dart index dec5deb..3c5e670 100644 --- a/test/player/player_center_toggle_test.dart +++ b/test/player/player_center_toggle_test.dart @@ -33,16 +33,16 @@ void main() { Key('player-play-toggle'), ]) { expect(tester.getSize(find.byKey(key)), const Size(52, 52)); - final container = tester.widget( + // 暂停按钮改为透明背景,容器已不再绘制任何底色或装饰。 + expect( find.descendant(of: find.byKey(key), matching: find.byType(Container)), + findsNothing, ); - final decoration = container.decoration! as BoxDecoration; - expect(decoration.color, AppColors.blue); - expect(decoration.shape, BoxShape.circle); final icon = tester.widget( find.descendant(of: find.byKey(key), matching: find.byType(Icon)), ); expect(icon.size, 26); + expect(icon.color, AppColors.onPrimary); } }); } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 9a938f0..7ce34cd 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -380,6 +380,30 @@ void main() { expect(tester.widget(synopsis).maxLines, 1); }); + testWidgets('should_share_one_muted_style_across_label_and_synopsis', ( + tester, + ) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + + TextStyle styleOf(Finder finder) => + tester.widget(finder).style ?? const TextStyle(); + final label = styleOf(find.byKey(const Key('player-episode-label'))); + final synopsis = styleOf(find.byKey(const Key('player-synopsis'))); + final separator = styleOf( + find.descendant( + of: find.byKey(const Key('player-episode-synopsis')), + matching: find.text('|'), + ), + ); + for (final style in [label, synopsis, separator]) { + expect(style.fontSize, 12); + expect(style.fontWeight, FontWeight.w500); + expect(style.color?.withValues(alpha: 1), AppColors.playerMuted); + } + expect(separator.color, isNot(Colors.white)); + }); + testWidgets('should_fit_long_title_and_synopsis_at_320_width', ( tester, ) async { From 8013cc18ade48742f5996cb7328d4c3bef2d5123 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:27:13 +0800 Subject: [PATCH 043/124] =?UTF-8?q?fix(flutter):=20=E6=94=BE=E5=A4=A7?= =?UTF-8?q?=E9=80=8F=E6=98=8E=E6=92=AD=E6=94=BE=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/player_center_toggle.dart | 13 ++++++++++--- test/player/player_center_toggle_test.dart | 10 +++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/lib/pages/player/widgets/player_center_toggle.dart b/lib/pages/player/widgets/player_center_toggle.dart index c0bd4d5..a764c26 100644 --- a/lib/pages/player/widgets/player_center_toggle.dart +++ b/lib/pages/player/widgets/player_center_toggle.dart @@ -4,6 +4,12 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +const double _toggleSize = 72; +const double _iconSize = 40; +const List _iconShadows = [ + Shadow(color: Colors.black54, blurRadius: 12), +]; + /// 首页沉浸流与全屏播放器共用的播放/暂停按钮。 class PlayerCenterToggle extends StatelessWidget { const PlayerCenterToggle({ @@ -23,12 +29,13 @@ class PlayerCenterToggle extends StatelessWidget { child: GestureDetector( onTap: onTap, child: SizedBox( - width: 52, - height: 52, + width: _toggleSize, + height: _toggleSize, child: Icon( playing ? Icons.pause_rounded : Icons.play_arrow_rounded, color: AppColors.onPrimary, - size: 26, + size: _iconSize, + shadows: _iconShadows, ), ), ), diff --git a/test/player/player_center_toggle_test.dart b/test/player/player_center_toggle_test.dart index 3c5e670..26ed622 100644 --- a/test/player/player_center_toggle_test.dart +++ b/test/player/player_center_toggle_test.dart @@ -6,7 +6,9 @@ import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; void main() { - testWidgets('should_use_one_shared_52px_center_toggle_style', (tester) async { + testWidgets('should_use_one_shared_72px_transparent_toggle_style', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -32,7 +34,7 @@ void main() { Key('test-center-toggle-a'), Key('player-play-toggle'), ]) { - expect(tester.getSize(find.byKey(key)), const Size(52, 52)); + expect(tester.getSize(find.byKey(key)), const Size(72, 72)); // 暂停按钮改为透明背景,容器已不再绘制任何底色或装饰。 expect( find.descendant(of: find.byKey(key), matching: find.byType(Container)), @@ -41,8 +43,10 @@ void main() { final icon = tester.widget( find.descendant(of: find.byKey(key), matching: find.byType(Icon)), ); - expect(icon.size, 26); + expect(icon.size, 40); expect(icon.color, AppColors.onPrimary); + expect(icon.shadows, isNotNull); + expect(icon.shadows, isNotEmpty); } }); } From a72adcfc3fa7c1a9946dd179ce072e3ce8475696 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:30:06 +0800 Subject: [PATCH 044/124] =?UTF-8?q?fix(flutter):=20=E6=94=BE=E5=A4=A7?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=9B=BE=E6=A0=87=E5=B9=B6=E5=8A=A0=E5=8D=8A?= =?UTF-8?q?=E9=80=8F=E6=98=8E=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/player_center_toggle.dart | 11 ++++++++--- test/player/player_center_toggle_test.dart | 14 ++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/pages/player/widgets/player_center_toggle.dart b/lib/pages/player/widgets/player_center_toggle.dart index a764c26..4ea318f 100644 --- a/lib/pages/player/widgets/player_center_toggle.dart +++ b/lib/pages/player/widgets/player_center_toggle.dart @@ -4,8 +4,9 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -const double _toggleSize = 72; -const double _iconSize = 40; +const double _toggleSize = 84; +const double _iconSize = 48; +const Color _toggleBackground = Colors.black26; const List _iconShadows = [ Shadow(color: Colors.black54, blurRadius: 12), ]; @@ -28,9 +29,13 @@ class PlayerCenterToggle extends StatelessWidget { label: playing ? 'Pause' : 'Play', child: GestureDetector( onTap: onTap, - child: SizedBox( + child: Container( width: _toggleSize, height: _toggleSize, + decoration: const BoxDecoration( + color: _toggleBackground, + shape: BoxShape.circle, + ), child: Icon( playing ? Icons.pause_rounded : Icons.play_arrow_rounded, color: AppColors.onPrimary, diff --git a/test/player/player_center_toggle_test.dart b/test/player/player_center_toggle_test.dart index 26ed622..fdcc6d2 100644 --- a/test/player/player_center_toggle_test.dart +++ b/test/player/player_center_toggle_test.dart @@ -6,7 +6,7 @@ import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; void main() { - testWidgets('should_use_one_shared_72px_transparent_toggle_style', ( + testWidgets('should_use_one_shared_84px_translucent_toggle_style', ( tester, ) async { await tester.pumpWidget( @@ -34,16 +34,18 @@ void main() { Key('test-center-toggle-a'), Key('player-play-toggle'), ]) { - expect(tester.getSize(find.byKey(key)), const Size(72, 72)); - // 暂停按钮改为透明背景,容器已不再绘制任何底色或装饰。 - expect( + expect(tester.getSize(find.byKey(key)), const Size(84, 84)); + // 圆形底改为半透明黑色,不再是完全透明或蓝色实底。 + final container = tester.widget( find.descendant(of: find.byKey(key), matching: find.byType(Container)), - findsNothing, ); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.color, Colors.black26); + expect(decoration.shape, BoxShape.circle); final icon = tester.widget( find.descendant(of: find.byKey(key), matching: find.byType(Icon)), ); - expect(icon.size, 40); + expect(icon.size, 48); expect(icon.color, AppColors.onPrimary); expect(icon.shadows, isNotNull); expect(icon.shadows, isNotEmpty); From 96074bc5e81b2d60adc921e79085e93abcf25078 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:36:35 +0800 Subject: [PATCH 045/124] =?UTF-8?q?feat(flutter):=20=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E9=A1=B5=E9=95=BF=E6=8C=89=E6=89=8B=E5=8A=BF=E4=B8=8E=E5=8A=A0?= =?UTF-8?q?=E9=80=9F=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 6 ++ lib/l10n/app_id.arb | 1 + lib/l10n/app_localizations.dart | 6 ++ lib/l10n/app_localizations_en.dart | 5 + lib/l10n/app_localizations_id.dart | 5 + lib/l10n/app_localizations_zh.dart | 5 + lib/l10n/app_zh.arb | 1 + lib/pages/player/player_view.dart | 37 +++++++ lib/pages/player/widgets/player_overlay.dart | 57 ++++++++++- .../player/widgets/player_overlay_models.dart | 16 ++++ test/player/player_fullscreen_test.dart | 96 +++++++++++++++++++ 11 files changed, 231 insertions(+), 4 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a22ee33..3d18911 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -53,6 +53,12 @@ "clearScreen": "Clear screen", "exitClearScreen": "Exit clear screen", "playbackSpeed": "Playback speed", + "speedBoosting": "{speed}x speed", + "@speedBoosting": { + "placeholders": { + "speed": {"type": "String"} + } + }, "minutesAgo": "{count} min ago", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index c4deb62..01110ff 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -53,6 +53,7 @@ "clearScreen": "Bersihkan layar", "exitClearScreen": "Keluar dari layar bersih", "playbackSpeed": "Kecepatan putar", + "speedBoosting": "{speed}x kecepatan", "minutesAgo": "{count} mnt lalu", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 425f8b8..909b346 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -382,6 +382,12 @@ abstract class AppLocalizations { /// **'Playback speed'** String get playbackSpeed; + /// No description provided for @speedBoosting. + /// + /// In en, this message translates to: + /// **'{speed}x speed'** + String speedBoosting(String speed); + /// No description provided for @minutesAgo. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f9512b8..e2f12e0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -154,6 +154,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get playbackSpeed => 'Playback speed'; + @override + String speedBoosting(String speed) { + return '${speed}x speed'; + } + @override String minutesAgo(int count) { return '$count min ago'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index fad8f89..da33e71 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -154,6 +154,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get playbackSpeed => 'Kecepatan putar'; + @override + String speedBoosting(String speed) { + return '${speed}x kecepatan'; + } + @override String minutesAgo(int count) { return '$count mnt lalu'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1fb5965..a8703df 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -151,6 +151,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get playbackSpeed => '倍速'; + @override + String speedBoosting(String speed) { + return '$speed 倍速快进中'; + } + @override String minutesAgo(int count) { return '$count 分钟前'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 65d93b3..a1ade16 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -53,6 +53,7 @@ "clearScreen": "清屏", "exitClearScreen": "退出清屏", "playbackSpeed": "倍速", + "speedBoosting": "{speed} 倍速快进中", "minutesAgo": "{count} 分钟前", "@minutesAgo": { "placeholders": { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 3eeb45d..d7f0652 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -36,6 +36,8 @@ import '../../core/data/repositories/playback_repository.dart'; part 'player_view_actions.dart'; +const double _boostPlaybackSpeed = 2.0; + class PlayerPage extends StatefulWidget { const PlayerPage({ super.key, @@ -82,6 +84,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; bool _chromeVisible = true; double _playbackSpeed = 1.0; + bool _boosting = false; + double? _speedBeforeBoost; + bool? _chromeBeforeBoost; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; @@ -130,6 +135,34 @@ class _PlayerPageState extends State with WidgetsBindingObserver { setState(() => _chromeVisible = false); } + /// 长按两侧:切 2 倍速并清屏,记下进入前的状态用于松手恢复。 + void _startBoost() { + if (_boosting) return; + setState(() { + _boosting = true; + _speedBeforeBoost = _playbackSpeed; + _chromeBeforeBoost = _chromeVisible; + _playbackSpeed = _boostPlaybackSpeed; + _chromeVisible = false; + }); + unawaited(logic.setPlaybackSpeed(_boostPlaybackSpeed)); + } + + /// 松手或手势取消:恢复长按前的倍速与清屏状态。 + void _endBoost() { + if (!_boosting) return; + final speed = _speedBeforeBoost ?? _playbackSpeed; + final chrome = _chromeBeforeBoost ?? true; + setState(() { + _boosting = false; + _speedBeforeBoost = null; + _chromeBeforeBoost = null; + _playbackSpeed = speed; + _chromeVisible = chrome; + }); + unawaited(logic.setPlaybackSpeed(speed)); + } + /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { setState(() { @@ -397,6 +430,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, totalEpisodes: logic.visibleIndices.length, + boosting: _boosting, ), actions: PlayerOverlayActions( onBack: widget.embedded @@ -419,6 +453,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, + onLongPressCenter: () => _openMore(context, episode), + onBoostStart: _startBoost, + onBoostEnd: _endBoost, ), controlsVisible: _chromeVisible, ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 6b404c8..dd64973 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -16,6 +16,8 @@ const double _topButtonSize = 36; const double _topBarHeight = 58; const double _railButtonSize = 34; const double _railGap = 18; +const double _boostHintIconSize = 16; +const double _boostHintFontSize = 13; const List _playerTextShadows = [ Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), ]; @@ -45,10 +47,16 @@ class PlayerPlaybackOverlay extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: GestureDetector( - key: const Key('player-video-tap'), - behavior: HitTestBehavior.opaque, - onTap: actions.onTogglePlay, + child: LayoutBuilder( + builder: (context, constraints) => GestureDetector( + key: const Key('player-video-tap'), + behavior: HitTestBehavior.opaque, + onTap: actions.onTogglePlay, + onLongPressStart: (details) => + _handleLongPressStart(details, constraints.maxWidth), + onLongPressEnd: (_) => actions.onBoostEnd(), + onLongPressCancel: actions.onBoostEnd, + ), ), ), _top(context), @@ -120,6 +128,17 @@ class PlayerPlaybackOverlay extends StatelessWidget { ); } + /// 长按分区:中间 1/3 弹更多,左右各 1/3 开始加速。 + void _handleLongPressStart(LongPressStartDetails details, double width) { + final third = width / 3; + final dx = details.localPosition.dx; + if (dx < third || dx > width - third) { + actions.onBoostStart(); + return; + } + actions.onLongPressCenter(); + } + Widget _rail(BuildContext context) { final compact = MediaQuery.sizeOf(context).height < 700; return Positioned( @@ -149,6 +168,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (controlsVisible) _bottomInfo(context), + if (data.boosting) _boostHint(), _bottomProgress(), _bottomActions(), ], @@ -156,6 +176,35 @@ class PlayerPlaybackOverlay extends StatelessWidget { ); } + /// 加速提示:橙色小三角 + 当前倍速文案,悬在进度条上方。 + Widget _boostHint() { + return Padding( + key: const Key('player-speed-boost-hint'), + padding: const EdgeInsets.only(bottom: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.play_arrow_rounded, + color: AppColors.blue, + size: _boostHintIconSize, + shadows: _playerTextShadows, + ), + const SizedBox(width: 4), + Text( + data.l10n.speedBoosting(data.playbackSpeed.toStringAsFixed(1)), + style: const TextStyle( + color: AppColors.blue, + fontSize: _boostHintFontSize, + fontWeight: FontWeight.w700, + shadows: _playerTextShadows, + ), + ), + ], + ), + ); + } + Widget _bottomInfo(BuildContext context) { final series = localizedDramaTitle(data.l10n, data.drama); final synopsis = localizedDramaSynopsis(data.l10n, data.drama); diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 4849fa3..0bdc23e 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -22,6 +22,7 @@ class PlayerOverlayData { required this.playing, required this.playbackSpeed, required this.totalEpisodes, + required this.boosting, this.authorNote = '', }); @@ -61,6 +62,9 @@ class PlayerOverlayData { /// 可见集数量。 final int totalEpisodes; + /// 是否处于长按加速态。 + final bool boosting; + /// 作者声明;没有后端字段时为空。 final String authorNote; } @@ -83,6 +87,9 @@ class PlayerOverlayActions { required this.onMore, required this.onScrubStart, required this.onSeek, + required this.onLongPressCenter, + required this.onBoostStart, + required this.onBoostEnd, }); /// 返回上一页。 @@ -126,4 +133,13 @@ class PlayerOverlayActions { /// 提交进度跳转。 final Future Function(Duration)? onSeek; + + /// 长按画面中间:弹出更多面板。 + final VoidCallback onLongPressCenter; + + /// 长按画面两侧:开始加速。 + final VoidCallback onBoostStart; + + /// 长按结束或取消:结束加速。 + final VoidCallback onBoostEnd; } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 7ce34cd..f33334f 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -152,6 +152,102 @@ void main() { expect(find.byTooltip('Clear screen'), findsOneWidget); }); + testWidgets('should_open_more_sheet_on_center_long_press', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + + final gesture = await tester.startGesture(const Offset(195, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more-close'))); + await tester.pumpAndSettle(); + // 长按弹更多不改播放状态,仍处于播放中。 + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_boost_speed_on_side_long_press_and_restore', ( + tester, + ) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); + + final gesture = await tester.startGesture(const Offset(60, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + + expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); + expect(find.byKey(const Key('player-progress')), findsOneWidget); + expect(find.byKey(const Key('player-interaction-rail')), findsNothing); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect( + find.descendant( + of: find.byKey(const Key('player-speed-entry')), + matching: find.text('2.0x'), + ), + findsOneWidget, + ); + // 加速与清屏都不改变播放状态。 + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); + expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('player-speed-entry')), + matching: find.text('1.0x'), + ), + findsOneWidget, + ); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + + // 右侧 1/3 同样触发加速。 + final rightSide = await tester.startGesture(const Offset(330, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); + await rightSide.up(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); + }); + + testWidgets('should_keep_previous_clear_state_after_boost', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-interaction-rail')), findsNothing); + + final gesture = await tester.startGesture(const Offset(60, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); + await gesture.up(); + await tester.pumpAndSettle(); + + // 本来已清屏,松手后仍保持清屏,只恢复倍速与提示。 + expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); + expect(find.byKey(const Key('player-interaction-rail')), findsNothing); + expect(find.byTooltip('Exit clear screen'), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('player-speed-entry')), + matching: find.text('1.0x'), + ), + findsOneWidget, + ); + }); + testWidgets('should_fit_player_header_on_320_width', (tester) async { await _pumpApp(tester, size: const Size(320, 568)); await _openSeriesPlayer(tester); From 033ae5987c109d91ffa4ec2b759c1ac222522eec Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:39:52 +0800 Subject: [PATCH 046/124] =?UTF-8?q?fix(flutter):=20=E5=8A=A0=E9=80=9F?= =?UTF-8?q?=E6=80=81=E9=9A=90=E8=97=8F=E5=BA=95=E9=83=A8=E9=80=89=E9=9B=86?= =?UTF-8?q?=E4=B8=8E=E6=B8=85=E5=B1=8F=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/player_overlay.dart | 3 ++- test/player/player_fullscreen_test.dart | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index dd64973..bcf586f 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -170,7 +170,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { if (controlsVisible) _bottomInfo(context), if (data.boosting) _boostHint(), _bottomProgress(), - _bottomActions(), + // 加速态只保留进度条与提示,便于全屏快进时看清画面。 + if (!data.boosting) _bottomActions(), ], ), ); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index f33334f..345d15a 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -184,6 +184,8 @@ void main() { expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); expect(find.byKey(const Key('player-progress')), findsOneWidget); + expect(find.byKey(const Key('player-episodes-pill')), findsNothing); + expect(find.byKey(const Key('player-bottom-clear-screen')), findsNothing); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect( @@ -200,6 +202,8 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); + expect(find.byKey(const Key('player-episodes-pill')), findsOneWidget); + expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect( @@ -232,6 +236,8 @@ void main() { await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); + expect(find.byKey(const Key('player-episodes-pill')), findsNothing); + expect(find.byKey(const Key('player-bottom-clear-screen')), findsNothing); await gesture.up(); await tester.pumpAndSettle(); @@ -239,6 +245,9 @@ void main() { expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); + // 清屏态本来显示的选集胶囊与右下按钮,在加速结束后恢复。 + expect(find.byKey(const Key('player-episodes-pill')), findsOneWidget); + expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect( find.descendant( of: find.byKey(const Key('player-speed-entry')), From a24f1fa7c07f4129db841944789ffebf8c6c1767 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:45:13 +0800 Subject: [PATCH 047/124] =?UTF-8?q?feat(flutter):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E9=A2=91=E9=81=93=E5=8F=A0=E5=8A=A0=E8=BD=AE=E6=92=AD=E5=B9=B6?= =?UTF-8?q?=E6=94=B9=E4=B8=8B=E5=88=92=E7=BA=BF=E9=80=89=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/feed_banner_carousel.dart | 20 ++- lib/pages/home/home_browse_view.dart | 146 +++++++++++++----- test/home/k28_a_navigation_test.dart | 75 +++++++-- 3 files changed, 176 insertions(+), 65 deletions(-) diff --git a/lib/pages/discover/widgets/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart index 28800d8..b48cc83 100644 --- a/lib/pages/discover/widgets/feed_banner_carousel.dart +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -27,6 +27,7 @@ class FeedBannerCarousel extends StatefulWidget { required this.onOpen, this.active = true, this.dramaFor, + this.flush = false, }); final List banners; @@ -34,6 +35,9 @@ class FeedBannerCarousel extends StatefulWidget { final bool active; final Drama? Function(FeedBanner banner)? dramaFor; + /// 首页 hero 铺满屏幕并去掉圆角;发现页保持默认 false。 + final bool flush; + @override State createState() => _FeedBannerCarouselState(); } @@ -161,10 +165,14 @@ class _FeedBannerCarouselState extends State { final banner = widget.banners[index]; final dramaId = banner.dramaId; final drama = widget.dramaFor?.call(banner); + final radius = widget.flush + ? 0.0 + : (drama == null + ? AppDimens.authCardRadius + : AppDimens.heroBannerRadius); + final borderRadius = BorderRadius.circular(radius); final card = ClipRRect( - borderRadius: BorderRadius.circular( - drama == null ? AppDimens.authCardRadius : AppDimens.heroBannerRadius, - ), + borderRadius: borderRadius, child: Stack( fit: StackFit.expand, children: [ @@ -225,11 +233,7 @@ class _FeedBannerCarouselState extends State { ? card : Material( color: Colors.transparent, - borderRadius: BorderRadius.circular( - drama == null - ? AppDimens.authCardRadius - : AppDimens.heroBannerRadius, - ), + borderRadius: borderRadius, clipBehavior: Clip.antiAlias, child: InkWell( key: Key('feed-banner-$index'), diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index b8a04ea..ebf63d0 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -20,6 +20,17 @@ import 'home_browse_logic.dart'; import 'widgets/home_browse_header.dart'; import 'widgets/home_drama_grid.dart'; +const double _channelRowHeight = 44; +const double _channelGap = 20; +const double _channelUnderlineWidth = 18; +const double _channelUnderlineHeight = 2; +const double _channelUnderlineGap = 4; +const double _channelOverlayTop = 8; +const double _channelFontSize = 15; +const List _channelTextShadows = [ + Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), +]; + /// 浏览型首页:频道、Hero、继续观看与剧目目录。 class HomeBrowseView extends StatefulWidget { const HomeBrowseView({ @@ -116,8 +127,10 @@ class _HomeBrowseViewState extends State { ? _fallbackBanners(source) : catalog.banners; return [ - _channels(l10n), - if (banners.isNotEmpty) _hero(banners, source), + if (banners.isNotEmpty) + _heroWithChannels(l10n, banners, source) + else + _channels(l10n), if (continueItem != null) ...[ _continueHeader(l10n), ContinueWatchingCard( @@ -182,7 +195,30 @@ class _HomeBrowseViewState extends State { ), ); - Widget _channels(AppLocalizations l10n) { + /// 频道行叠加在 hero 轮播之上;无 banner 时作为普通行显示。 + Widget _heroWithChannels( + AppLocalizations l10n, + List banners, + List dramas, + ) { + return SizedBox( + key: const Key('home-hero-section'), + height: AppDimens.heroBannerHeight, + child: Stack( + children: [ + Positioned.fill(child: _hero(banners, dramas)), + Positioned( + top: _channelOverlayTop, + left: 0, + right: 0, + child: _channels(l10n, overImage: true), + ), + ], + ), + ); + } + + Widget _channels(AppLocalizations l10n, {bool overImage = false}) { final entries = [ (HomeChannel.hot, l10n.hot), (HomeChannel.free, l10n.free), @@ -190,60 +226,90 @@ class _HomeBrowseViewState extends State { (HomeChannel.ranking, l10n.rankingTab), ]; return SizedBox( - height: AppDimens.buttonMedium, + key: const Key('home-channels'), + height: _channelRowHeight, child: ListView.separated( - key: const Key('home-channels'), scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: AppDimens.pageHorizontal, ), itemCount: entries.length, - separatorBuilder: (_, _) => const SizedBox(width: AppDimens.spaceSm), + separatorBuilder: (_, _) => const SizedBox(width: _channelGap), itemBuilder: (context, index) { final entry = entries[index]; final selected = logic.channel == entry.$1; - return ChoiceChip( - key: Key('home-channel-${entry.$1.name}'), - label: Text(entry.$2), + return _channelItem( + entry.$1, + entry.$2, selected: selected, - showCheckmark: false, - onSelected: (_) => logic.selectChannel(entry.$1), - backgroundColor: AppColors.card, - selectedColor: AppColors.blue, - side: BorderSide( - color: selected ? AppColors.blue : AppColors.border, - ), - labelStyle: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.onSurface, - fontSize: AppDimens.chipFont, - fontWeight: FontWeight.w700, - ), - padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), - shape: const StadiumBorder(), + overImage: overImage, ); }, ), ); } + Widget _channelItem( + HomeChannel channel, + String label, { + required bool selected, + required bool overImage, + }) { + final color = overImage + ? (selected ? Colors.white : Colors.white70) + : (selected ? AppColors.onSurface : AppColors.muted); + final underlineColor = selected + ? (overImage ? Colors.white : AppColors.blue) + : Colors.transparent; + return Semantics( + button: true, + selected: selected, + child: GestureDetector( + key: Key('home-channel-${channel.name}'), + behavior: HitTestBehavior.opaque, + onTap: () => logic.selectChannel(channel), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + maxLines: 1, + style: TextStyle( + color: color, + fontSize: _channelFontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + shadows: overImage ? _channelTextShadows : null, + ), + ), + const SizedBox(height: _channelUnderlineGap), + Container( + key: Key('home-channel-underline-${channel.name}'), + width: _channelUnderlineWidth, + height: _channelUnderlineHeight, + decoration: BoxDecoration( + color: underlineColor, + borderRadius: BorderRadius.circular( + _channelUnderlineHeight / 2, + ), + ), + ), + ], + ), + ), + ); + } + Widget _hero(List banners, List dramas) { - return Padding( - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - AppDimens.spaceMd, - AppDimens.pageHorizontal, - 0, - ), - child: FeedBannerCarousel( - key: const Key('home-hero-banner'), - banners: banners, - active: widget.active, - dramaFor: (banner) => _dramaForBanner(banner, dramas), - onOpen: (banner) { - final drama = _dramaForBanner(banner, dramas); - if (drama != null) unawaited(widget.onOpenDrama(drama)); - }, - ), + return FeedBannerCarousel( + key: const Key('home-hero-banner'), + banners: banners, + active: widget.active, + flush: true, + dramaFor: (banner) => _dramaForBanner(banner, dramas), + onOpen: (banner) { + final drama = _dramaForBanner(banner, dramas); + if (drama != null) unawaited(widget.onOpenDrama(drama)); + }, ); } diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 9a17708..932692d 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -8,7 +8,6 @@ import 'package:fruitshorts/core/data/models/watch_progress.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/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/player_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'; @@ -49,27 +48,55 @@ void main() { expect(find.text('Coming soon'), findsOneWidget); }); - testWidgets('should_switch_home_channel_when_chip_is_tapped', (tester) async { + testWidgets('should_switch_home_channel_when_underline_tab_is_tapped', ( + tester, + ) async { await _pumpApp(tester); - expect(_chip(tester, 'home-channel-hot').selected, isTrue); - expect(_chip(tester, 'home-channel-free').selected, isFalse); - expect(_chip(tester, 'home-channel-hot').selectedColor, AppColors.blue); - expect(_chip(tester, 'home-channel-free').backgroundColor, AppColors.card); - expect( - _chip(tester, 'home-channel-free').labelStyle?.color, - AppColors.onSurface, - ); + // 悬浮在轮播图上:选中白色下划线,未选中透明占位。 + expect(_underlineColor(tester, 'hot'), Colors.white); + expect(_underlineColor(tester, 'free'), Colors.transparent); + expect(_labelStyle(tester, 'hot').fontWeight, FontWeight.w800); + expect(_labelStyle(tester, 'free').fontWeight, FontWeight.w600); + expect(_labelStyle(tester, 'hot').color, Colors.white); + expect(_labelStyle(tester, 'free').color, Colors.white70); await tester.tap(find.byKey(const Key('home-channel-free'))); await tester.pumpAndSettle(); - expect(_chip(tester, 'home-channel-hot').selected, isFalse); - expect(_chip(tester, 'home-channel-free').selected, isTrue); - expect( - _chip(tester, 'home-channel-free').labelStyle?.color, - AppColors.onPrimary, + expect(_underlineColor(tester, 'hot'), Colors.transparent); + expect(_underlineColor(tester, 'free'), Colors.white); + expect(_labelStyle(tester, 'free').fontWeight, FontWeight.w800); + }); + + testWidgets('should_render_flush_hero_with_channels_overlaid', ( + tester, + ) async { + await _pumpApp(tester); + + final hero = tester.getRect(find.byKey(const Key('home-hero-banner'))); + expect(hero.width, 390); + expect(hero.left, 0); + final card = tester.widget( + find + .descendant( + of: find.byKey(const Key('home-hero-banner')), + matching: find.byType(ClipRRect), + ) + .first, ); + expect(card.borderRadius, BorderRadius.zero); + + // 频道行在 hero Stack 内、位于轮播顶部 8 的位置上。 + expect( + find.ancestor( + of: find.byKey(const Key('home-channels')), + matching: find.byKey(const Key('home-hero-section')), + ), + findsOneWidget, + ); + final channels = tester.getRect(find.byKey(const Key('home-channels'))); + expect(channels.top, closeTo(hero.top + 8, 1)); }); testWidgets('should_open_theater_search_when_home_search_is_tapped', ( @@ -209,8 +236,22 @@ const _tabKeys = [ Key('tab-mine'), ]; -ChoiceChip _chip(WidgetTester tester, String key) => - tester.widget(find.byKey(Key(key))); +Color? _underlineColor(WidgetTester tester, String name) { + final underline = tester.widget( + find.byKey(Key('home-channel-underline-$name')), + ); + return (underline.decoration! as BoxDecoration).color; +} + +TextStyle _labelStyle(WidgetTester tester, String name) { + final label = tester.widget( + find.descendant( + of: find.byKey(Key('home-channel-$name')), + matching: find.byType(Text), + ), + ); + return label.style!; +} Future _pumpApp( WidgetTester tester, { From 0e48c0773e3199920c8058dc06a7d7c3a80ed9d1 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:50:37 +0800 Subject: [PATCH 048/124] =?UTF-8?q?fix(flutter):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E9=A2=91=E9=81=93=E9=80=89=E4=B8=AD=E6=80=81=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/home/home_browse_view.dart | 10 ++++------ test/home/k28_a_navigation_test.dart | 11 +++++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index ebf63d0..6dcc7ed 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -255,12 +255,10 @@ class _HomeBrowseViewState extends State { required bool selected, required bool overImage, }) { - final color = overImage - ? (selected ? Colors.white : Colors.white70) - : (selected ? AppColors.onSurface : AppColors.muted); - final underlineColor = selected - ? (overImage ? Colors.white : AppColors.blue) - : Colors.transparent; + final color = selected + ? AppColors.blue + : (overImage ? Colors.white70 : AppColors.muted); + final underlineColor = selected ? AppColors.blue : Colors.transparent; return Semantics( button: true, selected: selected, diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 932692d..7572dae 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -8,6 +8,7 @@ import 'package:fruitshorts/core/data/models/watch_progress.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/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/player_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'; @@ -53,19 +54,21 @@ void main() { ) async { await _pumpApp(tester); - // 悬浮在轮播图上:选中白色下划线,未选中透明占位。 - expect(_underlineColor(tester, 'hot'), Colors.white); + // 选中态用主题色,未选中保留白色文字与透明占位。 + expect(_underlineColor(tester, 'hot'), AppColors.blue); expect(_underlineColor(tester, 'free'), Colors.transparent); expect(_labelStyle(tester, 'hot').fontWeight, FontWeight.w800); expect(_labelStyle(tester, 'free').fontWeight, FontWeight.w600); - expect(_labelStyle(tester, 'hot').color, Colors.white); + expect(_labelStyle(tester, 'hot').color, AppColors.blue); expect(_labelStyle(tester, 'free').color, Colors.white70); await tester.tap(find.byKey(const Key('home-channel-free'))); await tester.pumpAndSettle(); expect(_underlineColor(tester, 'hot'), Colors.transparent); - expect(_underlineColor(tester, 'free'), Colors.white); + expect(_underlineColor(tester, 'free'), AppColors.blue); + expect(_labelStyle(tester, 'free').color, AppColors.blue); + expect(_labelStyle(tester, 'hot').color, Colors.white70); expect(_labelStyle(tester, 'free').fontWeight, FontWeight.w800); }); From 7cc65b7b093026927a1826d7cabe1a10a9bbfe95 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:56:13 +0800 Subject: [PATCH 049/124] =?UTF-8?q?feat(flutter):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E9=A2=91=E9=81=93=E8=A1=8C=E6=BB=9A=E5=8A=A8=E5=90=B8=E9=A1=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/home/home_browse_view.dart | 123 ++++++++++++++++++--------- test/home/k28_a_navigation_test.dart | 25 +++++- 2 files changed, 104 insertions(+), 44 deletions(-) diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index 6dcc7ed..b6ffc64 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -25,7 +25,6 @@ const double _channelGap = 20; const double _channelUnderlineWidth = 18; const double _channelUnderlineHeight = 2; const double _channelUnderlineGap = 4; -const double _channelOverlayTop = 8; const double _channelFontSize = 15; const List _channelTextShadows = [ Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), @@ -58,11 +57,14 @@ class HomeBrowseView extends StatefulWidget { class _HomeBrowseViewState extends State { late final HomeBrowseLogic logic; + final ScrollController _scroll = ScrollController(); + bool _channelsPinned = false; @override void initState() { super.initState(); logic = HomeBrowseLogic(history: context.read()); + _scroll.addListener(_onScroll); } @override @@ -73,10 +75,33 @@ class _HomeBrowseViewState extends State { @override void dispose() { + _scroll.removeListener(_onScroll); + _scroll.dispose(); logic.dispose(); super.dispose(); } + /// 滚过 hero 后频道行切成吸顶态,未过阈值时仍叠在轮播图上。 + void _onScroll() { + if (!_scroll.hasClients) return; + final pinned = + _scroll.offset >= AppDimens.heroBannerHeight - _channelRowHeight; + if (pinned != _channelsPinned) { + setState(() => _channelsPinned = pinned); + } + } + + /// 频道行只横向滚动,纵向拖动转交给页面列表,避免挡住上滑。 + void _scrollPageBy(DragUpdateDetails details) { + if (!_scroll.hasClients) return; + final position = _scroll.position; + _scroll.jumpTo( + (_scroll.offset - details.delta.dy) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(), + ); + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -103,19 +128,63 @@ class _HomeBrowseViewState extends State { } Widget _body(AppLocalizations l10n, CatalogController catalog) { - return RefreshIndicator( - key: const Key('home-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, - onRefresh: () async { - await Future.wait([catalog.load(), logic.refreshRecent()]); - }, - child: ListView( - key: const Key('home-browse-page'), - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - children: _sections(l10n, catalog), + final source = catalog.dramas; + final banners = catalog.banners.isEmpty + ? _fallbackBanners(source) + : catalog.banners; + final hasBanner = banners.isNotEmpty; + final pinned = !hasBanner || _channelsPinned; + return Stack( + children: [ + Positioned.fill( + child: RefreshIndicator( + key: const Key('home-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: () async { + await Future.wait([catalog.load(), logic.refreshRecent()]); + }, + child: ListView( + key: const Key('home-browse-page'), + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + children: _sections(l10n, catalog), + ), + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: _channelBar( + l10n, + pinned: pinned, + overImage: hasBanner && !pinned, + ), + ), + ], + ); + } + + Widget _channelBar( + AppLocalizations l10n, { + required bool pinned, + required bool overImage, + }) { + final content = GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: _scrollPageBy, + child: _channels(l10n, overImage: overImage), + ); + if (!pinned) return content; + return DecoratedBox( + key: const Key('home-channels-pinned'), + decoration: const BoxDecoration( + color: AppColors.ink, + border: Border(bottom: BorderSide(color: AppColors.border)), ), + child: content, ); } @@ -127,10 +196,7 @@ class _HomeBrowseViewState extends State { ? _fallbackBanners(source) : catalog.banners; return [ - if (banners.isNotEmpty) - _heroWithChannels(l10n, banners, source) - else - _channels(l10n), + if (banners.isNotEmpty) _hero(banners, source), if (continueItem != null) ...[ _continueHeader(l10n), ContinueWatchingCard( @@ -195,29 +261,6 @@ class _HomeBrowseViewState extends State { ), ); - /// 频道行叠加在 hero 轮播之上;无 banner 时作为普通行显示。 - Widget _heroWithChannels( - AppLocalizations l10n, - List banners, - List dramas, - ) { - return SizedBox( - key: const Key('home-hero-section'), - height: AppDimens.heroBannerHeight, - child: Stack( - children: [ - Positioned.fill(child: _hero(banners, dramas)), - Positioned( - top: _channelOverlayTop, - left: 0, - right: 0, - child: _channels(l10n, overImage: true), - ), - ], - ), - ); - } - Widget _channels(AppLocalizations l10n, {bool overImage = false}) { final entries = [ (HomeChannel.hot, l10n.hot), diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 7572dae..57820c3 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -90,16 +90,33 @@ void main() { ); expect(card.borderRadius, BorderRadius.zero); - // 频道行在 hero Stack 内、位于轮播顶部 8 的位置上。 + // 频道行是固定层,不在可滚动列表里,初始仍压在轮播顶部。 expect( find.ancestor( of: find.byKey(const Key('home-channels')), - matching: find.byKey(const Key('home-hero-section')), + matching: find.byKey(const Key('home-browse-page')), ), - findsOneWidget, + findsNothing, ); final channels = tester.getRect(find.byKey(const Key('home-channels'))); - expect(channels.top, closeTo(hero.top + 8, 1)); + expect(channels.top, closeTo(hero.top, 1)); + expect(find.byKey(const Key('home-channels-pinned')), findsNothing); + }); + + testWidgets('should_pin_home_channels_when_scrolling', (tester) async { + await _pumpApp(tester); + final before = tester.getRect(find.byKey(const Key('home-channel-hot'))); + expect(find.byKey(const Key('home-channels-pinned')), findsNothing); + + await tester.drag( + find.byKey(const Key('home-browse-page')), + const Offset(0, -300), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-channels-pinned')), findsOneWidget); + final after = tester.getRect(find.byKey(const Key('home-channel-hot'))); + expect(after.top, closeTo(before.top, 0.5)); }); testWidgets('should_open_theater_search_when_home_search_is_tapped', ( From 124ba75ab846cf878fcace827b293ce9cbdf2ecd Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:03:19 +0800 Subject: [PATCH 050/124] =?UTF-8?q?feat(flutter):=20=E5=8E=8B=E7=BC=A9?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=89=A7=E5=8D=A1=E5=B9=B6=E6=8A=8A=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E6=AC=A1=E6=95=B0=E4=B8=8A=E5=B0=81=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/home/widgets/home_drama_grid.dart | 98 +++++++++++---------- test/home/home_card_style_test.dart | 43 +++++++-- test/home/k28_a_navigation_test.dart | 21 ++++- 3 files changed, 104 insertions(+), 58 deletions(-) diff --git a/lib/pages/home/widgets/home_drama_grid.dart b/lib/pages/home/widgets/home_drama_grid.dart index ce650a8..677d848 100644 --- a/lib/pages/home/widgets/home_drama_grid.dart +++ b/lib/pages/home/widgets/home_drama_grid.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_theme.dart'; @@ -12,9 +13,12 @@ import '../../../l10n/app_localizations.dart'; const int _columns = 2; const double _columnGap = 10; const double _cardGap = 10; -const double _coverAspectRatio = 3 / 4; -const double _addSize = 28; -const double _addIconSize = 18; +const double _coverAspectRatio = 5 / 6; +const double _cardPadding = 8; +const double _coverTitleGap = 6; +const double _lineGap = 2; +const double _viewsIconSize = 10; +const double _viewsFontSize = 9; /// 首页浏览态使用的双列剧目网格。 class HomeDramaGrid extends StatelessWidget { @@ -89,16 +93,16 @@ class HomeDramaCard extends StatelessWidget { key: Key('home-drama-card-${drama.id}'), onTap: onTap, child: Padding( - padding: const EdgeInsets.all(AppDimens.spaceSm), + padding: const EdgeInsets.all(_cardPadding), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _cover(), - const SizedBox(height: AppDimens.spaceSm), + const SizedBox(height: _coverTitleGap), _title(), - const SizedBox(height: AppDimens.spaceXs), + const SizedBox(height: _lineGap), _genre(), - const SizedBox(height: AppDimens.spaceXs), + const SizedBox(height: _lineGap), _metadata(), ], ), @@ -124,6 +128,7 @@ class HomeDramaCard extends StatelessWidget { Positioned(top: 8, left: 8, child: _tagBadge()), if (_lockedCount > 0) Positioned(top: 8, right: 8, child: _lockedBadge()), + Positioned(left: 8, bottom: 8, child: _viewsBadge()), Positioned(right: 8, bottom: 8, child: _episodeBadge()), ], ), @@ -210,6 +215,37 @@ class HomeDramaCard extends StatelessWidget { ); } + /// 播放次数角标:深色半透明胶囊 + 白色小播放图标。 + Widget _viewsBadge() { + return Container( + key: Key('home-drama-views-${drama.id}'), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.playerOverlayPill, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: _viewsIconSize, + ), + const SizedBox(width: 2), + Text( + formatInteractionCount(drama.viewCount), + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: _viewsFontSize, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + Widget _title() { return Text( drama.title, @@ -239,47 +275,13 @@ class HomeDramaCard extends StatelessWidget { } Widget _metadata() { - return Row( - children: [ - Expanded( - child: Wrap( - spacing: AppDimens.spaceSm, - runSpacing: AppDimens.spaceXs, - children: [ - Text( - l10n.episodeCount(drama.episodeCount), - key: Key('home-drama-episode-count-${drama.id}'), - style: const TextStyle( - color: AppColors.subtle, - fontSize: AppDimens.cardViews, - ), - ), - Text( - l10n.viewCountLabel(drama.viewCount), - key: Key('home-drama-views-${drama.id}'), - style: const TextStyle( - color: AppColors.subtle, - fontSize: AppDimens.cardViews, - ), - ), - ], - ), - ), - Container( - key: Key('home-drama-add-${drama.id}'), - width: _addSize, - height: _addSize, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.add_rounded, - color: AppColors.onPrimary, - size: _addIconSize, - ), - ), - ], + return Text( + l10n.episodeCount(drama.episodeCount), + key: Key('home-drama-episode-count-${drama.id}'), + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.cardViews, + ), ); } } diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index 1859799..04f0495 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -10,7 +10,7 @@ import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/home/widgets/home_drama_grid.dart'; void main() { - testWidgets('should_render_warm_orange_cover_badges_and_plus_action', ( + testWidgets('should_render_compact_cover_badges_without_plus_action', ( tester, ) async { await tester.pumpWidget( @@ -20,13 +20,15 @@ void main() { supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Scaffold( - body: Builder( - builder: (context) => SizedBox( - width: 180, - child: HomeDramaCard( - l10n: AppLocalizations.of(context)!, - drama: _drama, - onTap: () {}, + body: SingleChildScrollView( + child: Builder( + builder: (context) => SizedBox( + width: 180, + child: HomeDramaCard( + l10n: AppLocalizations.of(context)!, + drama: _drama, + onTap: () {}, + ), ), ), ), @@ -54,7 +56,29 @@ void main() { expect(find.text('Free'), findsOneWidget); expect(find.text('18 locked'), findsOneWidget); expect(find.text('All 24'), findsOneWidget); - expect(find.byKey(const Key('home-drama-add-style-drama')), findsOneWidget); + expect(find.byKey(const Key('home-drama-add-style-drama')), findsNothing); + + // 播放次数角标落在封面内,并按万位格式化。 + final views = find.byKey(const Key('home-drama-views-style-drama')); + expect( + find.descendant( + of: find.byKey(const Key('home-drama-cover-style-drama')), + matching: views, + ), + findsOneWidget, + ); + expect( + find.descendant(of: views, matching: find.text('1.2万')), + findsOneWidget, + ); + + // 压缩后卡片高度接近「卡宽 × 1.5」,明显矮于旧版 3:4 封面。 + final cardSize = tester.getSize(card); + expect(cardSize.height, lessThan(cardSize.width * 1.6)); + final coverSize = tester.getSize( + find.byKey(const Key('home-drama-cover-style-drama')), + ); + expect(coverSize.height / coverSize.width, closeTo(6 / 5, 0.02)); }); } @@ -66,4 +90,5 @@ const _drama = Drama( genre: 'Romance', totalEpisodes: 24, freeEpisodes: 6, + viewCount: 12400, ); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 57820c3..8429c79 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; @@ -104,7 +105,23 @@ void main() { }); testWidgets('should_pin_home_channels_when_scrolling', (tester) async { - await _pumpApp(tester); + // 多放几部剧,保证内容高度足够滚过吸顶阈值。 + await _pumpApp( + tester, + withBanner: false, + dramas: [ + for (var index = 0; index < 10; index++) + Drama( + id: 'scroll-$index', + title: 'Scroll Drama $index', + synopsis: 'synopsis', + posterUrl: '/files/scroll-$index.png', + genre: 'Drama', + totalEpisodes: 10, + freeEpisodes: 10, + ), + ], + ); final before = tester.getRect(find.byKey(const Key('home-channel-hot'))); expect(find.byKey(const Key('home-channels-pinned')), findsNothing); @@ -278,12 +295,14 @@ Future _pumpApp( WatchHistoryRepository? history, Size size = const Size(390, 844), bool withBanner = true, + List? dramas, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final catalog = FakeCatalogRepository( + dramas: dramas, banners: withBanner ? const [ FeedBanner( From 1fc25ec5d1e00598c126ac4db39866c5594b51c2 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:08:03 +0800 Subject: [PATCH 051/124] =?UTF-8?q?fix(flutter):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E5=89=A7=E5=8D=A1=E7=A7=BB=E9=99=A4=E5=B0=81=E9=9D=A2=E9=94=81?= =?UTF-8?q?=E5=AE=9A=E8=A7=92=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/home/widgets/home_drama_grid.dart | 35 --------------------- test/home/home_card_style_test.dart | 5 ++- 2 files changed, 4 insertions(+), 36 deletions(-) diff --git a/lib/pages/home/widgets/home_drama_grid.dart b/lib/pages/home/widgets/home_drama_grid.dart index 677d848..a5b82ce 100644 --- a/lib/pages/home/widgets/home_drama_grid.dart +++ b/lib/pages/home/widgets/home_drama_grid.dart @@ -126,8 +126,6 @@ class HomeDramaCard extends StatelessWidget { fallbackAspectRatio: _coverAspectRatio, ), Positioned(top: 8, left: 8, child: _tagBadge()), - if (_lockedCount > 0) - Positioned(top: 8, right: 8, child: _lockedBadge()), Positioned(left: 8, bottom: 8, child: _viewsBadge()), Positioned(right: 8, bottom: 8, child: _episodeBadge()), ], @@ -140,13 +138,6 @@ class HomeDramaCard extends StatelessWidget { drama.episodes.any((episode) => episode.isFree); } - int get _lockedCount { - final explicit = drama.episodes.where((episode) => episode.locked).length; - if (explicit > 0) return explicit; - final inferred = drama.episodeCount - drama.freeEpisodes; - return inferred > 0 ? inferred : 0; - } - Widget _tagBadge() { final label = _isFree ? l10n.free : l10n.originalTag; return Container( @@ -170,32 +161,6 @@ class HomeDramaCard extends StatelessWidget { ); } - Widget _lockedBadge() { - return Container( - key: Key('home-drama-locked-${drama.id}'), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), - decoration: BoxDecoration( - color: AppColors.overlayDark, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.lock_rounded, color: AppColors.onPrimary, size: 11), - const SizedBox(width: 2), - Text( - l10n.lockedEpisodeCount(_lockedCount), - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 9, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ); - } - Widget _episodeBadge() { return Container( key: Key('home-drama-episodes-badge-${drama.id}'), diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index 04f0495..c205ebd 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -54,8 +54,11 @@ void main() { ); expect((tag.decoration! as BoxDecoration).color, AppColors.badgePink); expect(find.text('Free'), findsOneWidget); - expect(find.text('18 locked'), findsOneWidget); expect(find.text('All 24'), findsOneWidget); + expect( + find.byKey(const Key('home-drama-locked-style-drama')), + findsNothing, + ); expect(find.byKey(const Key('home-drama-add-style-drama')), findsNothing); // 播放次数角标落在封面内,并按万位格式化。 From 524b7f5b358a162125e076e07a6fe3c8731395dd Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:11:46 +0800 Subject: [PATCH 052/124] =?UTF-8?q?feat(flutter):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E9=A1=B6=E6=A0=8F=E6=94=B9=E4=B8=BA=E9=80=9A=E7=9F=A5=E9=93=83?= =?UTF-8?q?=E9=93=9B=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/widgets/home_browse_header.dart | 49 ++++++++++--------- test/home/home_preview_layout_test.dart | 31 +++++++++++- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/lib/pages/home/widgets/home_browse_header.dart b/lib/pages/home/widgets/home_browse_header.dart index 71f6208..e6554a4 100644 --- a/lib/pages/home/widgets/home_browse_header.dart +++ b/lib/pages/home/widgets/home_browse_header.dart @@ -5,10 +5,11 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; +import '../../notices/notices_page.dart'; const double _searchHeight = 36; -/// 首页固定顶栏:搜索、星期与头像入口。 +/// 首页固定顶栏:搜索、通知与头像入口。 class HomeBrowseHeader extends StatelessWidget { const HomeBrowseHeader({ super.key, @@ -22,7 +23,6 @@ class HomeBrowseHeader extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final weekday = _weekday(l10n); return SizedBox( height: AppDimens.contentHeaderHeight, child: Padding( @@ -33,7 +33,7 @@ class HomeBrowseHeader extends StatelessWidget { children: [ Expanded(child: _search(l10n, onSearch)), const SizedBox(width: AppDimens.spaceSm), - _weekdayText(weekday), + _notifications(context, l10n), const SizedBox(width: AppDimens.spaceSm), _profile(l10n), ], @@ -42,18 +42,6 @@ class HomeBrowseHeader extends StatelessWidget { ); } - String _weekday(AppLocalizations l10n) { - return switch (DateTime.now().weekday) { - DateTime.monday => l10n.weekdayMon, - DateTime.tuesday => l10n.weekdayTue, - DateTime.wednesday => l10n.weekdayWed, - DateTime.thursday => l10n.weekdayThu, - DateTime.friday => l10n.weekdayFri, - DateTime.saturday => l10n.weekdaySat, - _ => l10n.weekdaySun, - }; - } - Widget _search(AppLocalizations l10n, VoidCallback onTap) { return Semantics( button: true, @@ -95,14 +83,29 @@ class HomeBrowseHeader extends StatelessWidget { ); } - Widget _weekdayText(String value) { - return Text( - value, - key: const Key('home-weekday'), - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w600, + Widget _notifications(BuildContext context, AppLocalizations l10n) { + return Semantics( + button: true, + label: l10n.notices, + child: InkWell( + key: const Key('home-notifications'), + customBorder: const CircleBorder(), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const NoticesPage())), + child: Container( + width: AppDimens.tapTarget, + height: AppDimens.tapTarget, + decoration: const BoxDecoration( + color: AppColors.card, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.notifications_none_rounded, + color: AppColors.onSurface, + size: AppDimens.accountIcon, + ), + ), ), ); } diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 279abd3..c706c7a 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -63,7 +63,7 @@ void main() { expect(find.byKey(const Key('home-search')), findsOneWidget); expect(find.byKey(const Key('home-profile')), findsOneWidget); - expect(find.byKey(const Key('home-weekday')), findsOneWidget); + expect(find.byKey(const Key('home-notifications')), findsOneWidget); for (final channel in ['hot', 'free', 'newReleases', 'ranking']) { expect(find.byKey(Key('home-channel-$channel')), findsOneWidget); } @@ -76,6 +76,35 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('should_open_notices_when_bell_is_tapped', (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + 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(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('home-notifications'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('notices-page')), findsOneWidget); + }); + testWidgets('should_use_episode_title_then_drama_title_as_fallback', ( tester, ) async { From 5a51af426494967780d2520dae2d250aa570b9cc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:24:21 +0800 Subject: [PATCH 053/124] =?UTF-8?q?feat(flutter):=20=E5=89=A7=E5=9C=BA?= =?UTF-8?q?=E9=A1=B5=E6=8C=89=E5=8F=82=E8=80=83=E5=9B=BE=E9=87=8D=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 8 + lib/l10n/app_id.arb | 3 + lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_en.dart | 11 + lib/l10n/app_localizations_id.dart | 11 + lib/l10n/app_localizations_zh.dart | 13 +- lib/l10n/app_zh.arb | 5 +- lib/pages/discover/discover_view.dart | 239 ++++++++++++---- .../discover/widgets/discover_header.dart | 60 +--- .../widgets/discover_search_field.dart | 70 ++++- lib/pages/discover/widgets/drama_grid.dart | 264 ++++++++---------- test/discover/home_all_dramas_grid_test.dart | 53 +++- test/widget_test.dart | 2 +- 13 files changed, 499 insertions(+), 258 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3d18911..261acae 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,6 +29,14 @@ "romance": "Romance", "actionGenre": "Action", "filter": "Filter", + "reserve": "Reserve", + "identifyDrama": "Identify", + "heatLabel": "{count} heat", + "@heatLabel": { + "placeholders": { + "count": {"type": "String"} + } + }, "topCharts": "Top Charts", "newReleases": "New Releases", "newDramas": "New", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 01110ff..e54c24b 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -29,6 +29,9 @@ "romance": "Romantis", "actionGenre": "Aksi", "filter": "Filter", + "reserve": "Reservasi", + "identifyDrama": "Identifikasi", + "heatLabel": "{count} panas", "topCharts": "Peringkat Teratas", "newReleases": "Rilisan Baru", "newDramas": "Baru", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 909b346..a4e37c2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -274,6 +274,24 @@ abstract class AppLocalizations { /// **'Filter'** String get filter; + /// No description provided for @reserve. + /// + /// In en, this message translates to: + /// **'Reserve'** + String get reserve; + + /// No description provided for @identifyDrama. + /// + /// In en, this message translates to: + /// **'Identify'** + String get identifyDrama; + + /// No description provided for @heatLabel. + /// + /// In en, this message translates to: + /// **'{count} heat'** + String heatLabel(String count); + /// No description provided for @topCharts. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e2f12e0..0c8adfd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -98,6 +98,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get filter => 'Filter'; + @override + String get reserve => 'Reserve'; + + @override + String get identifyDrama => 'Identify'; + + @override + String heatLabel(String count) { + return '$count heat'; + } + @override String get topCharts => 'Top Charts'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index da33e71..c92b2e5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -98,6 +98,17 @@ class AppLocalizationsId extends AppLocalizations { @override String get filter => 'Filter'; + @override + String get reserve => 'Reservasi'; + + @override + String get identifyDrama => 'Identifikasi'; + + @override + String heatLabel(String count) { + return '$count panas'; + } + @override String get topCharts => 'Peringkat Teratas'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a8703df..1e2ebcf 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -69,7 +69,7 @@ class AppLocalizationsZh extends AppLocalizations { String get authLandingSubtitle => '登录后同步进度、收藏与金币权益'; @override - String get comingSoon => '即将开放'; + String get comingSoon => '即将上线'; @override String get authLegal => '继续即代表同意《用户协议》与《隐私政策》'; @@ -95,6 +95,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String get filter => '筛选'; + @override + String get reserve => '预约'; + + @override + String get identifyDrama => '识剧'; + + @override + String heatLabel(String count) { + return '$count热度'; + } + @override String get topCharts => '排行榜'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a1ade16..dd5e21b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -20,7 +20,7 @@ "recommended": "推荐", "fruitShortsBrand": "水果短剧", "authLandingSubtitle": "登录后同步进度、收藏与金币权益", - "comingSoon": "即将开放", + "comingSoon": "即将上线", "authLegal": "继续即代表同意《用户协议》与《隐私政策》", "authContinueGoogle": "使用 Google 继续", "authPhoneSignIn": "手机号登录", @@ -29,6 +29,9 @@ "romance": "都市爱情", "actionGenre": "动作", "filter": "筛选", + "reserve": "预约", + "identifyDrama": "识剧", + "heatLabel": "{count}热度", "topCharts": "排行榜", "newReleases": "新剧", "newDramas": "新剧", diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 0970307..3193a6d 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -22,6 +22,16 @@ import 'widgets/discover_header.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/drama_grid.dart'; +const Color _filterIconColor = Color(0xFF7B61FF); +const Color _newIconColor = Color(0xFF14B8A6); +const double _toolbarHeight = 36; +const double _toolbarGap = 10; +const double _toolbarRadius = 10; +const double _categoryTabHeight = 44; +const double _categoryTabGap = 20; +const double _categorySelectedFontSize = 16; +const double _categoryFontSize = 15; + /// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 class DiscoverPage extends StatefulWidget { const DiscoverPage({ @@ -96,7 +106,7 @@ class _DiscoverPageState extends State { focusNode: logic.searchFocus, onChanged: (_) => logic.onSearchChanged(), loading: logic.searchLoading, - onProfile: _openProfile, + onIdentify: () => _showComingSoon(l10n), ), Expanded(child: _feed(l10n, categories)), if (!embedded) _bottomNav(l10n), @@ -174,7 +184,8 @@ class _DiscoverPageState extends State { ? categories.feed.dramas : categories.dramasOf(logic.selectedCategoryId!); return [ - _categoryChips(l10n, categories), + _categoryTabs(l10n, categories), + _toolbar(l10n, categories), if (dramas.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 40), @@ -190,73 +201,199 @@ class _DiscoverPageState extends State { ]; } - Widget _categoryChips(AppLocalizations l10n, CategoryController categories) { - return Padding( - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - AppDimens.spaceMd, - AppDimens.pageHorizontal, - 0, + Widget _categoryTabs(AppLocalizations l10n, CategoryController categories) { + return SizedBox( + key: const Key('theater-category-chips'), + height: _categoryTabHeight, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 14), + itemCount: categories.categories.length + 1, + separatorBuilder: (_, _) => const SizedBox(width: _categoryTabGap), + itemBuilder: (context, index) { + if (index == 0) { + return _categoryTab( + key: const Key('theater-category-all'), + label: l10n.all, + selected: logic.selectedCategoryId == null, + onTap: () => logic.selectCategory(null), + ); + } + final category = categories.categories[index - 1]; + return _categoryTab( + key: Key('theater-category-${category.id}'), + label: category.name, + selected: logic.selectedCategoryId == category.id, + onTap: () => logic.selectCategory(category.id), + ); + }, ), - child: Wrap( - key: const Key('theater-category-chips'), - spacing: 10, - runSpacing: 10, - children: [ - _filterChip( - key: const Key('theater-category-all'), - label: l10n.all, - selected: logic.selectedCategoryId == null, - onSelected: () => logic.selectCategory(null), + ); + } + + Widget _categoryTab({ + required Key key, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Center( + child: Text( + label, + maxLines: 1, + style: TextStyle( + color: selected ? AppColors.onSurface : AppColors.muted, + fontSize: selected ? _categorySelectedFontSize : _categoryFontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w500, ), - for (final category in categories.categories) - _filterChip( - key: Key('theater-category-${category.id}'), - label: category.name, - selected: logic.selectedCategoryId == category.id, - onSelected: () => logic.selectCategory(category.id), + ), + ), + ); + } + + Widget _toolbar(AppLocalizations l10n, CategoryController categories) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 12), + child: Row( + children: [ + Expanded( + child: _toolButton( + key: const Key('theater-toolbar-filter'), + icon: Icons.tune_rounded, + color: _filterIconColor, + label: l10n.filter, + onTap: () => _openFilterSheet(l10n, categories), ), + ), + const SizedBox(width: _toolbarGap), + Expanded( + child: _toolButton( + key: const Key('theater-toolbar-ranking'), + icon: Icons.local_fire_department_rounded, + color: AppColors.blue, + label: l10n.rankingTab, + onTap: () => _showComingSoon(l10n), + ), + ), + const SizedBox(width: _toolbarGap), + Expanded( + child: _toolButton( + key: const Key('theater-toolbar-new'), + icon: Icons.play_circle_fill_rounded, + color: _newIconColor, + label: l10n.newDramas, + onTap: () => _showComingSoon(l10n), + ), + ), + const SizedBox(width: _toolbarGap), + Expanded( + child: _toolButton( + key: const Key('theater-toolbar-reserve'), + icon: Icons.calendar_month_rounded, + color: AppColors.blue, + label: l10n.reserve, + onTap: () => _showComingSoon(l10n), + ), + ), ], ), ); } - Widget _filterChip({ + Widget _toolButton({ required Key key, + required IconData icon, + required Color color, required String label, - required bool selected, - required VoidCallback onSelected, + required VoidCallback onTap, }) { - return ChoiceChip( - key: key, - label: Text(label), - selected: selected, - showCheckmark: false, - onSelected: (_) => onSelected(), - backgroundColor: AppColors.card, - selectedColor: AppColors.blue, - side: BorderSide(color: selected ? AppColors.blue : AppColors.border), - labelStyle: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.onSurface, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w700, + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(_toolbarRadius), + child: InkWell( + key: key, + onTap: onTap, + borderRadius: BorderRadius.circular(_toolbarRadius), + child: Container( + height: _toolbarHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_toolbarRadius), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), ), - shape: const StadiumBorder(), ); } + Future _openFilterSheet( + AppLocalizations l10n, + CategoryController categories, + ) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: ListView( + key: const Key('theater-filter-sheet'), + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 12), + children: [ + ListTile( + key: const Key('theater-filter-option-all'), + title: Text(l10n.all), + onTap: () { + Navigator.pop(sheetContext); + logic.selectCategory(null); + }, + ), + for (final category in categories.categories) + ListTile( + key: Key('theater-filter-option-${category.id}'), + title: Text(category.name), + onTap: () { + Navigator.pop(sheetContext); + logic.selectCategory(category.id); + }, + ), + ], + ), + ), + ); + } + + void _showComingSoon(AppLocalizations l10n) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.comingSoon))); + } + Widget _block(Widget child) => SizedBox(height: 280, child: Center(child: child)); - void _openProfile() { - final callback = widget.callbacks.onProfile; - if (callback != null) { - callback(); - return; - } - _act(DiscoverPageAction.my); - } - void _act(DiscoverPageAction action) { logic.act( context, diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart index bbff9d2..c2e0ecf 100644 --- a/lib/pages/discover/widgets/discover_header.dart +++ b/lib/pages/discover/widgets/discover_header.dart @@ -2,12 +2,10 @@ import 'package:flutter/material.dart'; -import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../l10n/app_localizations.dart'; import 'discover_search_field.dart'; -/// 剧场页页头:搜索与头像入口。 +/// 剧场页页头:整行搜索框(内嵌识剧入口)。 class DiscoverHeader extends StatelessWidget { const DiscoverHeader({ super.key, @@ -15,64 +13,26 @@ class DiscoverHeader extends StatelessWidget { required this.focusNode, required this.onChanged, required this.loading, - required this.onProfile, + required this.onIdentify, }); final TextEditingController controller; final FocusNode focusNode; final ValueChanged onChanged; final bool loading; - final VoidCallback onProfile; + final VoidCallback onIdentify; @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; return SizedBox( height: AppDimens.contentHeaderHeight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, - ), - child: Row( - children: [ - Expanded( - child: DiscoverSearchField( - controller: controller, - focusNode: focusNode, - onChanged: onChanged, - loading: loading, - horizontalPadding: 0, - ), - ), - const SizedBox(width: AppDimens.spaceSm), - _profile(l10n), - ], - ), - ), - ); - } - - Widget _profile(AppLocalizations l10n) { - return Semantics( - button: true, - label: l10n.myTab, - child: InkWell( - key: const Key('discover-profile'), - customBorder: const CircleBorder(), - onTap: onProfile, - child: Container( - width: AppDimens.tapTarget, - height: AppDimens.tapTarget, - decoration: BoxDecoration( - color: AppColors.card, - shape: BoxShape.circle, - border: Border.all(color: AppColors.border), - ), - child: const Icon( - Icons.person_rounded, - color: AppColors.onSurface, - size: AppDimens.accountIcon, - ), + child: Center( + child: DiscoverSearchField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + loading: loading, + onIdentify: onIdentify, ), ), ); diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index a9a9aab..3cda27d 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -6,7 +6,10 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; -/// 剧场顶部搜索框。 +const double _searchHeight = 40; +const double _searchRadius = 18; + +/// 剧场顶部搜索框:右侧内嵌「识剧」入口。 class DiscoverSearchField extends StatelessWidget { const DiscoverSearchField({ super.key, @@ -14,6 +17,7 @@ class DiscoverSearchField extends StatelessWidget { required this.focusNode, required this.onChanged, required this.loading, + required this.onIdentify, this.horizontalPadding = 14, }); @@ -21,6 +25,7 @@ class DiscoverSearchField extends StatelessWidget { final FocusNode focusNode; final ValueChanged onChanged; final bool loading; + final VoidCallback onIdentify; final double horizontalPadding; @override @@ -28,7 +33,7 @@ class DiscoverSearchField extends StatelessWidget { return Padding( padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: SizedBox( - height: AppDimens.searchHeight, + height: _searchHeight, child: TextField( key: const Key('discover-search'), controller: controller, @@ -40,7 +45,7 @@ class DiscoverSearchField extends StatelessWidget { decoration: InputDecoration( isDense: true, filled: true, - fillColor: AppColors.input, + fillColor: AppColors.card, hintText: AppLocalizations.of(context)!.searchDiscover, hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12), prefixIcon: const Icon( @@ -48,19 +53,28 @@ class DiscoverSearchField extends StatelessWidget { color: AppColors.subtle, size: 17, ), - suffixIcon: loading - ? const Padding( - padding: EdgeInsets.all(11), - child: CircularProgressIndicator(strokeWidth: 2), - ) - : null, + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 6), + child: loading + ? const SizedBox.square( + dimension: 18, + child: Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : _identifyPill(context), + ), + suffixIconConstraints: const BoxConstraints( + minWidth: 0, + minHeight: 0, + ), contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppDimens.pillRadius), + borderRadius: BorderRadius.circular(_searchRadius), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppDimens.pillRadius), + borderRadius: BorderRadius.circular(_searchRadius), borderSide: const BorderSide(color: AppColors.blue, width: 1), ), ), @@ -68,4 +82,38 @@ class DiscoverSearchField extends StatelessWidget { ), ); } + + Widget _identifyPill(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Material( + color: AppColors.raised, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + child: InkWell( + key: const Key('discover-identify'), + onTap: onIdentify, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.center_focus_weak_rounded, + color: AppColors.muted, + size: 14, + ), + const SizedBox(width: 4), + Text( + l10n.identifyDrama, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ], + ), + ), + ), + ), + ); + } } diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index 437f827..3f003b4 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -4,16 +4,29 @@ import 'package:flutter/material.dart'; import '../../../core/config/app_config.dart'; import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../l10n/app_localizations.dart'; const int _columns = 2; -const double _columnGap = 10; -const double _cardGap = 10; -const double _addSize = 28; -const double _addIconSize = 18; +const double _columnGap = 8; +const double _cardGap = 14; +const double _gridHorizontalPadding = 12; +const double _coverAspectRatio = 3 / 4; +const double _coverRadius = 10; +const double _titleGap = 8; +const double _tagsGap = 6; +const double _titleFontSize = 15; +const double _tagChipRadius = 6; +const double _tagChipFontSize = 11; +const double _heatFontSize = 9; +const double _heatIconSize = 11; +const int _maxTags = 3; +const Color _freeBadgeColor = Color(0xFF12B76A); +const Color _heatBackground = Color(0x99000000); +const Color _tagChipBackground = Color(0xFFF4F1EE); +final RegExp _tagSplitter = RegExp(r'[,/、·]'); /// 剧场/分类页共用的双列剧目网格。 class DramaGrid extends StatelessWidget { @@ -32,7 +45,7 @@ class DramaGrid extends StatelessWidget { Widget build(BuildContext context) { return Padding( key: const Key('home-all-dramas-section'), - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric(horizontal: _gridHorizontalPadding), child: Row( key: const Key('home-all-dramas-grid'), crossAxisAlignment: CrossAxisAlignment.start, @@ -60,7 +73,7 @@ class DramaGrid extends StatelessWidget { } } -/// 双列网格中的剧目卡片。 +/// 双列网格中的剧目卡片:海报 + 标题 + 题材标签。 class DramaGridCard extends StatelessWidget { const DramaGridCard({ super.key, @@ -73,103 +86,88 @@ class DramaGridCard extends StatelessWidget { final Drama drama; final VoidCallback onTap; + bool get _isFree => + drama.freeEpisodes > 0 || drama.episodes.any((episode) => episode.isFree); + @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - boxShadow: const [AppTheme.cardShadow], - ), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - clipBehavior: Clip.antiAlias, - child: InkWell( - key: Key('home-drama-card-${drama.id}'), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(AppDimens.spaceSm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _cover(), - const SizedBox(height: AppDimens.spaceSm), - _title(), - const SizedBox(height: AppDimens.spaceXs), - _footer(), - ], - ), - ), - ), + return GestureDetector( + key: Key('home-drama-card-${drama.id}'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _cover(), + const SizedBox(height: _titleGap), + _title(), + const SizedBox(height: _tagsGap), + _tags(), + ], ), ); } Widget _cover() { - final free = - drama.freeEpisodes > 0 || - drama.episodes.any((episode) => episode.isFree); return ClipRRect( key: Key('home-drama-cover-${drama.id}'), - borderRadius: BorderRadius.circular(AppDimens.coverRadius), - child: Stack( - children: [ - _DiscoverCoverImage(path: drama.posterUrl), - Positioned(top: 8, left: 8, child: _tagBadge(free)), - if (_lockedCount > 0) - Positioned(top: 8, right: 8, child: _lockedBadge()), - Positioned(right: 8, bottom: 8, child: _episodeBadge()), - ], - ), - ); - } - - int get _lockedCount { - final explicit = drama.episodes.where((episode) => episode.locked).length; - if (explicit > 0) return explicit; - final inferred = drama.episodeCount - drama.freeEpisodes; - return inferred > 0 ? inferred : 0; - } - - Widget _tagBadge(bool free) { - return Container( - key: Key('drama-tag-${drama.id}'), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.badgePink, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - child: Text( - free ? l10n.free : l10n.originalTag, - style: const TextStyle( - color: AppColors.badgePinkText, - fontSize: AppDimens.cardMeta, - fontWeight: FontWeight.w800, + borderRadius: BorderRadius.circular(_coverRadius), + child: AspectRatio( + aspectRatio: _coverAspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + _DiscoverCoverImage(path: drama.posterUrl), + Positioned(top: 8, left: 8, child: _tagBadge()), + Positioned(left: 8, bottom: 8, child: _heatBadge()), + ], ), ), ); } - Widget _lockedBadge() { + Widget _tagBadge() { return Container( - key: Key('drama-locked-${drama.id}'), + key: Key('drama-tag-${drama.id}'), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _freeBadgeColor, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + _isFree ? l10n.free : l10n.originalTag, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + Widget _heatBadge() { + return Container( + key: Key('drama-heat-${drama.id}'), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( - color: AppColors.overlayDark, + color: _heatBackground, borderRadius: BorderRadius.circular(AppDimens.pillRadius), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.lock_rounded, color: AppColors.onPrimary, size: 11), + const Icon( + Icons.local_fire_department_rounded, + color: AppColors.onPrimary, + size: _heatIconSize, + ), const SizedBox(width: 2), Text( - l10n.lockedEpisodeCount(_lockedCount), + l10n.heatLabel(formatInteractionCount(drama.viewCount)), style: const TextStyle( color: AppColors.onPrimary, - fontSize: 9, - fontWeight: FontWeight.w700, + fontSize: _heatFontSize, + fontWeight: FontWeight.w600, ), ), ], @@ -177,74 +175,60 @@ class DramaGridCard extends StatelessWidget { ); } - Widget _episodeBadge() { - return Container( - key: Key('drama-episodes-badge-${drama.id}'), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.badgeBeige, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - child: Text( - l10n.allEpisodesBadge(drama.episodeCount), - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 9, - fontWeight: FontWeight.w700, - ), - ), - ); - } - Widget _title() { return Text( drama.title, key: Key('home-drama-title-${drama.id}'), - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: AppDimens.cardTitle, - fontWeight: FontWeight.w800, + fontSize: _titleFontSize, + fontWeight: FontWeight.w700, + height: 1.25, ), ); } - Widget _genre() { - final genre = drama.genre.isEmpty ? drama.category : drama.genre; - return Text( - genre.isEmpty ? l10n.videoPending : genre, - key: Key('home-drama-genre-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.cardMeta, - ), - ); - } - - Widget _footer() { - return Row( + Widget _tags() { + final tags = _tagValues(); + if (tags.isEmpty) return const SizedBox.shrink(); + return Wrap( + spacing: 6, + runSpacing: 4, children: [ - Expanded(child: _genre()), - Container( - key: Key('drama-add-${drama.id}'), - width: _addSize, - height: _addSize, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, + for (var index = 0; index < tags.length; index++) + Container( + key: Key('drama-tag-chip-${drama.id}-$index'), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _tagChipBackground, + borderRadius: BorderRadius.circular(_tagChipRadius), + ), + child: Text( + tags[index], + style: const TextStyle( + color: AppColors.muted, + fontSize: _tagChipFontSize, + ), + ), ), - child: const Icon( - Icons.add_rounded, - color: AppColors.onPrimary, - size: _addIconSize, - ), - ), ], ); } + + List _tagValues() { + final result = []; + for (final value in [drama.genre, drama.category]) { + for (final part in value.split(_tagSplitter)) { + final tag = part.trim(); + if (tag.isEmpty || result.contains(tag)) continue; + result.add(tag); + if (result.length == _maxTags) return result; + } + } + return result; + } } class _DiscoverCoverImage extends StatelessWidget { @@ -259,7 +243,8 @@ class _DiscoverCoverImage extends StatelessWidget { return Image.network( url, width: double.infinity, - fit: BoxFit.fitWidth, + height: double.infinity, + fit: BoxFit.cover, loadingBuilder: (context, child, progress) { if (progress == null) return child; return _placeholder(); @@ -269,16 +254,13 @@ class _DiscoverCoverImage extends StatelessWidget { } Widget _placeholder() { - return AspectRatio( - aspectRatio: 3 / 4, - child: ColoredBox( - color: AppColors.card, - child: const Center( - child: Icon( - Icons.image_not_supported_outlined, - color: AppColors.muted, - size: 32, - ), + return const ColoredBox( + color: AppColors.card, + child: Center( + child: Icon( + Icons.image_not_supported_outlined, + color: AppColors.muted, + size: 32, ), ), ); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 2080cb0..d8a6e66 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -34,9 +34,54 @@ void main() { expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-identify')), findsOneWidget); + expect(find.byKey(const Key('discover-profile')), findsNothing); + for (final tool in ['filter', 'ranking', 'new', 'reserve']) { + expect(find.byKey(Key('theater-toolbar-$tool')), findsOneWidget); + } + expect( + find.byKey(const Key('drama-heat-city-lights-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('drama-tag-chip-city-lights-demo-0')), + findsOneWidget, + ); expect(tester.takeException(), isNull); }); + testWidgets('should_open_filter_sheet_and_apply_category', (tester) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + + await tester.tap(find.byKey(const Key('theater-toolbar-filter'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('theater-filter-sheet')), findsOneWidget); + + await tester.tap(find.byKey(const Key('theater-filter-option-7'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('theater-filter-sheet')), findsNothing); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + }); + + testWidgets('should_show_coming_soon_when_identify_is_tapped', ( + tester, + ) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + + await tester.tap(find.byKey(const Key('discover-identify'))); + await tester.pump(); + + expect(find.text('Coming soon'), findsOneWidget); + }); + testWidgets('should_filter_grid_when_category_chip_is_tapped', ( tester, ) async { @@ -73,7 +118,7 @@ void main() { matching: find.byType(Scrollable), ); - await tester.drag(find.byType(ListView), const Offset(0, -140)); + await tester.drag(_verticalFeed, const Offset(0, -140)); await tester.pumpAndSettle(); expect( @@ -129,7 +174,7 @@ void main() { await _pumpTheater(tester, categoryRepository: counter); final before = counter.categoryLoads + counter.feedLoads; - await tester.drag(find.byType(ListView), const Offset(0, 320)); + await tester.drag(_verticalFeed, const Offset(0, 320)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); await tester.pumpAndSettle(); @@ -139,6 +184,10 @@ void main() { }); } +final Finder _verticalFeed = find.byWidgetPredicate( + (widget) => widget is ListView && widget.scrollDirection == Axis.vertical, +); + Future _pumpTheater( WidgetTester tester, { required CategoryRepository categoryRepository, diff --git a/test/widget_test.dart b/test/widget_test.dart index 40b6905..3f0fb47 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -215,7 +215,7 @@ void main() { expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-profile')), findsOneWidget); + expect(find.byKey(const Key('discover-identify')), findsOneWidget); expect(tester.takeException(), isNull); }); From 5e2fba174615da99d169985c0b1d7e612f1f4f3f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:27:03 +0800 Subject: [PATCH 054/124] =?UTF-8?q?fix(flutter):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A1=86=E8=AF=86=E5=89=A7=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 1 - lib/l10n/app_id.arb | 1 - lib/l10n/app_localizations.dart | 6 -- lib/l10n/app_localizations_en.dart | 3 - lib/l10n/app_localizations_id.dart | 3 - lib/l10n/app_localizations_zh.dart | 3 - lib/l10n/app_zh.arb | 1 - lib/pages/discover/discover_view.dart | 1 - .../discover/widgets/discover_header.dart | 3 - .../widgets/discover_search_field.dart | 58 +++---------------- test/discover/home_all_dramas_grid_test.dart | 12 ---- test/widget_test.dart | 1 - 12 files changed, 9 insertions(+), 84 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 261acae..ac06870 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -30,7 +30,6 @@ "actionGenre": "Action", "filter": "Filter", "reserve": "Reserve", - "identifyDrama": "Identify", "heatLabel": "{count} heat", "@heatLabel": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index e54c24b..9d469cd 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -30,7 +30,6 @@ "actionGenre": "Aksi", "filter": "Filter", "reserve": "Reservasi", - "identifyDrama": "Identifikasi", "heatLabel": "{count} panas", "topCharts": "Peringkat Teratas", "newReleases": "Rilisan Baru", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a4e37c2..5ed8a28 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -280,12 +280,6 @@ abstract class AppLocalizations { /// **'Reserve'** String get reserve; - /// No description provided for @identifyDrama. - /// - /// In en, this message translates to: - /// **'Identify'** - String get identifyDrama; - /// No description provided for @heatLabel. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0c8adfd..94ed571 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -101,9 +101,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get reserve => 'Reserve'; - @override - String get identifyDrama => 'Identify'; - @override String heatLabel(String count) { return '$count heat'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index c92b2e5..afd95c8 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -101,9 +101,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get reserve => 'Reservasi'; - @override - String get identifyDrama => 'Identifikasi'; - @override String heatLabel(String count) { return '$count panas'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1e2ebcf..7a33e7b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -98,9 +98,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get reserve => '预约'; - @override - String get identifyDrama => '识剧'; - @override String heatLabel(String count) { return '$count热度'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index dd5e21b..bae635d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -30,7 +30,6 @@ "actionGenre": "动作", "filter": "筛选", "reserve": "预约", - "identifyDrama": "识剧", "heatLabel": "{count}热度", "topCharts": "排行榜", "newReleases": "新剧", diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 3193a6d..dfc986f 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -106,7 +106,6 @@ class _DiscoverPageState extends State { focusNode: logic.searchFocus, onChanged: (_) => logic.onSearchChanged(), loading: logic.searchLoading, - onIdentify: () => _showComingSoon(l10n), ), Expanded(child: _feed(l10n, categories)), if (!embedded) _bottomNav(l10n), diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart index c2e0ecf..ab14549 100644 --- a/lib/pages/discover/widgets/discover_header.dart +++ b/lib/pages/discover/widgets/discover_header.dart @@ -13,14 +13,12 @@ class DiscoverHeader extends StatelessWidget { required this.focusNode, required this.onChanged, required this.loading, - required this.onIdentify, }); final TextEditingController controller; final FocusNode focusNode; final ValueChanged onChanged; final bool loading; - final VoidCallback onIdentify; @override Widget build(BuildContext context) { @@ -32,7 +30,6 @@ class DiscoverHeader extends StatelessWidget { focusNode: focusNode, onChanged: onChanged, loading: loading, - onIdentify: onIdentify, ), ), ); diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index 3cda27d..74efb8d 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -3,13 +3,12 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; const double _searchHeight = 40; const double _searchRadius = 18; -/// 剧场顶部搜索框:右侧内嵌「识剧」入口。 +/// 剧场顶部整行搜索框。 class DiscoverSearchField extends StatelessWidget { const DiscoverSearchField({ super.key, @@ -17,7 +16,6 @@ class DiscoverSearchField extends StatelessWidget { required this.focusNode, required this.onChanged, required this.loading, - required this.onIdentify, this.horizontalPadding = 14, }); @@ -25,7 +23,6 @@ class DiscoverSearchField extends StatelessWidget { final FocusNode focusNode; final ValueChanged onChanged; final bool loading; - final VoidCallback onIdentify; final double horizontalPadding; @override @@ -53,17 +50,14 @@ class DiscoverSearchField extends StatelessWidget { color: AppColors.subtle, size: 17, ), - suffixIcon: Padding( - padding: const EdgeInsets.only(right: 6), - child: loading - ? const SizedBox.square( - dimension: 18, - child: Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : _identifyPill(context), - ), + suffixIcon: loading + ? const SizedBox.square( + dimension: 18, + child: Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : null, suffixIconConstraints: const BoxConstraints( minWidth: 0, minHeight: 0, @@ -82,38 +76,4 @@ class DiscoverSearchField extends StatelessWidget { ), ); } - - Widget _identifyPill(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 7), - child: Material( - color: AppColors.raised, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - child: InkWell( - key: const Key('discover-identify'), - onTap: onIdentify, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.center_focus_weak_rounded, - color: AppColors.muted, - size: 14, - ), - const SizedBox(width: 4), - Text( - l10n.identifyDrama, - style: const TextStyle(color: AppColors.muted, fontSize: 12), - ), - ], - ), - ), - ), - ), - ); - } } diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index d8a6e66..98f2ae1 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -34,7 +34,6 @@ void main() { expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-identify')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsNothing); for (final tool in ['filter', 'ranking', 'new', 'reserve']) { expect(find.byKey(Key('theater-toolbar-$tool')), findsOneWidget); @@ -71,17 +70,6 @@ void main() { ); }); - testWidgets('should_show_coming_soon_when_identify_is_tapped', ( - tester, - ) async { - await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - - await tester.tap(find.byKey(const Key('discover-identify'))); - await tester.pump(); - - expect(find.text('Coming soon'), findsOneWidget); - }); - testWidgets('should_filter_grid_when_category_chip_is_tapped', ( tester, ) async { diff --git a/test/widget_test.dart b/test/widget_test.dart index 3f0fb47..bcfc416 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -215,7 +215,6 @@ void main() { expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-identify')), findsOneWidget); expect(tester.takeException(), isNull); }); From b7926d64a5419fd58c7964e4bdae680cf6e755ff Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:35:09 +0800 Subject: [PATCH 055/124] =?UTF-8?q?feat(flutter):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=B2=89=E6=B5=B8=E5=BC=8F=20feed=20?= =?UTF-8?q?=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/home/home_view.dart | 19 ++- test/home/home_nav_ui_test.dart | 4 +- test/home/home_preview_layout_test.dart | 49 +----- test/home/k28_a_navigation_test.dart | 206 ++++-------------------- test/widget_test.dart | 17 +- 5 files changed, 55 insertions(+), 240 deletions(-) diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 7ec90e3..d1e878a 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -16,10 +16,10 @@ import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; import '../create/create_view.dart'; import '../mine/mine_types.dart'; +import '../player/player_view.dart'; import '../splash/splash_page.dart'; import '../mine/mine_view.dart'; import 'home_binding.dart'; -import 'home_browse_view.dart'; import 'home_logic.dart'; class HomePage extends StatelessWidget { @@ -158,15 +158,18 @@ class _HomeTabsState extends State<_HomeTabs> { } Widget _home(BuildContext context) { - return HomeBrowseView( + return PlayerPage( + homeMode: true, + embedded: true, active: logic.tab == 0, + drama: widget.dramas.first, + initialIndex: 0, + feedDramas: widget.dramas, + onDramaChanged: logic.onDramaChanged, + onImmersiveChanged: logic.onImmersiveChanged, onSearch: () => logic.select(1, search: true), - onProfile: logic.openProfile, - onTheater: () => logic.select(1), - onHistory: () => logic.openHistory(context), - onOpenDrama: (drama) => logic.playDrama(context, drama), - onOpenContinue: (drama, record) => - logic.playDrama(context, drama, episodeId: record.episodeId), + onDiscover: () => logic.select(1), + onMy: () => logic.select(3), ); } diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index b6ed587..40b220d 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -140,7 +140,7 @@ void main() { expect(find.byKey(const Key('tab-theater')), findsOneWidget); expect(find.byKey(const Key('tab-create')), findsOneWidget); expect(find.byKey(const Key('tab-mine')), findsOneWidget); - expect(find.byKey(const Key('home-browse-page')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); @@ -159,7 +159,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-browse-page')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); }); testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', ( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index c706c7a..37e111b 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -23,9 +23,7 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_browse_home_with_header_channels_and_cards', ( - tester, - ) async { + testWidgets('should_render_immersive_feed_on_home', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); tester.view.physicalSize = const Size(390, 844); @@ -61,50 +59,13 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-search')), findsOneWidget); - expect(find.byKey(const Key('home-profile')), findsOneWidget); - expect(find.byKey(const Key('home-notifications')), findsOneWidget); - for (final channel in ['hot', 'free', 'newReleases', 'ranking']) { - expect(find.byKey(Key('home-channel-$channel')), findsOneWidget); - } - expect(find.byKey(const Key('home-hero-banner')), findsOneWidget); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsOneWidget, - ); - expect(find.byKey(const Key('home-feed-info-card')), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byType(PageView), findsOneWidget); + expect(find.byKey(const Key('home-browse-page')), findsNothing); expect(tester.takeException(), isNull); }); - testWidgets('should_open_notices_when_bell_is_tapped', (tester) async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - 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(), - noticeRepository: FakeNoticeRepository(), - haptics: FakeHaptics(), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('home-notifications'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('notices-page')), findsOneWidget); - }); - testWidgets('should_use_episode_title_then_drama_title_as_fallback', ( tester, ) async { diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 8429c79..6e0a8c4 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -3,13 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; -import 'package:fruitshorts/core/data/models/drama.dart'; -import 'package:fruitshorts/core/data/models/home_feed.dart'; -import 'package:fruitshorts/core/data/models/watch_progress.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/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/player_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'; @@ -50,98 +46,48 @@ void main() { expect(find.text('Coming soon'), findsOneWidget); }); - testWidgets('should_switch_home_channel_when_underline_tab_is_tapped', ( - tester, - ) async { + testWidgets('should_start_home_on_immersive_feed', (tester) async { await _pumpApp(tester); - // 选中态用主题色,未选中保留白色文字与透明占位。 - expect(_underlineColor(tester, 'hot'), AppColors.blue); - expect(_underlineColor(tester, 'free'), Colors.transparent); - expect(_labelStyle(tester, 'hot').fontWeight, FontWeight.w800); - expect(_labelStyle(tester, 'free').fontWeight, FontWeight.w600); - expect(_labelStyle(tester, 'hot').color, AppColors.blue); - expect(_labelStyle(tester, 'free').color, Colors.white70); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byType(PageView), findsOneWidget); + expect(find.byKey(const Key('home-browse-page')), findsNothing); + expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); + }); - await tester.tap(find.byKey(const Key('home-channel-free'))); + testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async { + await _pumpApp(tester); + final before = tester + .widget(find.byKey(const Key('home-feed-title'))) + .data; + + await tester.drag(find.byType(PageView).first, const Offset(0, -600)); await tester.pumpAndSettle(); - expect(_underlineColor(tester, 'hot'), Colors.transparent); - expect(_underlineColor(tester, 'free'), AppColors.blue); - expect(_labelStyle(tester, 'free').color, AppColors.blue); - expect(_labelStyle(tester, 'hot').color, Colors.white70); - expect(_labelStyle(tester, 'free').fontWeight, FontWeight.w800); + final after = tester + .widget(find.byKey(const Key('home-feed-title'))) + .data; + expect(after, isNot(before)); }); - testWidgets('should_render_flush_hero_with_channels_overlaid', ( - tester, - ) async { + testWidgets('should_hide_feed_when_switching_to_theater', (tester) async { await _pumpApp(tester); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - final hero = tester.getRect(find.byKey(const Key('home-hero-banner'))); - expect(hero.width, 390); - expect(hero.left, 0); - final card = tester.widget( - find - .descendant( - of: find.byKey(const Key('home-hero-banner')), - matching: find.byType(ClipRRect), - ) - .first, - ); - expect(card.borderRadius, BorderRadius.zero); - - // 频道行是固定层,不在可滚动列表里,初始仍压在轮播顶部。 - expect( - find.ancestor( - of: find.byKey(const Key('home-channels')), - matching: find.byKey(const Key('home-browse-page')), - ), - findsNothing, - ); - final channels = tester.getRect(find.byKey(const Key('home-channels'))); - expect(channels.top, closeTo(hero.top, 1)); - expect(find.byKey(const Key('home-channels-pinned')), findsNothing); - }); - - testWidgets('should_pin_home_channels_when_scrolling', (tester) async { - // 多放几部剧,保证内容高度足够滚过吸顶阈值。 - await _pumpApp( - tester, - withBanner: false, - dramas: [ - for (var index = 0; index < 10; index++) - Drama( - id: 'scroll-$index', - title: 'Scroll Drama $index', - synopsis: 'synopsis', - posterUrl: '/files/scroll-$index.png', - genre: 'Drama', - totalEpisodes: 10, - freeEpisodes: 10, - ), - ], - ); - final before = tester.getRect(find.byKey(const Key('home-channel-hot'))); - expect(find.byKey(const Key('home-channels-pinned')), findsNothing); - - await tester.drag( - find.byKey(const Key('home-browse-page')), - const Offset(0, -300), - ); + await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-channels-pinned')), findsOneWidget); - final after = tester.getRect(find.byKey(const Key('home-channel-hot'))); - expect(after.top, closeTo(before.top, 0.5)); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect(find.byKey(const Key('discover-search')), findsOneWidget); }); - testWidgets('should_open_theater_search_when_home_search_is_tapped', ( + testWidgets('should_open_theater_search_when_feed_search_is_tapped', ( tester, ) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('home-search'))); + await tester.tap(find.byKey(const Key('home-feed-search'))); await tester.pumpAndSettle(); final field = tester.widget( @@ -165,36 +111,6 @@ void main() { ); }); - testWidgets('should_open_fullscreen_player_when_home_card_is_tapped', ( - tester, - ) async { - await _pumpApp(tester); - - await tester.ensureVisible( - find.byKey(const Key('home-drama-card-the-signal-demo')), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); - await tester.pumpAndSettle(); - - final player = tester.widget(find.byType(PlayerPage)); - expect(player.homeMode, isFalse); - }); - - testWidgets('should_use_first_drama_cover_when_home_has_no_banner', ( - tester, - ) async { - await _pumpApp(tester, withBanner: false); - - expect(find.byKey(const Key('home-hero-banner')), findsOneWidget); - expect(find.byKey(const Key('home-hero-title-0')), findsOneWidget); - await tester.tap(find.byKey(const Key('feed-banner-0'))); - await tester.pumpAndSettle(); - - final player = tester.widget(find.byType(PlayerPage)); - expect(player.homeMode, isFalse); - }); - testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( tester, ) async { @@ -218,44 +134,14 @@ void main() { await tester.tap(card); await tester.pumpAndSettle(); - final player = tester.widget(find.byType(PlayerPage)); + final player = tester.widget(find.byType(PlayerPage).last); expect(player.homeMode, isFalse); }); - testWidgets('should_resume_continue_watching_at_saved_episode', ( - tester, - ) async { - await _pumpApp( - tester, - history: InMemoryWatchHistoryRepository( - seed: const [ - WatchProgress( - dramaId: 'the-signal-demo', - episodeId: 'the-signal-demo-3', - position: Duration(minutes: 2), - duration: Duration(minutes: 10), - ), - ], - ), - ); - - expect(find.byKey(const Key('home-continue-watching')), findsOneWidget); - expect(find.text('Episode 3 · 8 min left'), findsOneWidget); - await tester.tap( - find.byKey(const Key('home-continue-play-the-signal-demo')), - ); - await tester.pumpAndSettle(); - - final player = tester.widget(find.byType(PlayerPage)); - expect(player.homeMode, isFalse); - expect(player.initialIndex, 2); - }); - - testWidgets('should_fit_home_and_theater_on_320_width', (tester) async { + testWidgets('should_fit_home_feed_and_theater_on_320_width', (tester) async { await _pumpApp(tester, size: const Size(320, 568)); - expect(find.byKey(const Key('home-search')), findsOneWidget); - expect(find.byKey(const Key('home-hero-title-0')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('tab-theater'))); @@ -273,53 +159,21 @@ const _tabKeys = [ Key('tab-mine'), ]; -Color? _underlineColor(WidgetTester tester, String name) { - final underline = tester.widget( - find.byKey(Key('home-channel-underline-$name')), - ); - return (underline.decoration! as BoxDecoration).color; -} - -TextStyle _labelStyle(WidgetTester tester, String name) { - final label = tester.widget( - find.descendant( - of: find.byKey(Key('home-channel-$name')), - matching: find.byType(Text), - ), - ); - return label.style!; -} - Future _pumpApp( WidgetTester tester, { - WatchHistoryRepository? history, Size size = const Size(390, 844), - bool withBanner = true, - List? dramas, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final catalog = FakeCatalogRepository( - dramas: dramas, - banners: withBanner - ? const [ - FeedBanner( - id: 'banner-1', - imageUrl: '/files/banner.jpg', - linkType: 'DRAMA', - linkValue: 'the-signal-demo', - ), - ] - : const [], - ); + final catalog = FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: FakeCategoryRepository(catalog: catalog), - watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), + watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), diff --git a/test/widget_test.dart b/test/widget_test.dart index bcfc416..5b910bc 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -64,11 +64,13 @@ void main() { ), ); await tester.pumpAndSettle(); + // 首页 feed 启动会创建首个播放器,先清掉再统计剧场播放器。 + created.clear(); await _openTheaterPlayer(tester); expect(created, hasLength(2)); expect(find.byType(CircularProgressIndicator), findsNothing); - await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.drag(find.byType(PageView).last, const Offset(0, -550)); await tester.pumpAndSettle(); expect( @@ -79,19 +81,14 @@ void main() { expect(created.toSet(), hasLength(3)); }); - testWidgets('should_open_fullscreen_player_when_home_card_is_tapped', ( + testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( tester, ) async { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.ensureVisible( - find.byKey(const Key('home-drama-card-the-signal-demo')), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); - await tester.pumpAndSettle(); + await _openTheaterPlayer(tester); - final player = tester.widget(find.byType(PlayerPage)); + final player = tester.widget(find.byType(PlayerPage).last); expect(player.homeMode, isFalse); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); }); @@ -121,7 +118,7 @@ void main() { await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-browse-page')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-create'))); await tester.pumpAndSettle(); From fe84244e7d2c57765e26d1edf591e257a3e6d48f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:35:40 +0800 Subject: [PATCH 056/124] =?UTF-8?q?test(flutter):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E7=9A=84=E9=A6=96=E9=A1=B5=E8=BD=AE=E6=92=AD?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/discover/discover_banner_test.dart | 84 ------------------------- 1 file changed, 84 deletions(-) delete mode 100644 test/discover/discover_banner_test.dart diff --git a/test/discover/discover_banner_test.dart b/test/discover/discover_banner_test.dart deleted file mode 100644 index a5f08a0..0000000 --- a/test/discover/discover_banner_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -// 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/player/player_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_fullscreen_player_when_home_hero_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); - expect(find.byKey(const Key('feed-banner-carousel')), findsOneWidget); - expect( - tester.getSize(find.byKey(const Key('home-hero-banner'))).height, - greaterThanOrEqualTo(230), - ); - expect( - tester - .widget(find.byKey(const Key('home-hero-title-0'))) - .style - ?.fontSize, - 28, - ); - expect(find.byKey(const Key('feed-banner-dot-0')), findsOneWidget); - await tester.tap(find.byKey(const Key('feed-banner-0'))); - await tester.pumpAndSettle(); - - final player = tester.widget(find.byType(PlayerPage)); - expect(player.homeMode, isFalse); - expect(player.drama.title, 'The Signal'); - }); -} - -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(); -} From ee6f0d7e723d298a889ece2fea4b69462924da42 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:46:20 +0800 Subject: [PATCH 057/124] =?UTF-8?q?feat(flutter):=20=E6=8C=89=E5=8F=82?= =?UTF-8?q?=E8=80=83=E5=9B=BE=E9=87=8D=E5=81=9A=E9=A6=96=E9=A1=B5=20feed?= =?UTF-8?q?=20=E6=B5=AE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 20 + lib/l10n/app_id.arb | 9 + lib/l10n/app_localizations.dart | 60 +- lib/l10n/app_localizations_en.dart | 34 +- lib/l10n/app_localizations_id.dart | 34 +- lib/l10n/app_localizations_zh.dart | 34 +- lib/l10n/app_zh.arb | 9 + lib/pages/player/player_view.dart | 11 +- lib/pages/player/player_view_actions.dart | 19 + .../player/widgets/home_feed_overlay.dart | 665 ++++++++++-------- .../widgets/home_feed_overlay_parts.dart | 393 +++++++++++ test/home/home_preview_layout_test.dart | 122 ++-- test/home/k28_a_navigation_test.dart | 37 +- test/support/fake_catalog.dart | 13 +- 14 files changed, 1062 insertions(+), 398 deletions(-) create mode 100644 lib/pages/player/widgets/home_feed_overlay_parts.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ac06870..0483ed0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,6 +29,26 @@ "romance": "Romance", "actionGenre": "Action", "filter": "Filter", + "danmaku": "Danmaku", + "watchFullscreen": "Full screen", + "seriesMeta": "Series · {episodes} eps | {viewers} watching", + "@seriesMeta": { + "placeholders": { + "episodes": {"type": "String"}, + "viewers": {"type": "String"} + } + }, + "watchFullSeries": "Watch full series · {episodes} eps", + "@watchFullSeries": { + "placeholders": { + "episodes": {"type": "String"} + } + }, + "feedTabFollowing": "Following", + "feedTabAnime": "Anime", + "feedTabReal": "Live-action", + "feedTabForYou": "For you", + "authorNoteLabel": "Author note", "reserve": "Reserve", "heatLabel": "{count} heat", "@heatLabel": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 9d469cd..98312cb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -29,6 +29,15 @@ "romance": "Romantis", "actionGenre": "Aksi", "filter": "Filter", + "danmaku": "Danmaku", + "watchFullscreen": "Layar penuh", + "seriesMeta": "Serial · {episodes} eps | {viewers} menonton", + "watchFullSeries": "Tonton serial lengkap · {episodes} eps", + "feedTabFollowing": "Mengikuti", + "feedTabAnime": "Anime", + "feedTabReal": "Live-action", + "feedTabForYou": "Untukmu", + "authorNoteLabel": "Catatan penulis", "reserve": "Reservasi", "heatLabel": "{count} panas", "topCharts": "Peringkat Teratas", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5ed8a28..b01c917 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -274,6 +274,60 @@ abstract class AppLocalizations { /// **'Filter'** String get filter; + /// No description provided for @danmaku. + /// + /// In en, this message translates to: + /// **'Danmaku'** + String get danmaku; + + /// No description provided for @watchFullscreen. + /// + /// In en, this message translates to: + /// **'Full screen'** + String get watchFullscreen; + + /// No description provided for @seriesMeta. + /// + /// In en, this message translates to: + /// **'Series · {episodes} eps | {viewers} watching'** + String seriesMeta(String episodes, String viewers); + + /// No description provided for @watchFullSeries. + /// + /// In en, this message translates to: + /// **'Watch full series · {episodes} eps'** + String watchFullSeries(String episodes); + + /// No description provided for @feedTabFollowing. + /// + /// In en, this message translates to: + /// **'Following'** + String get feedTabFollowing; + + /// No description provided for @feedTabAnime. + /// + /// In en, this message translates to: + /// **'Anime'** + String get feedTabAnime; + + /// No description provided for @feedTabReal. + /// + /// In en, this message translates to: + /// **'Live-action'** + String get feedTabReal; + + /// No description provided for @feedTabForYou. + /// + /// In en, this message translates to: + /// **'For you'** + String get feedTabForYou; + + /// No description provided for @authorNoteLabel. + /// + /// In en, this message translates to: + /// **'Author note'** + String get authorNoteLabel; + /// No description provided for @reserve. /// /// In en, this message translates to: @@ -2686,12 +2740,6 @@ abstract class AppLocalizations { /// **'Clear-screen playback'** String get clearPlayback; - /// No description provided for @danmaku. - /// - /// In en, this message translates to: - /// **'Danmaku'** - String get danmaku; - /// No description provided for @danmakuUnavailable. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 94ed571..2a273c4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -98,6 +98,37 @@ class AppLocalizationsEn extends AppLocalizations { @override String get filter => 'Filter'; + @override + String get danmaku => 'Danmaku'; + + @override + String get watchFullscreen => 'Full screen'; + + @override + String seriesMeta(String episodes, String viewers) { + return 'Series · $episodes eps | $viewers watching'; + } + + @override + String watchFullSeries(String episodes) { + return 'Watch full series · $episodes eps'; + } + + @override + String get feedTabFollowing => 'Following'; + + @override + String get feedTabAnime => 'Anime'; + + @override + String get feedTabReal => 'Live-action'; + + @override + String get feedTabForYou => 'For you'; + + @override + String get authorNoteLabel => 'Author note'; + @override String get reserve => 'Reserve'; @@ -1396,9 +1427,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get clearPlayback => 'Clear-screen playback'; - @override - String get danmaku => 'Danmaku'; - @override String get danmakuUnavailable => 'Coming soon'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index afd95c8..c257713 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -98,6 +98,37 @@ class AppLocalizationsId extends AppLocalizations { @override String get filter => 'Filter'; + @override + String get danmaku => 'Komentar layar'; + + @override + String get watchFullscreen => 'Layar penuh'; + + @override + String seriesMeta(String episodes, String viewers) { + return 'Serial · $episodes eps | $viewers menonton'; + } + + @override + String watchFullSeries(String episodes) { + return 'Tonton serial lengkap · $episodes eps'; + } + + @override + String get feedTabFollowing => 'Mengikuti'; + + @override + String get feedTabAnime => 'Anime'; + + @override + String get feedTabReal => 'Live-action'; + + @override + String get feedTabForYou => 'Untukmu'; + + @override + String get authorNoteLabel => 'Catatan penulis'; + @override String get reserve => 'Reservasi'; @@ -1396,9 +1427,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get clearPlayback => 'Putar tanpa kontrol'; - @override - String get danmaku => 'Komentar layar'; - @override String get danmakuUnavailable => 'Belum tersedia'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 7a33e7b..3ca6a26 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -95,6 +95,37 @@ class AppLocalizationsZh extends AppLocalizations { @override String get filter => '筛选'; + @override + String get danmaku => '弹幕'; + + @override + String get watchFullscreen => '全屏观看'; + + @override + String seriesMeta(String episodes, String viewers) { + return '系列剧 · 共$episodes集 | 共$viewers人在追'; + } + + @override + String watchFullSeries(String episodes) { + return '观看完整剧集 · 全$episodes集'; + } + + @override + String get feedTabFollowing => '关注'; + + @override + String get feedTabAnime => '漫剧'; + + @override + String get feedTabReal => '真人剧'; + + @override + String get feedTabForYou => '推荐'; + + @override + String get authorNoteLabel => '作者声明'; + @override String get reserve => '预约'; @@ -1372,9 +1403,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get clearPlayback => '清屏播放'; - @override - String get danmaku => '弹幕'; - @override String get danmakuUnavailable => '暂未开放'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bae635d..1c89f23 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -29,6 +29,15 @@ "romance": "都市爱情", "actionGenre": "动作", "filter": "筛选", + "danmaku": "弹幕", + "watchFullscreen": "全屏观看", + "seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追", + "watchFullSeries": "观看完整剧集 · 全{episodes}集", + "feedTabFollowing": "关注", + "feedTabAnime": "漫剧", + "feedTabReal": "真人剧", + "feedTabForYou": "推荐", + "authorNoteLabel": "作者声明", "reserve": "预约", "heatLabel": "{count}热度", "topCharts": "排行榜", diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index d7f0652..ccf5d93 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -367,8 +367,14 @@ class _PlayerPageState extends State with WidgetsBindingObserver { l10n: l10n, drama: drama, episode: episode, + controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), - likeCount: interactions.likeCount(drama.id), + likeCount: + interactions.likeCount(drama.id) ?? drama.likeCount, + favoriteCount: drama.favoriteCount == null + ? null + : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), + commentCount: drama.commentCount, saved: context.watch().isSaved(drama.id), onLike: () async { if (await ensureSignedIn(context)) { @@ -404,6 +410,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onComments: () => logic.showComments(context, drama), onShare: () => logic.shareDrama(context, drama), onEpisodes: () => logic.showEpisodes(context), + onDetail: () => unawaited(logic.openDetail(context)), + onFullscreen: () => + unawaited(_openFullscreen(context, drama, episode)), onDownload: () => requestEpisodeDownload(context, episode.id), onClear: () => setState(() => _chromeVisible = false), onSearch: diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 3f9fd81..52ecf84 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -43,6 +43,25 @@ extension _PlayerViewActions on _PlayerPageState { ); } + /// 首页 feed 的「全屏观看」:以当前剧集为起点打开全屏播放页。 + Future _openFullscreen( + BuildContext context, + Drama drama, + Episode episode, + ) async { + final index = drama.episodes.indexWhere((item) => item.id == episode.id); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage( + drama: drama, + initialIndex: index < 0 ? 0 : index, + wallet: widget.wallet, + downloads: widget.downloads, + ), + ), + ); + } + Future _openUnlockSheet( BuildContext context, int index, diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 220d1ab..a6cd45b 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -1,422 +1,479 @@ // Author: fruit.com.cn -import 'dart:async'; - +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../core/widgets/glass_panel.dart'; import '../../../l10n/app_localizations.dart'; -/// Home feed layer from `docs/移动端设计预览.html` screen 01. -class HomeFeedOverlay extends StatelessWidget { +part 'home_feed_overlay_parts.dart'; + +const Color _overlayPill = Color(0x66000000); +const Color _fullBarColor = Color(0xCC000000); +const double _topIconSize = 36; +const double _topIconGlyph = 22; +const double _pillHeight = 34; +const double _tabGap = 22; +const double _tabFontSize = 15; +const double _underlineWidth = 18; +const double _underlineHeight = 2; +const double _titleFontSize = 17; +const double _metaFontSize = 12; +const double _tagFontSize = 11; +const double _railIconSize = 22; +const double _fullBarHeight = 48; +const double _progressHeight = 2; +const int _maxTags = 4; +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + +/// 首页沉浸式 feed 浮层:顶部频道 tab、右侧互动栏、底部信息与整集进度条。 +class HomeFeedOverlay extends StatefulWidget { const HomeFeedOverlay({ super.key, required this.l10n, required this.drama, required this.episode, + required this.controller, required this.liked, - this.likeCount, required this.saved, required this.onLike, required this.onSave, required this.onComments, required this.onShare, required this.onEpisodes, + required this.onDetail, + required this.onFullscreen, required this.onDownload, required this.onClear, required this.onSearch, + this.likeCount, + this.favoriteCount, + this.commentCount, + this.authorNote = '', this.onPip, }); final AppLocalizations l10n; final Drama drama; final Episode episode; + final ValueListenable? controller; final bool liked; final int? likeCount; + final int? favoriteCount; + final int? commentCount; final bool saved; + final String authorNote; final VoidCallback onLike; final VoidCallback onSave; final VoidCallback onComments; final VoidCallback onShare; final VoidCallback onEpisodes; + final VoidCallback onDetail; + final VoidCallback onFullscreen; final VoidCallback onDownload; final VoidCallback onClear; final VoidCallback onSearch; final VoidCallback? onPip; + @override + State createState() => _HomeFeedOverlayState(); +} + +class _HomeFeedOverlayState extends State { + _FeedTab _tab = _FeedTab.recommend; + @override Widget build(BuildContext context) { return SafeArea( bottom: false, - child: Column( + child: Stack( children: [ - _header(context), - Expanded( - child: Align( - alignment: const Alignment(1.0, 0.45), - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 12, 16), - child: _rail(), - ), - ), + Positioned( + top: 0, + left: 0, + right: 0, + child: IgnorePointer(child: _gradient(top: true)), ), - Padding( - padding: const EdgeInsets.fromLTRB(14, 0, 14, 14), - child: _infoCard(), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer(child: _gradient(top: false)), + ), + Column( + children: [ + _header(context), + Expanded( + child: Align( + alignment: const Alignment(1.0, 0.45), + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 10, 16), + child: _rail(), + ), + ), + ), + _bottomInfo(), + _fullSeriesBar(), + ], ), ], ), ); } + Widget _gradient({required bool top}) { + return Container( + height: top ? 140 : 360, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: top ? Alignment.topCenter : Alignment.bottomCenter, + end: top ? Alignment.bottomCenter : Alignment.topCenter, + colors: top + ? const [Color(0xB3000000), Color(0x00000000)] + : const [Color(0xE6000000), Color(0x00000000)], + ), + ), + ); + } + Widget _header(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(14, 6, 10, 4), + padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), child: Row( children: [ + _TopIconButton( + key: const Key('home-feed-menu'), + tooltip: widget.l10n.more, + icon: Icons.menu_rounded, + dimension: _topIconSize, + glyphSize: _topIconGlyph, + onPressed: () => _openMore(context), + ), Expanded( - child: Text( - localizedDramaTitle(l10n, drama), - key: const Key('home-feed-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w800, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], + child: Center( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var index = 0; index < _FeedTab.values.length; index++) + _tabSlot(_FeedTab.values[index], index), + ], + ), ), ), ), - _RoundIcon( + _TopIconButton( key: const Key('home-feed-search'), - tooltip: l10n.search, + tooltip: widget.l10n.search, icon: Icons.search_rounded, - onPressed: onSearch, - ), - const SizedBox(width: 8), - _RoundIcon( - key: const Key('home-feed-clear-screen'), - tooltip: l10n.clearScreen, - icon: Icons.fullscreen_rounded, - onPressed: onClear, - ), - const SizedBox(width: 8), - _RoundIcon( - key: const Key('home-feed-more'), - tooltip: l10n.more, - icon: Icons.menu_rounded, - onPressed: () => _openMore(context), + dimension: _topIconSize, + glyphSize: _topIconGlyph, + onPressed: widget.onSearch, ), ], ), ); } + Widget _tabSlot(_FeedTab tab, int index) { + final item = _FeedTabItem( + tab: tab, + label: tab.label(widget.l10n), + selected: _tab == tab, + fontSize: _tabFontSize, + underlineWidth: _underlineWidth, + underlineHeight: _underlineHeight, + onTap: () => _selectTab(tab), + ); + if (index == 0) return item; + return Padding( + padding: const EdgeInsets.only(left: _tabGap), + child: item, + ); + } + + void _selectTab(_FeedTab tab) { + if (tab.comingSoon) { + _showComingSoon(); + return; + } + setState(() => _tab = tab); + } + Widget _rail() { return Column( mainAxisSize: MainAxisSize.min, children: [ - _RailItem( - key: const Key('rail-like'), - icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - label: likeCount?.toString() ?? l10n.like, - countKey: likeCount == null ? null : const Key('rail-like-count'), - selected: liked, - onTap: onLike, + _FeedRailItem( + key: const Key('rail-save'), + icon: widget.saved ? Icons.star_rounded : Icons.star_border_rounded, + count: widget.favoriteCount, + countKey: const Key('rail-save-count'), + label: widget.l10n.favorite, + selected: widget.saved, + iconSize: _railIconSize, + onTap: widget.onSave, ), - _RailItem( + _FeedRailItem( key: const Key('rail-comments'), icon: Icons.chat_bubble_outline_rounded, - label: l10n.comments, - onTap: onComments, + count: widget.commentCount, + countKey: const Key('rail-comments-count'), + label: widget.l10n.comments, + iconSize: _railIconSize, + onTap: widget.onComments, ), - _RailItem( + _FeedRailItem( + key: const Key('rail-like'), + icon: widget.liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + count: widget.likeCount, + countKey: const Key('rail-like-count'), + label: widget.l10n.like, + selected: widget.liked, + iconSize: _railIconSize, + onTap: widget.onLike, + ), + _FeedRailItem( key: const Key('rail-share'), icon: Icons.ios_share_rounded, - label: l10n.share, - onTap: onShare, - ), - _RailItem( - key: const Key('rail-save'), - icon: saved ? Icons.star_rounded : Icons.star_border_rounded, - label: l10n.favorite, - selected: saved, - onTap: onSave, + label: widget.l10n.share, + iconSize: _railIconSize, + onTap: widget.onShare, ), ], ); } - Widget _infoCard() { - final genre = drama.genre.isEmpty ? drama.category : drama.genre; - return GlassPanel( - key: const Key('home-feed-info-card'), - radius: 14, - blur: 6, - padding: const EdgeInsets.all(12), - borderColor: AppColors.border, + Widget _bottomInfo() { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), child: Column( - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Wrap( - spacing: 6, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, + Row( children: [ - if (genre.isNotEmpty) - _tag( - genre, - background: AppColors.blue, - foreground: AppColors.onPrimary, + Flexible( + child: _DanmakuPill( + l10n: widget.l10n, + height: _pillHeight, + background: _overlayPill, + onTap: _showComingSoon, ), - if (drama.category.isNotEmpty && drama.category != genre) - _tag( - drama.category, - background: AppColors.raised, - foreground: AppColors.onSurface, + ), + const SizedBox(width: 12), + Flexible( + child: _FullscreenPill( + l10n: widget.l10n, + height: _pillHeight, + background: _overlayPill, + onTap: widget.onFullscreen, ), - Text( - l10n.episodeOfTotal(episode.number, drama.episodeCount), - key: const Key('home-feed-episode'), - style: const TextStyle(color: AppColors.muted, fontSize: 9), ), ], ), const SizedBox(height: 8), - Text( - episode.title.trim().isEmpty - ? localizedDramaTitle(l10n, drama) - : episode.title, - key: const Key('home-feed-episode-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w800, + _seriesMetaRow(), + const SizedBox(height: 2), + _titleRow(), + const SizedBox(height: 6), + _tagsRow(), + if (widget.authorNote.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + '${widget.l10n.authorNoteLabel}: ${widget.authorNote}', + key: const Key('home-feed-author-note'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontSize: 11), + ), + ], + ], + ), + ); + } + + Widget _seriesMetaRow() { + return GestureDetector( + key: const Key('home-feed-series-meta'), + onTap: widget.onDetail, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.l10n.seriesMeta( + '${widget.drama.episodeCount}', + formatInteractionCount(widget.drama.viewCount), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white70, + fontSize: _metaFontSize, + ), ), ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: SizedBox( - height: 34, - child: FilledButton.icon( - key: const Key('home-feed-follow-series'), - onPressed: onSave, - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - AppDimens.buttonRadius, - ), - ), - padding: EdgeInsets.zero, - textStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - icon: const Icon(Icons.play_arrow_rounded, size: 16), - label: Text( - saved ? l10n.followingTab : l10n.followSeries, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: SizedBox( - height: 34, - child: FilledButton( - key: const Key('home-feed-episodes'), - onPressed: onEpisodes, - style: FilledButton.styleFrom( - backgroundColor: AppColors.raised, - foregroundColor: AppColors.onSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - AppDimens.buttonRadius, - ), - ), - padding: EdgeInsets.zero, - textStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - child: Text( - l10n.episodes, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - ], + const Icon( + Icons.chevron_right_rounded, + color: Colors.white70, + size: 16, ), ], ), ); } - Widget _tag( - String label, { - required Color background, - required Color foreground, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(6), + Widget _titleRow() { + return GestureDetector( + onTap: widget.onDetail, + child: Row( + children: [ + Flexible( + child: Text( + localizedDramaTitle(widget.l10n, widget.drama), + key: const Key('home-feed-title'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: Colors.white, + fontSize: _titleFontSize, + fontWeight: FontWeight.w800, + shadows: [Shadow(blurRadius: 8, color: Colors.black)], + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: Colors.white, + size: 20, + ), + ], ), - child: Text( - label, - maxLines: 1, - style: TextStyle( - color: foreground, - fontSize: 8, - fontWeight: FontWeight.w800, + ); + } + + Widget _tagsRow() { + final tags = [ + widget.l10n.episodeLabel(widget.episode.number), + ..._tagValues(), + ].take(_maxTags).toList(); + return Wrap( + key: const Key('home-feed-tags'), + spacing: 6, + runSpacing: 4, + children: [ + for (final tag in tags) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _overlayPill, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + tag, + maxLines: 1, + style: const TextStyle( + color: Colors.white, + fontSize: _tagFontSize, + ), + ), + ), + ], + ); + } + + List _tagValues() { + final result = []; + for (final value in [widget.drama.genre, widget.drama.category]) { + for (final part in value.split(_tagSplitter)) { + final tag = part.trim(); + if (tag.isEmpty || result.contains(tag)) continue; + result.add(tag); + } + } + return result; + } + + Widget _fullSeriesBar() { + return GestureDetector( + key: const Key('home-feed-full-series'), + onTap: widget.onEpisodes, + child: Container( + height: _fullBarHeight, + color: _fullBarColor, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 14, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + widget.l10n.watchFullSeries( + '${widget.drama.episodeCount}', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: Colors.white, + size: 18, + ), + ], + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _FeedProgressTrack( + controller: widget.controller, + height: _progressHeight, + ), + ), + ], ), ), ); } + void _showComingSoon() { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon))); + } + Future _openMore(BuildContext context) async { await showModalBottomSheet( context: context, backgroundColor: AppColors.card, showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - key: const Key('home-feed-download'), - leading: const Icon( - Icons.download_outlined, - color: AppColors.secondary, - ), - title: Text(l10n.downloads), - onTap: () { - Navigator.pop(sheetContext); - onDownload(); - }, - ), - if (onPip != null) - ListTile( - key: const Key('home-feed-pip'), - leading: const Icon( - Icons.picture_in_picture_alt_outlined, - color: AppColors.secondary, - ), - title: Text(l10n.pictureInPicture), - onTap: () { - Navigator.pop(sheetContext); - onPip!(); - }, - ), - ], - ), - ), - ); - } -} - -class _RoundIcon extends StatelessWidget { - const _RoundIcon({ - super.key, - required this.tooltip, - required this.icon, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 36, - height: 36, - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: AppColors.card.withValues(alpha: 0.94), - foregroundColor: AppColors.onSurface, - ), - icon: Icon(icon, size: 18), - ), - ); - } -} - -class _RailItem extends StatelessWidget { - const _RailItem({ - super.key, - required this.icon, - 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) { - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Semantics( - button: true, - label: label, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(22), - child: SizedBox( - width: 44, - child: Column( - children: [ - Icon( - icon, - size: 22, - color: selected ? AppColors.blue : AppColors.onSurface, - ), - const SizedBox(height: 2), - Text( - label, - key: countKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.cream, - fontSize: 9, - shadows: [Shadow(blurRadius: 6, color: Colors.black)], - ), - ), - ], - ), - ), - ), + builder: (sheetContext) => _FeedMoreSheet( + l10n: widget.l10n, + onDownload: widget.onDownload, + onPip: widget.onPip, + onClear: widget.onClear, ), ); } diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart new file mode 100644 index 0000000..629c157 --- /dev/null +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -0,0 +1,393 @@ +// Author: fruit.com.cn + +part of 'home_feed_overlay.dart'; + +/// 顶部频道 tab:关注/漫剧点击提示即将上线。 +enum _FeedTab { + following, + anime, + real, + recommend; + + bool get comingSoon => this == _FeedTab.following || this == _FeedTab.anime; + + String get keyName => switch (this) { + _FeedTab.following => 'follow', + _FeedTab.anime => 'anime', + _FeedTab.real => 'real', + _FeedTab.recommend => 'recommend', + }; + + String label(AppLocalizations l10n) => switch (this) { + _FeedTab.following => l10n.feedTabFollowing, + _FeedTab.anime => l10n.feedTabAnime, + _FeedTab.real => l10n.feedTabReal, + _FeedTab.recommend => l10n.feedTabForYou, + }; +} + +class _TopIconButton extends StatelessWidget { + const _TopIconButton({ + super.key, + required this.tooltip, + required this.icon, + required this.dimension, + required this.glyphSize, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final double dimension; + final double glyphSize; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: dimension, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon( + icon, + size: glyphSize, + color: Colors.white, + shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ); + } +} + +class _FeedTabItem extends StatelessWidget { + const _FeedTabItem({ + required this.tab, + required this.label, + required this.selected, + required this.fontSize, + required this.underlineWidth, + required this.underlineHeight, + required this.onTap, + }); + + final _FeedTab tab; + final String label; + final bool selected; + final double fontSize; + final double underlineWidth; + final double underlineHeight; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: Key('home-feed-tab-${tab.keyName}'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + maxLines: 1, + style: TextStyle( + color: selected ? Colors.white : Colors.white70, + fontSize: fontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + const SizedBox(height: 4), + Container( + width: underlineWidth, + height: underlineHeight, + decoration: BoxDecoration( + color: selected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + ], + ), + ); + } +} + +class _FeedRailItem extends StatelessWidget { + const _FeedRailItem({ + super.key, + required this.icon, + required this.label, + required this.iconSize, + required this.onTap, + this.count, + this.countKey, + this.selected = false, + }); + + final IconData icon; + final String label; + final double iconSize; + final VoidCallback onTap; + final int? count; + final Key? countKey; + final bool selected; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Semantics( + button: true, + label: label, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + width: 48, + child: Column( + children: [ + Icon( + icon, + size: iconSize, + color: selected ? AppColors.blue : Colors.white, + shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + ), + const SizedBox(height: 2), + Text( + count == null ? label : formatInteractionCount(count!), + key: countKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _FeedPill extends StatelessWidget { + const _FeedPill({ + required this.height, + required this.background, + required this.child, + }); + + final double height; + final Color background; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + height: height, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + ), + child: Center(child: child), + ); + } +} + +class _DanmakuPill extends StatelessWidget { + const _DanmakuPill({ + required this.l10n, + required this.height, + required this.background, + required this.onTap, + }); + + final AppLocalizations l10n; + final double height; + final Color background; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: const Key('home-feed-danmaku'), + onTap: onTap, + child: _FeedPill( + height: height, + background: background, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '弹', + style: TextStyle(color: Colors.white, fontSize: 12), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + l10n.danmaku, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + ], + ), + ), + ); + } +} + +class _FullscreenPill extends StatelessWidget { + const _FullscreenPill({ + required this.l10n, + required this.height, + required this.background, + required this.onTap, + }); + + final AppLocalizations l10n; + final double height; + final Color background; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: const Key('home-feed-fullscreen'), + onTap: onTap, + child: _FeedPill( + height: height, + background: background, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.fullscreen_rounded, color: Colors.white, size: 14), + const SizedBox(width: 4), + Flexible( + child: Text( + l10n.watchFullscreen, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ], + ), + ), + ); + } +} + +class _FeedProgressTrack extends StatelessWidget { + const _FeedProgressTrack({required this.controller, required this.height}); + + final ValueListenable? controller; + final double height; + + @override + Widget build(BuildContext context) { + final source = controller; + if (source == null) return _track(0); + return ValueListenableBuilder( + valueListenable: source, + builder: (context, value, _) { + final total = value.duration.inMilliseconds; + final fraction = total <= 0 + ? 0.0 + : (value.position.inMilliseconds / total).clamp(0.0, 1.0); + return _track(fraction.toDouble()); + }, + ); + } + + Widget _track(double fraction) { + return LayoutBuilder( + builder: (context, constraints) => SizedBox( + height: height, + child: Stack( + children: [ + Container( + width: constraints.maxWidth, + height: height, + color: Colors.white24, + ), + Container( + width: constraints.maxWidth * fraction, + height: height, + color: AppColors.blue, + ), + ], + ), + ), + ); + } +} + +class _FeedMoreSheet extends StatelessWidget { + const _FeedMoreSheet({ + required this.l10n, + required this.onDownload, + required this.onClear, + this.onPip, + }); + + final AppLocalizations l10n; + final VoidCallback onDownload; + final VoidCallback onClear; + final VoidCallback? onPip; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('home-feed-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.downloads), + onTap: () { + Navigator.pop(context); + onDownload(); + }, + ), + if (onPip != null) + ListTile( + key: const Key('home-feed-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.pictureInPicture), + onTap: () { + Navigator.pop(context); + onPip!(); + }, + ), + ListTile( + key: const Key('home-feed-clear'), + leading: const Icon( + Icons.fullscreen_rounded, + color: AppColors.secondary, + ), + title: Text(l10n.clearScreen), + onTap: () { + Navigator.pop(context); + onClear(); + }, + ), + ], + ), + ); + } +} diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 37e111b..8a5aacc 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -60,75 +60,29 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byType(PageView), findsOneWidget); expect(find.byKey(const Key('home-browse-page')), findsNothing); expect(tester.takeException(), isNull); }); - testWidgets('should_use_episode_title_then_drama_title_as_fallback', ( + testWidgets('should_render_new_feed_structure_with_formatted_counts', ( tester, ) async { - Future pumpEpisode(Episode episode) async { - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(), - locale: const Locale('en'), - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalizations.localizationsDelegates, - home: Builder( - builder: (context) => Scaffold( - body: HomeFeedOverlay( - l10n: AppLocalizations.of(context)!, - drama: _drama, - episode: episode, - liked: false, - saved: false, - onLike: () {}, - onSave: () {}, - onComments: () {}, - onShare: () {}, - onEpisodes: () {}, - onDownload: () {}, - onClear: () {}, - onSearch: () {}, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - } - - await pumpEpisode( - const Episode( - id: 'featured', - number: 2, - title: 'Midnight Contract', - isFree: true, - ), + const drama = Drama( + id: 'feed-drama', + title: 'Feed Drama', + synopsis: 'synopsis', + posterUrl: '', + genre: 'Romance', + category: 'Urban', + totalEpisodes: 8, + freeEpisodes: 8, + viewCount: 419000, + likeCount: 1006, + favoriteCount: 32000, + commentCount: 12, ); - expect( - tester - .widget(find.byKey(const Key('home-feed-episode-title'))) - .data, - 'Midnight Contract', - ); - - await pumpEpisode( - const Episode(id: 'fallback', number: 2, title: '', isFree: true), - ); - expect( - tester - .widget(find.byKey(const Key('home-feed-episode-title'))) - .data, - 'Fallback Drama', - ); - }); - - testWidgets('should_show_server_like_count_when_count_is_available', ( - tester, - ) async { await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), @@ -139,21 +93,26 @@ void main() { builder: (context) => Scaffold( body: HomeFeedOverlay( l10n: AppLocalizations.of(context)!, - drama: _drama, + drama: drama, episode: const Episode( - id: 'count-episode', - number: 1, - title: 'Count Episode', + id: 'feed-episode', + number: 2, + title: 'Ignored Episode Title', isFree: true, ), + controller: null, liked: true, - likeCount: 128, saved: false, + likeCount: 1006, + favoriteCount: 32000, + commentCount: 12, onLike: () {}, onSave: () {}, onComments: () {}, onShare: () {}, onEpisodes: () {}, + onDetail: () {}, + onFullscreen: () {}, onDownload: () {}, onClear: () {}, onSearch: () {}, @@ -164,15 +123,26 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('rail-like-count')), findsOneWidget); - expect(find.text('128'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('home-feed-title'))).data, + 'Feed Drama', + ); + expect(find.byKey(const Key('home-feed-info-card')), findsNothing); + expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-tags')), findsOneWidget); + expect(find.text('Episode 2'), findsOneWidget); + expect(find.textContaining('41.9万'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('rail-like-count'))).data, + '1006', + ); + expect( + tester.widget(find.byKey(const Key('rail-save-count'))).data, + '3.2万', + ); + expect( + tester.widget(find.byKey(const Key('rail-comments-count'))).data, + '12', + ); }); } - -const _drama = Drama( - id: 'featured-drama', - title: 'Fallback Drama', - synopsis: 'synopsis', - posterUrl: '', - totalEpisodes: 8, -); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 6e0a8c4..79bc0c4 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -50,12 +50,47 @@ void main() { await _pumpApp(tester); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byType(PageView), findsOneWidget); expect(find.byKey(const Key('home-browse-page')), findsNothing); + expect( + tester.widget(find.byKey(const Key('rail-like-count'))).data, + '41.9万', + ); + expect( + tester.widget(find.byKey(const Key('rail-save-count'))).data, + '1006', + ); + expect( + tester.widget(find.byKey(const Key('rail-comments-count'))).data, + '3.2万', + ); expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); }); + testWidgets('should_open_episode_sheet_from_full_series_bar', (tester) async { + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget); + }); + + testWidgets('should_show_coming_soon_for_danmaku_and_following_tab', ( + tester, + ) async { + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('home-feed-danmaku'))); + await tester.pump(); + expect(find.text('Coming soon'), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-feed-tab-follow'))); + await tester.pump(); + expect(find.text('Coming soon'), findsOneWidget); + }); + testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async { await _pumpApp(tester); final before = tester diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 799ac78..8b5598c 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -44,7 +44,16 @@ class FakeCatalogRepository implements CatalogRepository, SearchRepository { } final List demoDramas = [ - _drama('the-signal-demo', 'The Signal', 'Suspense · Mystery', format: 1), + _drama( + 'the-signal-demo', + 'The Signal', + 'Suspense · Mystery', + format: 1, + viewCount: 419000, + likeCount: 419000, + favoriteCount: 1006, + commentCount: 32000, + ), _drama( 'city-lights-demo', 'City Lights & Velvet Nights', @@ -65,6 +74,7 @@ Drama _drama( int? likeCount, int? favoriteCount, int? commentCount, + int viewCount = 0, }) => Drama( id: id, title: title, @@ -73,6 +83,7 @@ Drama _drama( genre: genre, language: 'id', detailLoaded: true, + viewCount: viewCount, likeCount: likeCount, favoriteCount: favoriteCount, commentCount: commentCount, From d36d62f6a6d3b552c38e08213d3a0f6aa32a3f42 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:48:42 +0800 Subject: [PATCH 058/124] =?UTF-8?q?fix(flutter):=20=E5=8E=BB=E9=99=A4?= =?UTF-8?q?=E5=85=B3=E6=B3=A8=20tab=20=E5=B9=B6=E5=8A=A0=E6=B7=B1=E8=89=B2?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/app_bottom_nav.dart | 35 +++++++++++++------ lib/l10n/app_en.arb | 1 - lib/l10n/app_id.arb | 1 - lib/l10n/app_localizations.dart | 6 ---- lib/l10n/app_localizations_en.dart | 3 -- lib/l10n/app_localizations_id.dart | 3 -- lib/l10n/app_localizations_zh.dart | 3 -- lib/l10n/app_zh.arb | 1 - lib/pages/home/home_view.dart | 1 + .../widgets/home_feed_overlay_parts.dart | 7 ++-- test/home/home_nav_ui_test.dart | 22 ++++++++++-- test/home/k28_a_navigation_test.dart | 4 +-- 12 files changed, 48 insertions(+), 39 deletions(-) diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index 231deec..8905618 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -26,18 +26,20 @@ class AppBottomNavItem { final bool selected; } -/// 四槽底部导航,仅做浅色暖橙视觉换肤。 +/// 四槽底部导航:首页 feed 用深色样式,其它页面保持浅色暖橙风格。 class AppBottomNav extends StatelessWidget { const AppBottomNav({ super.key, required this.items, this.backgroundColor = AppColors.bar, this.barKey, + this.dark = false, }); final List items; final Color backgroundColor; final Key? barKey; + final bool dark; @override Widget build(BuildContext context) { @@ -50,11 +52,25 @@ class AppBottomNav extends StatelessWidget { key: barKey, height: AppDimens.navHeight, decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.95), - border: const Border(top: BorderSide(color: AppColors.border)), + color: dark + ? const Color(0xE6000000) + : backgroundColor.withValues(alpha: 0.95), + border: Border( + top: BorderSide( + color: dark ? Colors.white12 : AppColors.border, + ), + ), ), child: Row( - children: [for (final item in items) _NavDestination(item: item)], + children: [ + for (final item in items) + _NavDestination( + item: item, + unselectedColor: dark + ? Colors.white70 + : AppColors.navUnselected, + ), + ], ), ), ), @@ -64,9 +80,10 @@ class AppBottomNav extends StatelessWidget { } class _NavDestination extends StatelessWidget { - const _NavDestination({required this.item}); + const _NavDestination({required this.item, required this.unselectedColor}); final AppBottomNavItem item; + final Color unselectedColor; @override Widget build(BuildContext context) => Expanded( @@ -89,9 +106,7 @@ class _NavDestination extends StatelessWidget { child: Icon( item.selected ? item.selectedIcon : item.icon, size: AppDimens.navIcon, - color: item.selected - ? AppColors.blue - : AppColors.navUnselected, + color: item.selected ? AppColors.blue : unselectedColor, ), ), const SizedBox(height: 2), @@ -102,9 +117,7 @@ class _NavDestination extends StatelessWidget { style: TextStyle( fontSize: AppDimens.navLabel, fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500, - color: item.selected - ? AppColors.blue - : AppColors.navUnselected, + color: item.selected ? AppColors.blue : unselectedColor, letterSpacing: 0, ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0483ed0..bd73cbc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -44,7 +44,6 @@ "episodes": {"type": "String"} } }, - "feedTabFollowing": "Following", "feedTabAnime": "Anime", "feedTabReal": "Live-action", "feedTabForYou": "For you", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 98312cb..8ae2c4c 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -33,7 +33,6 @@ "watchFullscreen": "Layar penuh", "seriesMeta": "Serial · {episodes} eps | {viewers} menonton", "watchFullSeries": "Tonton serial lengkap · {episodes} eps", - "feedTabFollowing": "Mengikuti", "feedTabAnime": "Anime", "feedTabReal": "Live-action", "feedTabForYou": "Untukmu", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b01c917..1da0b30 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -298,12 +298,6 @@ abstract class AppLocalizations { /// **'Watch full series · {episodes} eps'** String watchFullSeries(String episodes); - /// No description provided for @feedTabFollowing. - /// - /// In en, this message translates to: - /// **'Following'** - String get feedTabFollowing; - /// No description provided for @feedTabAnime. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2a273c4..a0b04dc 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -114,9 +114,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Watch full series · $episodes eps'; } - @override - String get feedTabFollowing => 'Following'; - @override String get feedTabAnime => 'Anime'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index c257713..f0d6374 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -114,9 +114,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Tonton serial lengkap · $episodes eps'; } - @override - String get feedTabFollowing => 'Mengikuti'; - @override String get feedTabAnime => 'Anime'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 3ca6a26..1f82f34 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -111,9 +111,6 @@ class AppLocalizationsZh extends AppLocalizations { return '观看完整剧集 · 全$episodes集'; } - @override - String get feedTabFollowing => '关注'; - @override String get feedTabAnime => '漫剧'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1c89f23..039959f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -33,7 +33,6 @@ "watchFullscreen": "全屏观看", "seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追", "watchFullSeries": "观看完整剧集 · 全{episodes}集", - "feedTabFollowing": "关注", "feedTabAnime": "漫剧", "feedTabReal": "真人剧", "feedTabForYou": "推荐", diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index d1e878a..084ecae 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -198,6 +198,7 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _nav(BuildContext context, AppLocalizations l10n) { return AppBottomNav( barKey: const Key('home-tabs-bar'), + dark: logic.tab == 0, items: [ _navItem(context, l10n, 0), _navItem(context, l10n, 1), diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 629c157..aed95ef 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -2,24 +2,21 @@ part of 'home_feed_overlay.dart'; -/// 顶部频道 tab:关注/漫剧点击提示即将上线。 +/// 顶部频道 tab:漫剧点击提示即将上线。 enum _FeedTab { - following, anime, real, recommend; - bool get comingSoon => this == _FeedTab.following || this == _FeedTab.anime; + bool get comingSoon => this == _FeedTab.anime; String get keyName => switch (this) { - _FeedTab.following => 'follow', _FeedTab.anime => 'anime', _FeedTab.real => 'real', _FeedTab.recommend => 'recommend', }; String label(AppLocalizations l10n) => switch (this) { - _FeedTab.following => l10n.feedTabFollowing, _FeedTab.anime => l10n.feedTabAnime, _FeedTab.real => l10n.feedTabReal, _FeedTab.recommend => l10n.feedTabForYou, diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 40b220d..763eb84 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -52,9 +52,21 @@ void main() { final bar = tester.widget(barFinder); final decoration = bar.decoration as BoxDecoration; final border = decoration.border as Border; - expect(decoration.color, AppColors.bar.withValues(alpha: 0.95)); - expect(border.top.color, AppColors.border); - expect(border.top.color, isNot(Colors.white)); + // 首页 feed 用深色导航。 + expect(decoration.color, const Color(0xE6000000)); + expect(border.top.color, Colors.white12); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('tab-theater')), + matching: find.text('Theater'), + ), + ) + .style + ?.color, + Colors.white70, + ); expect( tester .widget( @@ -88,6 +100,10 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); + final lightBar = tester.widget(barFinder); + final lightDecoration = lightBar.decoration as BoxDecoration; + expect(lightDecoration.color, AppColors.bar.withValues(alpha: 0.95)); + expect((lightDecoration.border as Border).top.color, AppColors.border); await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 79bc0c4..148bdf8 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -77,7 +77,7 @@ void main() { expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget); }); - testWidgets('should_show_coming_soon_for_danmaku_and_following_tab', ( + testWidgets('should_show_coming_soon_for_danmaku_and_anime_tab', ( tester, ) async { await _pumpApp(tester); @@ -86,7 +86,7 @@ void main() { await tester.pump(); expect(find.text('Coming soon'), findsOneWidget); - await tester.tap(find.byKey(const Key('home-feed-tab-follow'))); + await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); await tester.pump(); expect(find.text('Coming soon'), findsOneWidget); }); From 321c62664ca973c224d9722b1ad38b0225dd602a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:56:51 +0800 Subject: [PATCH 059/124] =?UTF-8?q?fix(flutter):=20=E7=B2=BE=E4=BF=AE?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=20feed=20=E4=BA=A4=E4=BA=92=E4=B8=8E?= =?UTF-8?q?=E5=BA=95=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 2 - lib/l10n/app_id.arb | 2 - lib/l10n/app_localizations.dart | 18 +- lib/l10n/app_localizations_en.dart | 9 +- lib/l10n/app_localizations_id.dart | 9 +- lib/l10n/app_localizations_zh.dart | 9 +- lib/l10n/app_zh.arb | 2 - lib/pages/home/home_view.dart | 13 +- lib/pages/player/player_view.dart | 16 +- lib/pages/player/player_view_actions.dart | 19 -- .../player/widgets/home_feed_overlay.dart | 85 ++--- .../widgets/home_feed_overlay_parts.dart | 320 +++++++----------- .../player/widgets/interaction_rail.dart | 99 ++++++ lib/pages/player/widgets/player_overlay.dart | 55 ++- .../player/widgets/player_overlay_parts.dart | 115 ------- test/home/home_preview_layout_test.dart | 82 ++++- test/home/k28_a_navigation_test.dart | 20 +- 17 files changed, 427 insertions(+), 448 deletions(-) create mode 100644 lib/pages/player/widgets/interaction_rail.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bd73cbc..0894619 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,8 +29,6 @@ "romance": "Romance", "actionGenre": "Action", "filter": "Filter", - "danmaku": "Danmaku", - "watchFullscreen": "Full screen", "seriesMeta": "Series · {episodes} eps | {viewers} watching", "@seriesMeta": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 8ae2c4c..2549d49 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -29,8 +29,6 @@ "romance": "Romantis", "actionGenre": "Aksi", "filter": "Filter", - "danmaku": "Danmaku", - "watchFullscreen": "Layar penuh", "seriesMeta": "Serial · {episodes} eps | {viewers} menonton", "watchFullSeries": "Tonton serial lengkap · {episodes} eps", "feedTabAnime": "Anime", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1da0b30..e902046 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -274,18 +274,6 @@ abstract class AppLocalizations { /// **'Filter'** String get filter; - /// No description provided for @danmaku. - /// - /// In en, this message translates to: - /// **'Danmaku'** - String get danmaku; - - /// No description provided for @watchFullscreen. - /// - /// In en, this message translates to: - /// **'Full screen'** - String get watchFullscreen; - /// No description provided for @seriesMeta. /// /// In en, this message translates to: @@ -2734,6 +2722,12 @@ abstract class AppLocalizations { /// **'Clear-screen playback'** String get clearPlayback; + /// No description provided for @danmaku. + /// + /// In en, this message translates to: + /// **'Danmaku'** + String get danmaku; + /// No description provided for @danmakuUnavailable. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a0b04dc..6c47931 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -98,12 +98,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get filter => 'Filter'; - @override - String get danmaku => 'Danmaku'; - - @override - String get watchFullscreen => 'Full screen'; - @override String seriesMeta(String episodes, String viewers) { return 'Series · $episodes eps | $viewers watching'; @@ -1424,6 +1418,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get clearPlayback => 'Clear-screen playback'; + @override + String get danmaku => 'Danmaku'; + @override String get danmakuUnavailable => 'Coming soon'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index f0d6374..4a4f89a 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -98,12 +98,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get filter => 'Filter'; - @override - String get danmaku => 'Komentar layar'; - - @override - String get watchFullscreen => 'Layar penuh'; - @override String seriesMeta(String episodes, String viewers) { return 'Serial · $episodes eps | $viewers menonton'; @@ -1424,6 +1418,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get clearPlayback => 'Putar tanpa kontrol'; + @override + String get danmaku => 'Komentar layar'; + @override String get danmakuUnavailable => 'Belum tersedia'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1f82f34..6e53bfa 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -95,12 +95,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get filter => '筛选'; - @override - String get danmaku => '弹幕'; - - @override - String get watchFullscreen => '全屏观看'; - @override String seriesMeta(String episodes, String viewers) { return '系列剧 · 共$episodes集 | 共$viewers人在追'; @@ -1400,6 +1394,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get clearPlayback => '清屏播放'; + @override + String get danmaku => '弹幕'; + @override String get danmakuUnavailable => '暂未开放'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 039959f..dd70f0c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -29,8 +29,6 @@ "romance": "都市爱情", "actionGenre": "动作", "filter": "筛选", - "danmaku": "弹幕", - "watchFullscreen": "全屏观看", "seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追", "watchFullSeries": "观看完整剧集 · 全{episodes}集", "feedTabAnime": "漫剧", diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 084ecae..a03c011 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -73,6 +73,7 @@ class HomePage extends StatelessWidget { return _HomeTabs( key: ValueKey(catalog.dramas.first.id), dramas: catalog.dramas, + onRefreshFeed: catalog.load, ); } } @@ -97,9 +98,14 @@ class HomeFailureView extends StatelessWidget { /// 四个根页面共享同一路由并保持各自状态。 class _HomeTabs extends StatefulWidget { - const _HomeTabs({super.key, required this.dramas}); + const _HomeTabs({ + super.key, + required this.dramas, + required this.onRefreshFeed, + }); final List dramas; + final VoidCallback onRefreshFeed; @override State<_HomeTabs> createState() => _HomeTabsState(); @@ -138,7 +144,7 @@ class _HomeTabsState extends State<_HomeTabs> { }, child: Scaffold( key: logic.scaffoldKey, - backgroundColor: Colors.transparent, + backgroundColor: logic.tab == 0 ? Colors.black : Colors.transparent, body: _body(context), bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n), ), @@ -165,6 +171,9 @@ class _HomeTabsState extends State<_HomeTabs> { drama: widget.dramas.first, initialIndex: 0, feedDramas: widget.dramas, + onOpenFullPlayer: (drama, episode) => + logic.playDrama(context, drama, episodeId: episode.id), + onRefreshFeed: widget.onRefreshFeed, onDramaChanged: logic.onDramaChanged, onImmersiveChanged: logic.onImmersiveChanged, onSearch: () => logic.select(1, search: true), diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index ccf5d93..4d3ff00 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -55,6 +55,8 @@ class PlayerPage extends StatefulWidget { this.feedDramas, this.onDramaChanged, this.onImmersiveChanged, + this.onOpenFullPlayer, + this.onRefreshFeed, this.wallet, this.downloads, }); @@ -73,6 +75,8 @@ class PlayerPage extends StatefulWidget { final List? feedDramas; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; + final void Function(Drama drama, Episode episode)? onOpenFullPlayer; + final VoidCallback? onRefreshFeed; final WalletController? wallet; final DownloadController? downloads; @@ -411,12 +415,20 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onShare: () => logic.shareDrama(context, drama), onEpisodes: () => logic.showEpisodes(context), onDetail: () => unawaited(logic.openDetail(context)), - onFullscreen: () => - unawaited(_openFullscreen(context, drama, episode)), + onOpenFullPlayer: () => + widget.onOpenFullPlayer?.call(drama, episode), + onRefreshFeed: () => widget.onRefreshFeed?.call(), onDownload: () => requestEpisodeDownload(context, episode.id), onClear: () => setState(() => _chromeVisible = false), onSearch: widget.onSearch ?? () => logic.showDiscover(context), + onScrubStart: ready + ? () => logic.beginScrub(videoPort!) + : null, + onSeek: ready + ? (position) => + logic.seekTo(context, videoPort!, position) + : null, onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 52ecf84..3f9fd81 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -43,25 +43,6 @@ extension _PlayerViewActions on _PlayerPageState { ); } - /// 首页 feed 的「全屏观看」:以当前剧集为起点打开全屏播放页。 - Future _openFullscreen( - BuildContext context, - Drama drama, - Episode episode, - ) async { - final index = drama.episodes.indexWhere((item) => item.id == episode.id); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlayerPage( - drama: drama, - initialIndex: index < 0 ? 0 : index, - wallet: widget.wallet, - downloads: widget.downloads, - ), - ), - ); - } - Future _openUnlockSheet( BuildContext context, int index, diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index a6cd45b..939109f 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -9,6 +9,7 @@ import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; +import 'interaction_rail.dart'; part 'home_feed_overlay_parts.dart'; @@ -16,7 +17,6 @@ const Color _overlayPill = Color(0x66000000); const Color _fullBarColor = Color(0xCC000000); const double _topIconSize = 36; const double _topIconGlyph = 22; -const double _pillHeight = 34; const double _tabGap = 22; const double _tabFontSize = 15; const double _underlineWidth = 18; @@ -24,9 +24,9 @@ const double _underlineHeight = 2; const double _titleFontSize = 17; const double _metaFontSize = 12; const double _tagFontSize = 11; -const double _railIconSize = 22; const double _fullBarHeight = 48; const double _progressHeight = 2; +const double _progressHitHeight = 20; const int _maxTags = 4; final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -46,10 +46,13 @@ class HomeFeedOverlay extends StatefulWidget { required this.onShare, required this.onEpisodes, required this.onDetail, - required this.onFullscreen, + required this.onOpenFullPlayer, + required this.onRefreshFeed, required this.onDownload, required this.onClear, required this.onSearch, + this.onScrubStart, + this.onSeek, this.likeCount, this.favoriteCount, this.commentCount, @@ -73,11 +76,14 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onShare; final VoidCallback onEpisodes; final VoidCallback onDetail; - final VoidCallback onFullscreen; + final VoidCallback onOpenFullPlayer; + final VoidCallback onRefreshFeed; final VoidCallback onDownload; final VoidCallback onClear; final VoidCallback onSearch; final VoidCallback? onPip; + final VoidCallback? onScrubStart; + final ValueChanged? onSeek; @override State createState() => _HomeFeedOverlayState(); @@ -145,13 +151,12 @@ class _HomeFeedOverlayState extends State { padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), child: Row( children: [ - _TopIconButton( - key: const Key('home-feed-menu'), - tooltip: widget.l10n.more, - icon: Icons.menu_rounded, + _RefreshButton( + tooltip: widget.l10n.retry, dimension: _topIconSize, glyphSize: _topIconGlyph, - onPressed: () => _openMore(context), + onTap: widget.onRefreshFeed, + onLongPress: () => _openMore(context), ), Expanded( child: Center( @@ -205,45 +210,42 @@ class _HomeFeedOverlayState extends State { } Widget _rail() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - _FeedRailItem( + return InteractionRail( + items: [ + InteractionRailItem( key: const Key('rail-save'), - icon: widget.saved ? Icons.star_rounded : Icons.star_border_rounded, + icon: Icons.star_border_rounded, + activeIcon: Icons.star_rounded, + label: widget.l10n.favorite, count: widget.favoriteCount, countKey: const Key('rail-save-count'), - label: widget.l10n.favorite, selected: widget.saved, - iconSize: _railIconSize, onTap: widget.onSave, ), - _FeedRailItem( + InteractionRailItem( key: const Key('rail-comments'), icon: Icons.chat_bubble_outline_rounded, + activeIcon: Icons.chat_bubble_rounded, + label: widget.l10n.comments, count: widget.commentCount, countKey: const Key('rail-comments-count'), - label: widget.l10n.comments, - iconSize: _railIconSize, onTap: widget.onComments, ), - _FeedRailItem( + InteractionRailItem( key: const Key('rail-like'), - icon: widget.liked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, + icon: Icons.favorite_border_rounded, + activeIcon: Icons.favorite_rounded, + label: widget.l10n.like, count: widget.likeCount, countKey: const Key('rail-like-count'), - label: widget.l10n.like, selected: widget.liked, - iconSize: _railIconSize, onTap: widget.onLike, ), - _FeedRailItem( + InteractionRailItem( key: const Key('rail-share'), icon: Icons.ios_share_rounded, + activeIcon: Icons.ios_share_rounded, label: widget.l10n.share, - iconSize: _railIconSize, onTap: widget.onShare, ), ], @@ -257,28 +259,6 @@ class _HomeFeedOverlayState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Row( - children: [ - Flexible( - child: _DanmakuPill( - l10n: widget.l10n, - height: _pillHeight, - background: _overlayPill, - onTap: _showComingSoon, - ), - ), - const SizedBox(width: 12), - Flexible( - child: _FullscreenPill( - l10n: widget.l10n, - height: _pillHeight, - background: _overlayPill, - onTap: widget.onFullscreen, - ), - ), - ], - ), - const SizedBox(height: 8), _seriesMetaRow(), const SizedBox(height: 2), _titleRow(), @@ -405,7 +385,7 @@ class _HomeFeedOverlayState extends State { Widget _fullSeriesBar() { return GestureDetector( key: const Key('home-feed-full-series'), - onTap: widget.onEpisodes, + onTap: widget.onOpenFullPlayer, child: Container( height: _fullBarHeight, color: _fullBarColor, @@ -447,9 +427,12 @@ class _HomeFeedOverlayState extends State { left: 0, right: 0, bottom: 0, - child: _FeedProgressTrack( + child: _FeedProgressBar( controller: widget.controller, height: _progressHeight, + hitHeight: _progressHitHeight, + onScrubStart: widget.onScrubStart, + onSeek: widget.onSeek, ), ), ], diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index aed95ef..8e2485e 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -58,6 +58,45 @@ class _TopIconButton extends StatelessWidget { } } +/// 刷新入口:点击重新加载 feed,长按仍可打开更多(清屏等)。 +class _RefreshButton extends StatelessWidget { + const _RefreshButton({ + required this.tooltip, + required this.dimension, + required this.glyphSize, + required this.onTap, + required this.onLongPress, + }); + + final String tooltip; + final double dimension; + final double glyphSize; + final VoidCallback onTap; + final VoidCallback onLongPress; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: GestureDetector( + key: const Key('home-feed-refresh'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + onLongPress: onLongPress, + child: SizedBox.square( + dimension: dimension, + child: Icon( + Icons.refresh_rounded, + size: glyphSize, + color: Colors.white, + shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ), + ); + } +} + class _FeedTabItem extends StatelessWidget { const _FeedTabItem({ required this.tab, @@ -111,217 +150,100 @@ class _FeedTabItem extends StatelessWidget { } } -class _FeedRailItem extends StatelessWidget { - const _FeedRailItem({ - super.key, - required this.icon, - required this.label, - required this.iconSize, - required this.onTap, - this.count, - this.countKey, - this.selected = false, - }); - - final IconData icon; - final String label; - final double iconSize; - final VoidCallback onTap; - final int? count; - final Key? countKey; - final bool selected; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Semantics( - button: true, - label: label, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: SizedBox( - width: 48, - child: Column( - children: [ - Icon( - icon, - size: iconSize, - color: selected ? AppColors.blue : Colors.white, - shadows: const [Shadow(blurRadius: 6, color: Colors.black)], - ), - const SizedBox(height: 2), - Text( - count == null ? label : formatInteractionCount(count!), - key: countKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - shadows: [Shadow(blurRadius: 6, color: Colors.black)], - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _FeedPill extends StatelessWidget { - const _FeedPill({ +/// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。 +class _FeedProgressBar extends StatefulWidget { + const _FeedProgressBar({ + required this.controller, required this.height, - required this.background, - required this.child, + required this.hitHeight, + this.onScrubStart, + this.onSeek, }); - final double height; - final Color background; - final Widget child; - - @override - Widget build(BuildContext context) { - return Container( - height: height, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(999), - ), - child: Center(child: child), - ); - } -} - -class _DanmakuPill extends StatelessWidget { - const _DanmakuPill({ - required this.l10n, - required this.height, - required this.background, - required this.onTap, - }); - - final AppLocalizations l10n; - final double height; - final Color background; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - key: const Key('home-feed-danmaku'), - onTap: onTap, - child: _FeedPill( - height: height, - background: background, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - '弹', - style: TextStyle(color: Colors.white, fontSize: 12), - ), - const SizedBox(width: 6), - Flexible( - child: Text( - l10n.danmaku, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white70, fontSize: 12), - ), - ), - ], - ), - ), - ); - } -} - -class _FullscreenPill extends StatelessWidget { - const _FullscreenPill({ - required this.l10n, - required this.height, - required this.background, - required this.onTap, - }); - - final AppLocalizations l10n; - final double height; - final Color background; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - key: const Key('home-feed-fullscreen'), - onTap: onTap, - child: _FeedPill( - height: height, - background: background, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.fullscreen_rounded, color: Colors.white, size: 14), - const SizedBox(width: 4), - Flexible( - child: Text( - l10n.watchFullscreen, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 12), - ), - ), - ], - ), - ), - ); - } -} - -class _FeedProgressTrack extends StatelessWidget { - const _FeedProgressTrack({required this.controller, required this.height}); - final ValueListenable? controller; final double height; + final double hitHeight; + final VoidCallback? onScrubStart; + final ValueChanged? onSeek; + + @override + State<_FeedProgressBar> createState() => _FeedProgressBarState(); +} + +class _FeedProgressBarState extends State<_FeedProgressBar> { + double? _preview; @override Widget build(BuildContext context) { - final source = controller; - if (source == null) return _track(0); - return ValueListenableBuilder( - valueListenable: source, - builder: (context, value, _) { - final total = value.duration.inMilliseconds; - final fraction = total <= 0 - ? 0.0 - : (value.position.inMilliseconds / total).clamp(0.0, 1.0); - return _track(fraction.toDouble()); + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final source = widget.controller; + final track = source == null + ? _track(0, width) + : ValueListenableBuilder( + valueListenable: source, + builder: (context, value, _) { + final total = value.duration.inMilliseconds; + final played = total <= 0 + ? 0.0 + : (value.position.inMilliseconds / total) + .clamp(0.0, 1.0) + .toDouble(); + return _track(_preview ?? played, width); + }, + ); + return GestureDetector( + key: const Key('home-feed-progress'), + behavior: HitTestBehavior.opaque, + onTapDown: (details) { + widget.onScrubStart?.call(); + _commit(details.localPosition.dx / width); + }, + onHorizontalDragStart: (details) { + widget.onScrubStart?.call(); + _update(details.localPosition.dx / width); + }, + onHorizontalDragUpdate: (details) => + _update(details.localPosition.dx / width), + onHorizontalDragEnd: (_) => _commit(_preview), + onHorizontalDragCancel: () => setState(() => _preview = null), + child: SizedBox( + height: widget.hitHeight, + child: Align(alignment: Alignment.bottomCenter, child: track), + ), + ); }, ); } - Widget _track(double fraction) { - return LayoutBuilder( - builder: (context, constraints) => SizedBox( - height: height, - child: Stack( - children: [ - Container( - width: constraints.maxWidth, - height: height, - color: Colors.white24, - ), - Container( - width: constraints.maxWidth * fraction, - height: height, - color: AppColors.blue, - ), - ], - ), + void _update(double fraction) { + setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble()); + } + + void _commit(double? fraction) { + if (fraction == null) return; + final total = widget.controller?.value.duration.inMilliseconds ?? 0; + if (total > 0) { + widget.onSeek?.call( + Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()), + ); + } + setState(() => _preview = null); + } + + Widget _track(double fraction, double width) { + return SizedBox( + height: widget.height, + child: Stack( + children: [ + Container(width: width, height: widget.height, color: Colors.white24), + Container( + width: width * fraction, + height: widget.height, + color: AppColors.blue, + ), + ], ), ); } diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart new file mode 100644 index 0000000..1411bc3 --- /dev/null +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -0,0 +1,99 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/formatting/count_format.dart'; +import '../../../core/theme/app_colors.dart'; + +const double _buttonSize = 34; +const double _iconSize = 24; +const double _itemGap = 18; +const Color _buttonBackground = Color(0x80000000); + +/// 互动栏单项:图标、计数与点击。 +class InteractionRailItem { + const InteractionRailItem({ + required this.key, + required this.icon, + required this.activeIcon, + required this.label, + required this.onTap, + this.count, + this.countKey, + this.selected = false, + }); + + final Key key; + final IconData icon; + final IconData activeIcon; + final String label; + final VoidCallback onTap; + final int? count; + final Key? countKey; + final bool selected; +} + +/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 +class InteractionRail extends StatelessWidget { + const InteractionRail({super.key, required this.items}); + + final List items; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < items.length; index++) ...[ + if (index > 0) const SizedBox(height: _itemGap), + _RailButton(item: items[index]), + ], + ], + ); + } +} + +class _RailButton extends StatelessWidget { + const _RailButton({required this.item}); + + final InteractionRailItem item; + + @override + Widget build(BuildContext context) { + return Column( + key: item.key, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + dimension: _buttonSize, + child: IconButton( + onPressed: item.onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: _buttonBackground, + foregroundColor: item.selected ? AppColors.blue : Colors.white, + ), + icon: Icon( + item.selected ? item.activeIcon : item.icon, + size: _iconSize, + ), + ), + ), + const SizedBox(height: 2), + Text( + item.count == null ? item.label : formatInteractionCount(item.count!), + key: item.countKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + height: 1, + fontSize: 11, + fontWeight: FontWeight.w600, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ], + ); + } +} diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index bcf586f..910cc13 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -1,12 +1,12 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; -import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; import 'player_center_toggle.dart'; +import 'interaction_rail.dart'; import 'player_overlay_models.dart'; import 'seek_progress_bar.dart'; @@ -14,8 +14,6 @@ part 'player_overlay_parts.dart'; const double _topButtonSize = 36; const double _topBarHeight = 58; -const double _railButtonSize = 34; -const double _railGap = 18; const double _boostHintIconSize = 16; const double _boostHintFontSize = 13; const List _playerTextShadows = [ @@ -144,17 +142,46 @@ class PlayerPlaybackOverlay extends StatelessWidget { return Positioned( right: AppDimens.spaceMd, top: MediaQuery.sizeOf(context).height * (compact ? 0.42 : 0.55), - child: _Rail( - l10n: data.l10n, - liked: data.liked, - saved: data.saved, - likeCount: data.likeCount, - favoriteCount: data.favoriteCount, - commentCount: data.commentCount, - onLike: actions.onLike, - onComments: actions.onComments, - onSave: actions.onSave, - onShare: actions.onShare, + child: InteractionRail( + key: const Key('player-interaction-rail'), + items: [ + InteractionRailItem( + key: const Key('player-rail-save'), + icon: Icons.star_border_rounded, + activeIcon: Icons.star_rounded, + label: data.l10n.favorite, + count: data.favoriteCount, + countKey: const Key('player-rail-save-count'), + selected: data.saved, + onTap: actions.onSave, + ), + InteractionRailItem( + key: const Key('player-rail-comments'), + icon: Icons.chat_bubble_outline_rounded, + activeIcon: Icons.chat_bubble_rounded, + label: data.l10n.comments, + count: data.commentCount, + countKey: const Key('player-rail-comments-count'), + onTap: actions.onComments, + ), + InteractionRailItem( + key: const Key('player-rail-like'), + icon: Icons.favorite_border_rounded, + activeIcon: Icons.favorite_rounded, + label: data.l10n.like, + count: data.likeCount, + countKey: const Key('player-rail-like-count'), + selected: data.liked, + onTap: actions.onLike, + ), + InteractionRailItem( + key: const Key('player-rail-share'), + icon: Icons.ios_share_rounded, + activeIcon: Icons.ios_share_rounded, + label: data.l10n.share, + onTap: actions.onShare, + ), + ], ), ); } diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index c78b216..383365d 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -105,121 +105,6 @@ class _BottomIconButton extends StatelessWidget { } } -class _Rail extends StatelessWidget { - const _Rail({ - required this.l10n, - required this.liked, - required this.saved, - required this.likeCount, - required this.favoriteCount, - required this.commentCount, - required this.onLike, - required this.onComments, - required this.onSave, - required this.onShare, - }); - - final AppLocalizations l10n; - final bool liked; - final bool saved; - final int? likeCount; - final int? favoriteCount; - final int? commentCount; - final VoidCallback onLike; - final VoidCallback onComments; - final VoidCallback onSave; - final VoidCallback onShare; - - @override - Widget build(BuildContext context) { - return Column( - key: const Key('player-interaction-rail'), - children: [ - _RailButton( - key: const Key('player-rail-save'), - icon: saved ? Icons.star_rounded : Icons.star_border_rounded, - label: favoriteCount == null - ? l10n.favorite - : formatInteractionCount(favoriteCount!), - active: saved, - onTap: onSave, - ), - const SizedBox(height: _railGap), - _RailButton( - key: const Key('player-rail-comments'), - icon: Icons.chat_bubble_outline_rounded, - label: commentCount == null - ? l10n.comments - : formatInteractionCount(commentCount!), - onTap: onComments, - ), - const SizedBox(height: _railGap), - _RailButton( - key: const Key('player-rail-like'), - icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - label: likeCount == null - ? l10n.like - : formatInteractionCount(likeCount!), - active: liked, - onTap: onLike, - ), - const SizedBox(height: _railGap), - _RailButton( - key: const Key('player-rail-share'), - icon: Icons.ios_share_rounded, - label: l10n.share, - onTap: onShare, - ), - ], - ); - } -} - -class _RailButton extends StatelessWidget { - const _RailButton({ - super.key, - required this.icon, - required this.label, - required this.onTap, - this.active = false, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - final bool active; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - SizedBox.square( - dimension: _railButtonSize, - child: IconButton( - onPressed: onTap, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: const Color(0x80000000), - foregroundColor: active ? AppColors.blue : AppColors.onPrimary, - ), - icon: Icon(icon, size: 24), - ), - ), - Text( - label, - maxLines: 1, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 11, - height: 1, - fontWeight: FontWeight.w600, - ), - ), - ], - ); - } -} - class _EpisodeSynopsis extends StatefulWidget { const _EpisodeSynopsis({ super.key, diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 8a5aacc..cc195f9 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -14,6 +14,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; +import 'package:video_player/video_player.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; @@ -112,7 +113,8 @@ void main() { onShare: () {}, onEpisodes: () {}, onDetail: () {}, - onFullscreen: () {}, + onOpenFullPlayer: () {}, + onRefreshFeed: () {}, onDownload: () {}, onClear: () {}, onSearch: () {}, @@ -145,4 +147,82 @@ void main() { '12', ); }); + + testWidgets('should_refresh_seek_and_open_player_from_feed_actions', ( + tester, + ) async { + var refreshed = 0; + var opened = 0; + Duration? seeked; + final controller = ValueNotifier( + VideoPlayerValue( + duration: const Duration(seconds: 100), + position: const Duration(seconds: 10), + isInitialized: true, + ), + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) => Scaffold( + body: HomeFeedOverlay( + l10n: AppLocalizations.of(context)!, + drama: const Drama( + id: 'action-drama', + title: 'Action Drama', + synopsis: 'synopsis', + posterUrl: '', + totalEpisodes: 8, + viewCount: 1200, + ), + episode: const Episode( + id: 'action-episode', + number: 1, + title: 'Episode 1', + isFree: true, + ), + controller: controller, + liked: false, + saved: false, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onEpisodes: () {}, + onDetail: () {}, + onOpenFullPlayer: () => opened++, + onRefreshFeed: () => refreshed++, + onDownload: () {}, + onClear: () {}, + onSearch: () {}, + onScrubStart: () {}, + onSeek: (value) => seeked = value, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('home-feed-refresh'))); + await tester.pump(); + expect(refreshed, 1); + + await tester.drag( + find.byKey(const Key('home-feed-progress')), + const Offset(120, 0), + ); + await tester.pumpAndSettle(); + expect(seeked, isNotNull); + expect(seeked!.inSeconds, greaterThan(0)); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pump(); + expect(opened, 1); + }); } diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 148bdf8..6fcf919 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -65,27 +65,29 @@ void main() { tester.widget(find.byKey(const Key('rail-comments-count'))).data, '3.2万', ); + expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget); + expect(find.byKey(const Key('home-feed-danmaku')), findsNothing); + expect(find.byKey(const Key('home-feed-fullscreen')), findsNothing); + expect( + tester.widget(find.byType(Scaffold).first).backgroundColor, + Colors.black, + ); expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); }); - testWidgets('should_open_episode_sheet_from_full_series_bar', (tester) async { + testWidgets('should_open_full_player_from_full_series_bar', (tester) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget); + final player = tester.widget(find.byType(PlayerPage).last); + expect(player.homeMode, isFalse); }); - testWidgets('should_show_coming_soon_for_danmaku_and_anime_tab', ( - tester, - ) async { + testWidgets('should_show_coming_soon_for_anime_tab', (tester) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('home-feed-danmaku'))); - await tester.pump(); - expect(find.text('Coming soon'), findsOneWidget); - await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); await tester.pump(); expect(find.text('Coming soon'), findsOneWidget); From 521ebfc1ab26ad3f48d525836134cd84065d2c1e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:58:26 +0800 Subject: [PATCH 060/124] =?UTF-8?q?fix(flutter):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=E5=BC=B9=E5=B9=95=E6=96=87?= =?UTF-8?q?=E6=A1=88=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 1 - lib/l10n/app_id.arb | 1 - lib/l10n/app_localizations.dart | 6 ------ lib/l10n/app_localizations_en.dart | 3 --- lib/l10n/app_localizations_id.dart | 3 --- lib/l10n/app_localizations_zh.dart | 3 --- lib/l10n/app_zh.arb | 1 - 7 files changed, 18 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0894619..ee56f24 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -627,7 +627,6 @@ "qualityUnavailable": "Unavailable", "notInterested": "Not interested", "clearPlayback": "Clear-screen playback", - "danmaku": "Danmaku", "danmakuUnavailable": "Coming soon", "danmakuSettings": "Danmaku settings", "offlineCache": "Offline cache", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 2549d49..4cba543 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -604,7 +604,6 @@ "qualityUnavailable": "Tidak tersedia", "notInterested": "Tidak tertarik", "clearPlayback": "Putar tanpa kontrol", - "danmaku": "Komentar layar", "danmakuUnavailable": "Belum tersedia", "danmakuSettings": "Pengaturan komentar layar", "offlineCache": "Cache offline", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e902046..776aa99 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2722,12 +2722,6 @@ abstract class AppLocalizations { /// **'Clear-screen playback'** String get clearPlayback; - /// No description provided for @danmaku. - /// - /// In en, this message translates to: - /// **'Danmaku'** - String get danmaku; - /// No description provided for @danmakuUnavailable. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6c47931..02eb36f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1418,9 +1418,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get clearPlayback => 'Clear-screen playback'; - @override - String get danmaku => 'Danmaku'; - @override String get danmakuUnavailable => 'Coming soon'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 4a4f89a..5be257a 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1418,9 +1418,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get clearPlayback => 'Putar tanpa kontrol'; - @override - String get danmaku => 'Komentar layar'; - @override String get danmakuUnavailable => 'Belum tersedia'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6e53bfa..10f6cae 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1394,9 +1394,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get clearPlayback => '清屏播放'; - @override - String get danmaku => '弹幕'; - @override String get danmakuUnavailable => '暂未开放'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index dd70f0c..85c6b4b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -566,7 +566,6 @@ "qualityUnavailable": "暂不可用", "notInterested": "不感兴趣", "clearPlayback": "清屏播放", - "danmaku": "弹幕", "danmakuUnavailable": "暂未开放", "danmakuSettings": "弹幕设置", "offlineCache": "离线缓存", From 636b369bd49b5b77199970542d808e63c11581c6 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:05:24 +0800 Subject: [PATCH 061/124] =?UTF-8?q?fix(flutter):=20=E9=A6=96=E9=A1=B5=20fe?= =?UTF-8?q?ed=20=E5=8E=BB=E5=BA=95=E8=89=B2=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=95=B4=E9=9B=86=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/home/home_view.dart | 21 +++- lib/pages/player/player_view.dart | 9 +- .../player/widgets/home_feed_overlay.dart | 117 +++++++----------- .../widgets/home_feed_overlay_parts.dart | 48 +++++-- test/home/home_preview_layout_test.dart | 27 +++- test/home/k28_a_navigation_test.dart | 46 ++++++- 6 files changed, 171 insertions(+), 97 deletions(-) diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index a03c011..5d2bb9d 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; @@ -32,10 +33,7 @@ class HomePage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.light(), - child: DecoratedBox( - decoration: const BoxDecoration(gradient: AppTheme.pageGradient), - child: _buildHome(context, catalog, l10n), - ), + child: _buildHome(context, catalog, l10n), ); } @@ -136,7 +134,8 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _scaffold(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return PopScope( + final feed = logic.tab == 0; + final content = PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; @@ -144,11 +143,21 @@ class _HomeTabsState extends State<_HomeTabs> { }, child: Scaffold( key: logic.scaffoldKey, - backgroundColor: logic.tab == 0 ? Colors.black : Colors.transparent, + backgroundColor: Colors.transparent, body: _body(context), bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n), ), ); + return AnnotatedRegion( + value: feed ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, + child: feed + ? content + : DecoratedBox( + key: const Key('home-page-gradient'), + decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + child: content, + ), + ); } Widget _body(BuildContext context) { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 4d3ff00..5cc01b9 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -75,7 +75,7 @@ class PlayerPage extends StatefulWidget { final List? feedDramas; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; - final void Function(Drama drama, Episode episode)? onOpenFullPlayer; + final Future Function(Drama drama, Episode episode)? onOpenFullPlayer; final VoidCallback? onRefreshFeed; final WalletController? wallet; final DownloadController? downloads; @@ -415,8 +415,11 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onShare: () => logic.shareDrama(context, drama), onEpisodes: () => logic.showEpisodes(context), onDetail: () => unawaited(logic.openDetail(context)), - onOpenFullPlayer: () => - widget.onOpenFullPlayer?.call(drama, episode), + onOpenFullPlayer: () async { + await widget.onOpenFullPlayer?.call(drama, episode); + if (!mounted || !widget.active) return; + logic.session.setUserWantsPlay(true); + }, onRefreshFeed: () => widget.onRefreshFeed?.call(), onDownload: () => requestEpisodeDownload(context, episode.id), onClear: () => setState(() => _chromeVisible = false), diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 939109f..7f6d5ad 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -14,7 +14,6 @@ import 'interaction_rail.dart'; part 'home_feed_overlay_parts.dart'; const Color _overlayPill = Color(0x66000000); -const Color _fullBarColor = Color(0xCC000000); const double _topIconSize = 36; const double _topIconGlyph = 22; const double _tabGap = 22; @@ -24,7 +23,7 @@ const double _underlineHeight = 2; const double _titleFontSize = 17; const double _metaFontSize = 12; const double _tagFontSize = 11; -const double _fullBarHeight = 48; +const double _fullBarHeight = 44; const double _progressHeight = 2; const double _progressHitHeight = 20; const int _maxTags = 4; @@ -96,56 +95,25 @@ class _HomeFeedOverlayState extends State { Widget build(BuildContext context) { return SafeArea( bottom: false, - child: Stack( + child: Column( children: [ - Positioned( - top: 0, - left: 0, - right: 0, - child: IgnorePointer(child: _gradient(top: true)), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: IgnorePointer(child: _gradient(top: false)), - ), - Column( - children: [ - _header(context), - Expanded( - child: Align( - alignment: const Alignment(1.0, 0.45), - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 10, 16), - child: _rail(), - ), - ), + _header(context), + Expanded( + child: Align( + alignment: const Alignment(1.0, 0.45), + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 10, 16), + child: _rail(), ), - _bottomInfo(), - _fullSeriesBar(), - ], + ), ), + _bottomInfo(), + _fullSeriesBar(), ], ), ); } - Widget _gradient({required bool top}) { - return Container( - height: top ? 140 : 360, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: top ? Alignment.topCenter : Alignment.bottomCenter, - end: top ? Alignment.bottomCenter : Alignment.topCenter, - colors: top - ? const [Color(0xB3000000), Color(0x00000000)] - : const [Color(0xE6000000), Color(0x00000000)], - ), - ), - ); - } - Widget _header(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), @@ -386,41 +354,42 @@ class _HomeFeedOverlayState extends State { return GestureDetector( key: const Key('home-feed-full-series'), onTap: widget.onOpenFullPlayer, - child: Container( + child: SizedBox( height: _fullBarHeight, - color: _fullBarColor, child: Stack( children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - children: [ - const Icon( - Icons.play_arrow_rounded, - color: Colors.white, - size: 14, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - widget.l10n.watchFullSeries( - '${widget.drama.episodeCount}', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w600, + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 14, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + widget.l10n.watchFullSeries( + '${widget.drama.episodeCount}', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), ), ), - ), - const Icon( - Icons.chevron_right_rounded, - color: Colors.white, - size: 18, - ), - ], + const Icon( + Icons.chevron_right_rounded, + color: Colors.white, + size: 18, + ), + ], + ), ), ), Positioned( diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 8e2485e..d1214d7 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -2,6 +2,9 @@ part of 'home_feed_overlay.dart'; +const double _thumbSize = 6; +const double _thumbActiveSize = 10; + /// 顶部频道 tab:漫剧点击提示即将上线。 enum _FeedTab { anime, @@ -208,10 +211,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _update(details.localPosition.dx / width), onHorizontalDragEnd: (_) => _commit(_preview), onHorizontalDragCancel: () => setState(() => _preview = null), - child: SizedBox( - height: widget.hitHeight, - child: Align(alignment: Alignment.bottomCenter, child: track), - ), + child: track, ); }, ); @@ -233,15 +233,43 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { } Widget _track(double fraction, double width) { + final active = _preview != null; + final size = active ? _thumbActiveSize : _thumbSize; + final left = (width * fraction - size / 2) + .clamp(0.0, width - size) + .toDouble(); return SizedBox( - height: widget.height, + height: widget.hitHeight, child: Stack( + clipBehavior: Clip.none, children: [ - Container(width: width, height: widget.height, color: Colors.white24), - Container( - width: width * fraction, - height: widget.height, - color: AppColors.blue, + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container(height: widget.height, color: Colors.white24), + ), + Positioned( + left: 0, + bottom: 0, + child: Container( + width: width * fraction, + height: widget.height, + color: AppColors.blue, + ), + ), + Positioned( + left: left, + bottom: 0, + child: Container( + key: const Key('home-feed-progress-thumb'), + width: size, + height: size, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), ), ], ), diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index cc195f9..4ac0487 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -209,17 +209,38 @@ void main() { ); await tester.pumpAndSettle(); + final bar = tester.getRect(find.byKey(const Key('home-feed-full-series'))); + expect(bar.height, 44); + expect( + tester.getRect(find.textContaining('Watch full series')).center.dy, + closeTo(bar.center.dy, 2), + ); + expect( + tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))), + const Size(6, 6), + ); + await tester.tap(find.byKey(const Key('home-feed-refresh'))); await tester.pump(); expect(refreshed, 1); - await tester.drag( - find.byKey(const Key('home-feed-progress')), - const Offset(120, 0), + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('home-feed-progress'))), ); + await gesture.moveBy(const Offset(120, 0)); + await tester.pump(); + expect( + tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))), + const Size(10, 10), + ); + await gesture.up(); await tester.pumpAndSettle(); expect(seeked, isNotNull); expect(seeked!.inSeconds, greaterThan(0)); + expect( + tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))), + const Size(6, 6), + ); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pump(); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 6fcf919..fd5433d 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; @@ -70,8 +71,10 @@ void main() { expect(find.byKey(const Key('home-feed-fullscreen')), findsNothing); expect( tester.widget(find.byType(Scaffold).first).backgroundColor, - Colors.black, + Colors.transparent, ); + expect(find.byKey(const Key('home-page-gradient')), findsNothing); + expect(_hasLightStatusBar(tester), isTrue); expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); }); @@ -93,6 +96,19 @@ void main() { expect(find.text('Coming soon'), findsOneWidget); }); + testWidgets('should_restore_light_theme_after_leaving_feed_tab', ( + tester, + ) async { + await _pumpApp(tester); + expect(_hasLightStatusBar(tester), isTrue); + + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-page-gradient')), findsOneWidget); + expect(_hasLightStatusBar(tester), isFalse); + }); + testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async { await _pumpApp(tester); final before = tester @@ -108,6 +124,28 @@ void main() { expect(after, isNot(before)); }); + testWidgets('should_resume_feed_after_returning_from_full_player', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(PlayerPage).last).homeMode, + isFalse, + ); + + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + }); + testWidgets('should_hide_feed_when_switching_to_theater', (tester) async { await _pumpApp(tester); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); @@ -196,6 +234,12 @@ const _tabKeys = [ Key('tab-mine'), ]; +bool _hasLightStatusBar(WidgetTester tester) => tester + .widgetList>( + find.byType(AnnotatedRegion), + ) + .any((region) => region.value.statusBarIconBrightness == Brightness.light); + Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), From 82fe237c2478657f589000f24ddc499f5a133261 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:11:42 +0800 Subject: [PATCH 062/124] =?UTF-8?q?fix(flutter):=20=E9=A6=96=E9=A1=B5=20fe?= =?UTF-8?q?ed=20=E9=9D=99=E9=BB=98=E5=88=B7=E6=96=B0=E4=B8=8E=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=9D=A1=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/catalog_controller.dart | 13 ++++ lib/pages/home/home_view.dart | 4 +- lib/pages/player/player_view.dart | 10 ++- .../player/widgets/home_feed_overlay.dart | 11 ++- .../widgets/home_feed_overlay_parts.dart | 27 ++++--- .../home/catalog_controller_refresh_test.dart | 72 +++++++++++++++++++ test/home/home_preview_layout_test.dart | 44 ++++++++++++ test/home/k28_a_navigation_test.dart | 26 +++++++ 8 files changed, 190 insertions(+), 17 deletions(-) create mode 100644 test/home/catalog_controller_refresh_test.dart diff --git a/lib/core/data/services/catalog_controller.dart b/lib/core/data/services/catalog_controller.dart index 34cccdb..dfab65e 100644 --- a/lib/core/data/services/catalog_controller.dart +++ b/lib/core/data/services/catalog_controller.dart @@ -44,6 +44,19 @@ class CatalogController extends ChangeNotifier { } } + /// 静默刷新首页聚合:成功替换数据,失败返回 false。 + /// + /// 不触碰 [loading]/[error],因此页面不会切到全屏 loading 或清空旧数据。 + Future refresh() async { + try { + feed = await repository.fetchHomeFeed(); + notifyListeners(); + return true; + } catch (_) { + return false; + } + } + /// 需要完整剧集时补一次详情;已加载过则直接返回,避免重复请求。 Future requireDetail(Drama drama) async { if (drama.detailLoaded) return drama; diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 5d2bb9d..c174448 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -71,7 +71,7 @@ class HomePage extends StatelessWidget { return _HomeTabs( key: ValueKey(catalog.dramas.first.id), dramas: catalog.dramas, - onRefreshFeed: catalog.load, + onRefreshFeed: catalog.refresh, ); } } @@ -103,7 +103,7 @@ class _HomeTabs extends StatefulWidget { }); final List dramas; - final VoidCallback onRefreshFeed; + final Future Function() onRefreshFeed; @override State<_HomeTabs> createState() => _HomeTabsState(); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 5cc01b9..953e7cc 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -76,7 +76,7 @@ class PlayerPage extends StatefulWidget { final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; final Future Function(Drama drama, Episode episode)? onOpenFullPlayer; - final VoidCallback? onRefreshFeed; + final Future Function()? onRefreshFeed; final WalletController? wallet; final DownloadController? downloads; @@ -420,7 +420,13 @@ class _PlayerPageState extends State with WidgetsBindingObserver { if (!mounted || !widget.active) return; logic.session.setUserWantsPlay(true); }, - onRefreshFeed: () => widget.onRefreshFeed?.call(), + onRefreshFeed: () async { + final ok = await widget.onRefreshFeed?.call() ?? true; + if (!context.mounted || ok) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.catalogError))); + }, onDownload: () => requestEpisodeDownload(context, episode.id), onClear: () => setState(() => _chromeVisible = false), onSearch: diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 7f6d5ad..d92e950 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -14,6 +14,7 @@ import 'interaction_rail.dart'; part 'home_feed_overlay_parts.dart'; const Color _overlayPill = Color(0x66000000); +const Color _fullBarBackground = Color(0x4D000000); const double _topIconSize = 36; const double _topIconGlyph = 22; const double _tabGap = 22; @@ -25,7 +26,7 @@ const double _metaFontSize = 12; const double _tagFontSize = 11; const double _fullBarHeight = 44; const double _progressHeight = 2; -const double _progressHitHeight = 20; +const double _progressHitHeight = 24; const int _maxTags = 4; final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -100,7 +101,7 @@ class _HomeFeedOverlayState extends State { _header(context), Expanded( child: Align( - alignment: const Alignment(1.0, 0.45), + alignment: const Alignment(1.0, 0.68), child: Padding( padding: const EdgeInsets.fromLTRB(0, 0, 10, 16), child: _rail(), @@ -358,6 +359,12 @@ class _HomeFeedOverlayState extends State { height: _fullBarHeight, child: Stack( children: [ + Positioned.fill( + child: Container( + key: const Key('home-feed-full-series-bg'), + color: _fullBarBackground, + ), + ), Positioned.fill( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14), diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index d1214d7..5e4898a 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -4,6 +4,8 @@ part of 'home_feed_overlay.dart'; const double _thumbSize = 6; const double _thumbActiveSize = 10; +const double _trackActiveHeight = 4; +const double _lineCenterFromBottom = 6; /// 顶部频道 tab:漫剧点击提示即将上线。 enum _FeedTab { @@ -199,10 +201,6 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { return GestureDetector( key: const Key('home-feed-progress'), behavior: HitTestBehavior.opaque, - onTapDown: (details) { - widget.onScrubStart?.call(); - _commit(details.localPosition.dx / width); - }, onHorizontalDragStart: (details) { widget.onScrubStart?.call(); _update(details.localPosition.dx / width); @@ -233,11 +231,14 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { } Widget _track(double fraction, double width) { - final active = _preview != null; - final size = active ? _thumbActiveSize : _thumbSize; + final dragging = _preview != null; + final trackHeight = dragging ? _trackActiveHeight : widget.height; + final size = dragging ? _thumbActiveSize : _thumbSize; final left = (width * fraction - size / 2) .clamp(0.0, width - size) .toDouble(); + final lineBottom = _lineCenterFromBottom - trackHeight / 2; + final thumbBottom = _lineCenterFromBottom - size / 2; return SizedBox( height: widget.hitHeight, child: Stack( @@ -246,21 +247,25 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { Positioned( left: 0, right: 0, - bottom: 0, - child: Container(height: widget.height, color: Colors.white24), + bottom: lineBottom, + child: Container( + key: const Key('home-feed-progress-track'), + height: trackHeight, + color: Colors.white24, + ), ), Positioned( left: 0, - bottom: 0, + bottom: lineBottom, child: Container( width: width * fraction, - height: widget.height, + height: trackHeight, color: AppColors.blue, ), ), Positioned( left: left, - bottom: 0, + bottom: thumbBottom, child: Container( key: const Key('home-feed-progress-thumb'), width: size, diff --git a/test/home/catalog_controller_refresh_test.dart b/test/home/catalog_controller_refresh_test.dart new file mode 100644 index 0000000..87a36f1 --- /dev/null +++ b/test/home/catalog_controller_refresh_test.dart @@ -0,0 +1,72 @@ +// 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/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/services/catalog_controller.dart'; + +void main() { + test('should_keep_state_when_silent_refresh_fails', () async { + final repository = _FakeCatalog(fail: true); + final controller = CatalogController(repository); + const oldFeed = HomeFeed(); + controller.feed = oldFeed; + + final ok = await controller.refresh(); + + expect(ok, isFalse); + expect(controller.feed, same(oldFeed)); + expect(controller.loading, isFalse); + expect(controller.error, isNull); + }); + + test( + 'should_replace_feed_without_touching_loading_when_refresh_succeeds', + () async { + final repository = _FakeCatalog(); + final controller = CatalogController(repository); + controller.feed = const HomeFeed(); + final next = HomeFeed( + sections: [ + FeedSection(code: 'new', title: 'New', dramas: const [_drama]), + ], + ); + repository.next = next; + + final ok = await controller.refresh(); + + expect(ok, isTrue); + expect(controller.feed, same(next)); + expect(controller.loading, isFalse); + expect(controller.error, isNull); + }, + ); +} + +class _FakeCatalog implements CatalogRepository { + _FakeCatalog({this.fail = false}); + + bool fail; + HomeFeed next = const HomeFeed(); + + @override + Future fetchHomeFeed() async { + if (fail) throw StateError('offline'); + return next; + } + + @override + Future fetchDiscoverFeed() async => const HomeFeed(); + + @override + Future fetchDrama(String dramaId) async => _drama; +} + +const _drama = Drama( + id: 'refresh-drama', + title: 'Refresh Drama', + synopsis: 'synopsis', + posterUrl: '', + detailLoaded: true, +); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 4ac0487..62f3fd4 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -211,6 +211,12 @@ void main() { final bar = tester.getRect(find.byKey(const Key('home-feed-full-series'))); expect(bar.height, 44); + expect( + tester + .widget(find.byKey(const Key('home-feed-full-series-bg'))) + .color, + const Color(0x4D000000), + ); expect( tester.getRect(find.textContaining('Watch full series')).center.dy, closeTo(bar.center.dy, 2), @@ -219,6 +225,23 @@ void main() { tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))), const Size(6, 6), ); + expect( + tester.getSize(find.byKey(const Key('home-feed-progress'))).height, + greaterThanOrEqualTo(24), + ); + expect( + tester + .getRect(find.byKey(const Key('home-feed-progress-thumb'))) + .center + .dy, + closeTo( + tester + .getRect(find.byKey(const Key('home-feed-progress-track'))) + .center + .dy, + 0.5, + ), + ); await tester.tap(find.byKey(const Key('home-feed-refresh'))); await tester.pump(); @@ -233,6 +256,23 @@ void main() { tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))), const Size(10, 10), ); + expect( + tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, + 4, + ); + expect( + tester + .getRect(find.byKey(const Key('home-feed-progress-thumb'))) + .center + .dy, + closeTo( + tester + .getRect(find.byKey(const Key('home-feed-progress-track'))) + .center + .dy, + 0.5, + ), + ); await gesture.up(); await tester.pumpAndSettle(); expect(seeked, isNotNull); @@ -241,6 +281,10 @@ void main() { tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))), const Size(6, 6), ); + expect( + tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, + 2, + ); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pump(); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index fd5433d..549714d 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -96,6 +96,32 @@ void main() { expect(find.text('Coming soon'), findsOneWidget); }); + testWidgets('should_refresh_feed_without_blank_loading', (tester) async { + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('home-feed-refresh'))); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + }); + + testWidgets('should_keep_feed_rail_above_bottom_panels', (tester) async { + await _pumpApp(tester); + final railTop = tester.getCenter(find.byKey(const Key('rail-save'))).dy; + final railBottom = tester + .getRect(find.byKey(const Key('rail-share'))) + .bottom; + final infoTop = tester + .getRect(find.byKey(const Key('home-feed-series-meta'))) + .top; + + expect(railTop, greaterThan(844 * 0.35)); + expect(railBottom, lessThanOrEqualTo(infoTop)); + }); + testWidgets('should_restore_light_theme_after_leaving_feed_tab', ( tester, ) async { From ea9ea6e60641fce9841c46143dfd53d6d0edd61d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:19:14 +0800 Subject: [PATCH 063/124] =?UTF-8?q?fix(flutter):=20feed=20=E9=95=BF?= =?UTF-8?q?=E6=8C=89=E5=8A=A0=E9=80=9F=E4=B8=8E=E5=88=B7=E6=96=B0=E8=BD=AC?= =?UTF-8?q?=E5=9C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 153 +++++++++--------- .../player/widgets/home_feed_overlay.dart | 108 +++++++++++-- .../widgets/home_feed_overlay_parts.dart | 31 ++-- test/home/home_preview_layout_test.dart | 94 ++++++++++- test/home/k28_a_navigation_test.dart | 34 +++- 5 files changed, 317 insertions(+), 103 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 953e7cc..eb9a665 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -366,83 +366,82 @@ class _PlayerPageState extends State with WidgetsBindingObserver { if (logic.pip || logic.pipPreparing) const SizedBox.expand() else if (widget.homeMode) - _chromeVisible - ? HomeFeedOverlay( - l10n: l10n, - drama: drama, - episode: episode, - controller: ready ? videoPort!.controller : null, - liked: interactions.isLiked(drama.id), - likeCount: - interactions.likeCount(drama.id) ?? drama.likeCount, - favoriteCount: drama.favoriteCount == null - ? null - : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), - commentCount: drama.commentCount, - saved: context.watch().isSaved(drama.id), - onLike: () async { - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final result = await interactions.toggleDramaLike( - drama.id, - ); - if (!result.isSuccess && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.errorMessage ?? - AppLocalizations.of(context)!.likeFailed, - ), - ), - ); - } - } - }, - onSave: () async { - final favorites = context.read(); - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final ok = await favorites.toggle(drama.id, card: drama); - if (ok || !context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.favoriteToggleFailed)), - ); - } - }, - onComments: () => logic.showComments(context, drama), - onShare: () => logic.shareDrama(context, drama), - onEpisodes: () => logic.showEpisodes(context), - onDetail: () => unawaited(logic.openDetail(context)), - onOpenFullPlayer: () async { - await widget.onOpenFullPlayer?.call(drama, episode); - if (!mounted || !widget.active) return; - logic.session.setUserWantsPlay(true); - }, - onRefreshFeed: () async { - final ok = await widget.onRefreshFeed?.call() ?? true; - if (!context.mounted || ok) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.catalogError))); - }, - onDownload: () => requestEpisodeDownload(context, episode.id), - onClear: () => setState(() => _chromeVisible = false), - onSearch: - widget.onSearch ?? () => logic.showDiscover(context), - onScrubStart: ready - ? () => logic.beginScrub(videoPort!) - : null, - onSeek: ready - ? (position) => - logic.seekTo(context, videoPort!, position) - : null, - onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(logic.enterPip(context)) - : null, - ) - : const SizedBox.shrink() + HomeFeedOverlay( + l10n: l10n, + drama: drama, + episode: episode, + controller: ready ? videoPort!.controller : null, + visible: _chromeVisible, + boosting: _boosting, + onBoostStart: _startBoost, + onBoostEnd: _endBoost, + onTogglePlay: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + liked: interactions.isLiked(drama.id), + likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, + favoriteCount: drama.favoriteCount == null + ? null + : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), + commentCount: drama.commentCount, + saved: context.watch().isSaved(drama.id), + onLike: () async { + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await interactions.toggleDramaLike(drama.id); + if (!result.isSuccess && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.errorMessage ?? + AppLocalizations.of(context)!.likeFailed, + ), + ), + ); + } + } + }, + onSave: () async { + final favorites = context.read(); + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final ok = await favorites.toggle(drama.id, card: drama); + if (ok || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.favoriteToggleFailed)), + ); + } + }, + onComments: () => logic.showComments(context, drama), + onShare: () => logic.shareDrama(context, drama), + onEpisodes: () => logic.showEpisodes(context), + onDetail: () => unawaited(logic.openDetail(context)), + onOpenFullPlayer: () async { + await widget.onOpenFullPlayer?.call(drama, episode); + if (!mounted || !widget.active) return; + logic.session.setUserWantsPlay(true); + }, + onRefreshFeed: () async { + final ok = await widget.onRefreshFeed?.call() ?? true; + if (context.mounted && !ok) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.catalogError))); + } + return ok; + }, + onDownload: () => requestEpisodeDownload(context, episode.id), + onClear: () => setState(() => _chromeVisible = false), + onSearch: widget.onSearch ?? () => logic.showDiscover(context), + onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onSeek: ready + ? (position) => logic.seekTo(context, videoPort!, position) + : null, + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, + ) else PlayerPlaybackOverlay( data: PlayerOverlayData( diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index d92e950..23bd1e7 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -27,6 +27,9 @@ const double _tagFontSize = 11; const double _fullBarHeight = 44; const double _progressHeight = 2; const double _progressHitHeight = 24; +const List _boostTextShadows = [ + Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), +]; const int _maxTags = 4; final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -40,6 +43,8 @@ class HomeFeedOverlay extends StatefulWidget { required this.controller, required this.liked, required this.saved, + required this.visible, + required this.boosting, required this.onLike, required this.onSave, required this.onComments, @@ -51,6 +56,9 @@ class HomeFeedOverlay extends StatefulWidget { required this.onDownload, required this.onClear, required this.onSearch, + required this.onTogglePlay, + required this.onBoostStart, + required this.onBoostEnd, this.onScrubStart, this.onSeek, this.likeCount, @@ -70,6 +78,8 @@ class HomeFeedOverlay extends StatefulWidget { final int? commentCount; final bool saved; final String authorNote; + final bool visible; + final bool boosting; final VoidCallback onLike; final VoidCallback onSave; final VoidCallback onComments; @@ -77,13 +87,16 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onEpisodes; final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; - final VoidCallback onRefreshFeed; + final Future Function() onRefreshFeed; final VoidCallback onDownload; final VoidCallback onClear; final VoidCallback onSearch; + final VoidCallback onTogglePlay; final VoidCallback? onPip; final VoidCallback? onScrubStart; final ValueChanged? onSeek; + final VoidCallback onBoostStart; + final VoidCallback onBoostEnd; @override State createState() => _HomeFeedOverlayState(); @@ -91,30 +104,100 @@ class HomeFeedOverlay extends StatefulWidget { class _HomeFeedOverlayState extends State { _FeedTab _tab = _FeedTab.recommend; + bool _refreshing = false; @override Widget build(BuildContext context) { return SafeArea( bottom: false, - child: Column( + child: Stack( children: [ - _header(context), - Expanded( - child: Align( - alignment: const Alignment(1.0, 0.68), - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 10, 16), - child: _rail(), + Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) => Listener( + onPointerUp: (_) => widget.onBoostEnd(), + onPointerCancel: (_) => widget.onBoostEnd(), + child: GestureDetector( + key: const Key('home-feed-gesture'), + behavior: HitTestBehavior.opaque, + onTap: widget.onTogglePlay, + onLongPressStart: (details) => + _handleLongPressStart(details, constraints.maxWidth), + onLongPressEnd: (_) => widget.onBoostEnd(), + onLongPressCancel: widget.onBoostEnd, + ), ), ), ), - _bottomInfo(), - _fullSeriesBar(), + if (widget.boosting) + Column(children: [const Spacer(), _boostHint(), _fullSeriesBar()]) + else if (widget.visible) + Column( + children: [ + _header(context), + Expanded( + child: Align( + alignment: const Alignment(1.0, 0.80), + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 10, 16), + child: _rail(), + ), + ), + ), + _bottomInfo(), + _fullSeriesBar(), + ], + ), ], ), ); } + void _handleLongPressStart(LongPressStartDetails details, double width) { + final third = width / 3; + final dx = details.localPosition.dx; + if (dx < third || dx > width - third) { + widget.onBoostStart(); + return; + } + _openMore(context); + } + + Widget _boostHint() { + return Padding( + key: const Key('home-feed-speed-boost-hint'), + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.play_arrow_rounded, + color: AppColors.blue, + size: 16, + shadows: _boostTextShadows, + ), + const SizedBox(width: 4), + Text( + widget.l10n.speedBoosting('2.0'), + style: const TextStyle( + color: AppColors.blue, + fontSize: 13, + fontWeight: FontWeight.w700, + shadows: _boostTextShadows, + ), + ), + ], + ), + ); + } + + Future _refresh() async { + if (_refreshing) return; + setState(() => _refreshing = true); + await widget.onRefreshFeed(); + if (mounted) setState(() => _refreshing = false); + } + Widget _header(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), @@ -124,7 +207,8 @@ class _HomeFeedOverlayState extends State { tooltip: widget.l10n.retry, dimension: _topIconSize, glyphSize: _topIconGlyph, - onTap: widget.onRefreshFeed, + refreshing: _refreshing, + onTap: _refresh, onLongPress: () => _openMore(context), ), Expanded( diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 5e4898a..c871980 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -3,9 +3,9 @@ part of 'home_feed_overlay.dart'; const double _thumbSize = 6; -const double _thumbActiveSize = 10; -const double _trackActiveHeight = 4; -const double _lineCenterFromBottom = 6; +const double _thumbActiveSize = 14; +const double _trackActiveHeight = 6; +const double _lineCenterFromBottom = 8; /// 顶部频道 tab:漫剧点击提示即将上线。 enum _FeedTab { @@ -69,6 +69,7 @@ class _RefreshButton extends StatelessWidget { required this.tooltip, required this.dimension, required this.glyphSize, + required this.refreshing, required this.onTap, required this.onLongPress, }); @@ -76,6 +77,7 @@ class _RefreshButton extends StatelessWidget { final String tooltip; final double dimension; final double glyphSize; + final bool refreshing; final VoidCallback onTap; final VoidCallback onLongPress; @@ -90,12 +92,23 @@ class _RefreshButton extends StatelessWidget { onLongPress: onLongPress, child: SizedBox.square( dimension: dimension, - child: Icon( - Icons.refresh_rounded, - size: glyphSize, - color: Colors.white, - shadows: const [Shadow(blurRadius: 6, color: Colors.black)], - ), + child: refreshing + ? const Center( + child: SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + key: Key('home-feed-refreshing'), + strokeWidth: 2, + color: Colors.white, + ), + ), + ) + : Icon( + Icons.refresh_rounded, + size: glyphSize, + color: Colors.white, + shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + ), ), ), ); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 62f3fd4..38bcb81 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -104,6 +106,11 @@ void main() { controller: null, liked: true, saved: false, + visible: true, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, likeCount: 1006, favoriteCount: 32000, commentCount: 12, @@ -114,7 +121,7 @@ void main() { onEpisodes: () {}, onDetail: () {}, onOpenFullPlayer: () {}, - onRefreshFeed: () {}, + onRefreshFeed: () async => true, onDownload: () {}, onClear: () {}, onSearch: () {}, @@ -189,6 +196,11 @@ void main() { controller: controller, liked: false, saved: false, + visible: true, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, onLike: () {}, onSave: () {}, onComments: () {}, @@ -196,7 +208,10 @@ void main() { onEpisodes: () {}, onDetail: () {}, onOpenFullPlayer: () => opened++, - onRefreshFeed: () => refreshed++, + onRefreshFeed: () async { + refreshed++; + return true; + }, onDownload: () {}, onClear: () {}, onSearch: () {}, @@ -254,11 +269,11 @@ void main() { await tester.pump(); expect( tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))), - const Size(10, 10), + const Size(14, 14), ); expect( tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, - 4, + 6, ); expect( tester @@ -290,4 +305,75 @@ void main() { await tester.pump(); expect(opened, 1); }); + + testWidgets('should_show_refresh_spinner_until_refresh_finishes', ( + tester, + ) async { + final completer = Completer(); + var calls = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) => Scaffold( + body: HomeFeedOverlay( + l10n: AppLocalizations.of(context)!, + drama: const Drama( + id: 'refresh-drama', + title: 'Refresh Drama', + synopsis: 'synopsis', + posterUrl: '', + totalEpisodes: 8, + ), + episode: const Episode( + id: 'refresh-episode', + number: 1, + title: 'Episode 1', + isFree: true, + ), + controller: null, + liked: false, + saved: false, + visible: true, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onEpisodes: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onRefreshFeed: () { + calls++; + return completer.future; + }, + onDownload: () {}, + onClear: () {}, + onSearch: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('home-feed-refresh'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-refreshing')), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-feed-refresh'))); + await tester.pump(); + expect(calls, 1); + + completer.complete(true); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-refreshing')), findsNothing); + expect(find.byIcon(Icons.refresh_rounded), findsOneWidget); + }); } diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 549714d..574b845 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -118,10 +118,42 @@ void main() { .getRect(find.byKey(const Key('home-feed-series-meta'))) .top; - expect(railTop, greaterThan(844 * 0.35)); + expect(railTop, greaterThan(844 * 0.45)); expect(railBottom, lessThanOrEqualTo(infoTop)); }); + testWidgets('should_boost_feed_on_side_long_press', (tester) async { + await _pumpApp(tester); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + + final gesture = await tester.startGesture(const Offset(60, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + + expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsOneWidget); + expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect(find.byKey(const Key('rail-save')), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('rail-save')), findsOneWidget); + }); + + testWidgets('should_open_more_sheet_on_center_long_press', (tester) async { + await _pumpApp(tester); + + final gesture = await tester.startGesture(const Offset(195, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-clear')), findsOneWidget); + }); + testWidgets('should_restore_light_theme_after_leaving_feed_tab', ( tester, ) async { From 7e415f6f4f0da486c1e8f063bf919848906ec9db Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:23:36 +0800 Subject: [PATCH 064/124] =?UTF-8?q?fix(flutter):=20feed=20rail=20=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E5=AF=B9=E9=BD=90=E5=B9=B6=E5=A4=8D=E7=94=A8=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E9=A1=B5=E6=9B=B4=E5=A4=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 4 +- .../player/widgets/home_feed_overlay.dart | 26 ++------ .../widgets/home_feed_overlay_parts.dart | 62 ------------------- test/home/home_preview_layout_test.dart | 3 + test/home/k28_a_navigation_test.dart | 6 +- 5 files changed, 15 insertions(+), 86 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index eb9a665..3708c22 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -434,13 +434,11 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onDownload: () => requestEpisodeDownload(context, episode.id), onClear: () => setState(() => _chromeVisible = false), onSearch: widget.onSearch ?? () => logic.showDiscover(context), + onMore: () => _openMore(context, episode), onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, - onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(logic.enterPip(context)) - : null, ) else PlayerPlaybackOverlay( diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 23bd1e7..70b5e20 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -56,6 +56,7 @@ class HomeFeedOverlay extends StatefulWidget { required this.onDownload, required this.onClear, required this.onSearch, + required this.onMore, required this.onTogglePlay, required this.onBoostStart, required this.onBoostEnd, @@ -65,7 +66,6 @@ class HomeFeedOverlay extends StatefulWidget { this.favoriteCount, this.commentCount, this.authorNote = '', - this.onPip, }); final AppLocalizations l10n; @@ -91,8 +91,8 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onDownload; final VoidCallback onClear; final VoidCallback onSearch; + final VoidCallback onMore; final VoidCallback onTogglePlay; - final VoidCallback? onPip; final VoidCallback? onScrubStart; final ValueChanged? onSeek; final VoidCallback onBoostStart; @@ -137,9 +137,9 @@ class _HomeFeedOverlayState extends State { _header(context), Expanded( child: Align( - alignment: const Alignment(1.0, 0.80), + alignment: Alignment.bottomRight, child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 10, 16), + padding: const EdgeInsets.fromLTRB(0, 0, 10, 12), child: _rail(), ), ), @@ -160,7 +160,7 @@ class _HomeFeedOverlayState extends State { widget.onBoostStart(); return; } - _openMore(context); + widget.onMore(); } Widget _boostHint() { @@ -209,7 +209,7 @@ class _HomeFeedOverlayState extends State { glyphSize: _topIconGlyph, refreshing: _refreshing, onTap: _refresh, - onLongPress: () => _openMore(context), + onLongPress: widget.onMore, ), Expanded( child: Center( @@ -506,18 +506,4 @@ class _HomeFeedOverlayState extends State { context, ).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon))); } - - Future _openMore(BuildContext context) async { - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - builder: (sheetContext) => _FeedMoreSheet( - l10n: widget.l10n, - onDownload: widget.onDownload, - onPip: widget.onPip, - onClear: widget.onClear, - ), - ); - } } diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index c871980..084fd93 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -294,65 +294,3 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { ); } } - -class _FeedMoreSheet extends StatelessWidget { - const _FeedMoreSheet({ - required this.l10n, - required this.onDownload, - required this.onClear, - this.onPip, - }); - - final AppLocalizations l10n; - final VoidCallback onDownload; - final VoidCallback onClear; - final VoidCallback? onPip; - - @override - Widget build(BuildContext context) { - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - key: const Key('home-feed-download'), - leading: const Icon( - Icons.download_outlined, - color: AppColors.secondary, - ), - title: Text(l10n.downloads), - onTap: () { - Navigator.pop(context); - onDownload(); - }, - ), - if (onPip != null) - ListTile( - key: const Key('home-feed-pip'), - leading: const Icon( - Icons.picture_in_picture_alt_outlined, - color: AppColors.secondary, - ), - title: Text(l10n.pictureInPicture), - onTap: () { - Navigator.pop(context); - onPip!(); - }, - ), - ListTile( - key: const Key('home-feed-clear'), - leading: const Icon( - Icons.fullscreen_rounded, - color: AppColors.secondary, - ), - title: Text(l10n.clearScreen), - onTap: () { - Navigator.pop(context); - onClear(); - }, - ), - ], - ), - ); - } -} diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 38bcb81..a79629e 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -125,6 +125,7 @@ void main() { onDownload: () {}, onClear: () {}, onSearch: () {}, + onMore: () {}, ), ), ), @@ -215,6 +216,7 @@ void main() { onDownload: () {}, onClear: () {}, onSearch: () {}, + onMore: () {}, onScrubStart: () {}, onSeek: (value) => seeked = value, ), @@ -356,6 +358,7 @@ void main() { onDownload: () {}, onClear: () {}, onSearch: () {}, + onMore: () {}, ), ), ), diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 574b845..4b417f0 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -120,6 +120,7 @@ void main() { expect(railTop, greaterThan(844 * 0.45)); expect(railBottom, lessThanOrEqualTo(infoTop)); + expect(infoTop - railBottom, lessThanOrEqualTo(20)); }); testWidgets('should_boost_feed_on_side_long_press', (tester) async { @@ -151,7 +152,10 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-clear')), findsOneWidget); + expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); + expect(find.byKey(const Key('home-feed-clear')), findsNothing); + expect(find.byKey(const Key('home-feed-download')), findsNothing); + expect(find.byKey(const Key('home-feed-pip')), findsNothing); }); testWidgets('should_restore_light_theme_after_leaving_feed_tab', ( From bc8f3bcab7bb87bf15be9c67f52bda479677b982 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:27:36 +0800 Subject: [PATCH 065/124] =?UTF-8?q?fix(flutter):=20=E5=9B=9E=E5=89=8D?= =?UTF-8?q?=E5=8F=B0=E5=A4=8D=E4=BD=8D=E6=AE=8B=E7=95=99=E7=94=BB=E4=B8=AD?= =?UTF-8?q?=E7=94=BB=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_logic.dart | 8 ++++++++ test/player/player_logic_test.dart | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index c9c94aa..5920184 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -313,6 +313,14 @@ class PlayerLogic extends ChangeNotifier { void onLifecycle(AppLifecycleState state) { if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress()); if (state == AppLifecycleState.resumed) { + // 部分机型退出画中画不会回调 onPipChanged(false):回前台先兜底复位, + // 否则 chromeHidden 恒为 true,播放浮层与首页底栏都会消失。 + if (pip || pipPreparing) { + pip = false; + pipPreparing = false; + notify(); + notifyImmersive(); + } // 签名地址有有效期:回前台时若票据已过期,重新取一张再起播, // 否则会撞上 403(契约 §1.4) final episode = trackedEpisode; diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 44084d9..c0235c2 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -187,6 +187,34 @@ void main() { expect(logic.pip, isFalse); }); + + test('should_clear_stale_pip_when_app_returns_to_foreground', () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory()); + final immersive = []; + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.onImmersiveChanged = immersive.add; + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + // 模拟画中画已进入但退出时没有回调 false。 + logic.onPipChanged(true); + expect(logic.chromeHidden, isTrue); + + logic.onLifecycle(AppLifecycleState.resumed); + + expect(logic.pip, isFalse); + expect(logic.pipPreparing, isFalse); + expect(logic.chromeHidden, isFalse); + expect(immersive.last, isFalse); + + logic.dispose(); + await _drain(logic); + }); }); group('history writes', () { From c87798fb16c0de650179d9430b35cbd0500daef3 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:33:35 +0800 Subject: [PATCH 066/124] =?UTF-8?q?fix(flutter):=20feed=20=E6=B8=85?= =?UTF-8?q?=E5=B1=8F=E4=BF=9D=E7=95=99=E9=A1=B6=E6=A0=8F=E5=B9=B6=E5=AF=BB?= =?UTF-8?q?=E5=9D=80=E6=81=A2=E5=A4=8D=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_logic.dart | 12 +++++- lib/pages/player/player_view.dart | 17 +++----- .../player/widgets/home_feed_overlay.dart | 18 +++----- test/home/k28_a_navigation_test.dart | 43 +++++++++++++++++-- test/player/player_logic_test.dart | 21 +++++++++ 5 files changed, 83 insertions(+), 28 deletions(-) diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 5920184..5c66f9f 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -578,7 +578,11 @@ class PlayerLogic extends ChangeNotifier { await session.playbackSettled; if (scrubbingPlayer != player || session.active != player) return; await player.controller.seekTo(position); - if (session.active == player) await saveCurrentProgress(); + if (session.active == player) { + // 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。 + resumeAfterSeek(); + await saveCurrentProgress(); + } } catch (_) { if (context.mounted && session.active == player) { final l10n = AppLocalizations.of(context)!; @@ -591,6 +595,12 @@ class PlayerLogic extends ChangeNotifier { } } + /// 拖动进度落点后恢复播放;暂停态拖动不应停留在暂停。 + @visibleForTesting + void resumeAfterSeek() { + if (!session.userWantsPlay) session.setUserWantsPlay(true); + } + Future leave(BuildContext context) async { await saveCurrentProgress(); if (context.mounted) Navigator.of(context).pop(); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 3708c22..70a69a4 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -510,17 +510,12 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), if (widget.homeMode && !_chromeVisible) Positioned( - top: 0, - right: 0, - child: SafeArea( - left: false, - bottom: false, - minimum: const EdgeInsets.only(top: 8, right: 12), - child: ExitClearScreenButton( - key: const Key('home-feed-exit-clear-screen'), - tooltip: l10n.exitClearScreen, - onPressed: () => setState(() => _chromeVisible = true), - ), + right: 12, + bottom: 56, + child: ExitClearScreenButton( + key: const Key('home-feed-exit-clear-screen'), + tooltip: l10n.exitClearScreen, + onPressed: () => setState(() => _chromeVisible = true), ), ), if (widget.homeMode && diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 70b5e20..18fd684 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -131,23 +131,17 @@ class _HomeFeedOverlayState extends State { ), if (widget.boosting) Column(children: [const Spacer(), _boostHint(), _fullSeriesBar()]) - else if (widget.visible) + else Column( children: [ _header(context), - Expanded( - child: Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 10, 12), - child: _rail(), - ), - ), - ), - _bottomInfo(), + const Spacer(), + if (widget.visible) _bottomInfo(), _fullSeriesBar(), ], ), + if (widget.visible && !widget.boosting) + Positioned(right: 10, bottom: 56, child: _rail()), ], ), ); @@ -307,7 +301,7 @@ class _HomeFeedOverlayState extends State { Widget _bottomInfo() { return Padding( - padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), + padding: const EdgeInsets.fromLTRB(14, 0, 56, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 4b417f0..bda1d33 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -114,13 +114,48 @@ void main() { final railBottom = tester .getRect(find.byKey(const Key('rail-share'))) .bottom; - final infoTop = tester - .getRect(find.byKey(const Key('home-feed-series-meta'))) + final seriesTop = tester + .getRect(find.byKey(const Key('home-feed-full-series'))) .top; + final titleRight = tester + .getRect(find.byKey(const Key('home-feed-title'))) + .right; + final railLeft = tester.getRect(find.byKey(const Key('rail-save'))).left; expect(railTop, greaterThan(844 * 0.45)); - expect(railBottom, lessThanOrEqualTo(infoTop)); - expect(infoTop - railBottom, lessThanOrEqualTo(20)); + expect(seriesTop - railBottom, closeTo(12, 1)); + expect(titleRight, lessThanOrEqualTo(railLeft + 2)); + }); + + testWidgets('should_keep_feed_header_and_progress_after_clear', ( + tester, + ) async { + await _pumpApp(tester); + final gesture = await tester.startGesture(const Offset(195, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more-clear'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget); + expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget); + expect(find.byKey(const Key('home-feed-search')), findsOneWidget); + expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); + expect(find.byKey(const Key('rail-save')), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + final exit = tester.getRect( + find.byKey(const Key('home-feed-exit-clear-screen')), + ); + expect(exit.right, greaterThan(340)); + expect(exit.center.dy, greaterThan(600)); + + await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('rail-save')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); }); testWidgets('should_boost_feed_on_side_long_press', (tester) async { diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index c0235c2..9c42526 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -215,6 +215,27 @@ void main() { logic.dispose(); await _drain(logic); }); + + test('should_resume_playback_after_seek_when_paused', () async { + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + logic.session.setUserWantsPlay(false); + expect(logic.session.userWantsPlay, isFalse); + + logic.resumeAfterSeek(); + + expect(logic.session.userWantsPlay, isTrue); + logic.dispose(); + await _drain(logic); + }); }); group('history writes', () { From b2c219d6bdeaae37b629ed79315c6cc8315a6692 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:38:09 +0800 Subject: [PATCH 067/124] =?UTF-8?q?fix(flutter):=20=E6=B8=85=E5=B1=8F?= =?UTF-8?q?=E6=80=81=E8=BF=9B=E5=BA=A6=E5=8F=AF=E6=8B=96=E5=8A=A8=E5=B9=B6?= =?UTF-8?q?=E9=95=BF=E6=8C=89=E6=81=A2=E5=A4=8D=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 11 +--- test/home/home_preview_layout_test.dart | 79 +++++++++++++++++++++++++ test/home/k28_a_navigation_test.dart | 22 +++++++ 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 70a69a4..6ee2abb 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -142,6 +142,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { /// 长按两侧:切 2 倍速并清屏,记下进入前的状态用于松手恢复。 void _startBoost() { if (_boosting) return; + // 暂停时长按两侧先恢复播放,再进入 2 倍速。 + if (!logic.session.userWantsPlay) logic.session.setUserWantsPlay(true); setState(() { _boosting = true; _speedBeforeBoost = _playbackSpeed; @@ -499,15 +501,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ? null : () => _openUnlockSheet(context, index, episode), ), - if (widget.homeMode && !_chromeVisible) - Positioned.fill( - child: GestureDetector( - key: const Key('home-feed-video-tap'), - behavior: HitTestBehavior.opaque, - onTap: () => - logic.session.setUserWantsPlay(!logic.session.userWantsPlay), - ), - ), if (widget.homeMode && !_chromeVisible) Positioned( right: 12, diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index a79629e..b281dfc 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -379,4 +379,83 @@ void main() { expect(find.byKey(const Key('home-feed-refreshing')), findsNothing); expect(find.byIcon(Icons.refresh_rounded), findsOneWidget); }); + + testWidgets('should_seek_from_series_bar_when_overlay_is_cleared', ( + tester, + ) async { + Duration? seeked; + final controller = ValueNotifier( + VideoPlayerValue( + duration: const Duration(seconds: 100), + position: const Duration(seconds: 10), + isInitialized: true, + ), + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) => Scaffold( + body: HomeFeedOverlay( + l10n: AppLocalizations.of(context)!, + drama: const Drama( + id: 'clear-drama', + title: 'Clear Drama', + synopsis: 'synopsis', + posterUrl: '', + totalEpisodes: 8, + ), + episode: const Episode( + id: 'clear-episode', + number: 1, + title: 'Episode 1', + isFree: true, + ), + controller: controller, + liked: false, + saved: false, + visible: false, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onEpisodes: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onRefreshFeed: () async => true, + onDownload: () {}, + onClear: () {}, + onSearch: () {}, + onMore: () {}, + onScrubStart: () {}, + onSeek: (value) => seeked = value, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget); + expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('home-feed-progress'))), + ); + await gesture.moveBy(const Offset(80, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(seeked, isNotNull); + expect(seeked!.inSeconds, greaterThan(0)); + }); } diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index bda1d33..bff3ca7 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -178,6 +178,28 @@ void main() { expect(find.byKey(const Key('rail-save')), findsOneWidget); }); + testWidgets('should_resume_playback_when_boosting_while_paused', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + + final gesture = await tester.startGesture(const Offset(60, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + + expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsOneWidget); + expect(find.textContaining('2.0'), findsWidgets); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + }); + testWidgets('should_open_more_sheet_on_center_long_press', (tester) async { await _pumpApp(tester); From 551da77e67eb3445aa0623b548f5b7596118daab Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:41:27 +0800 Subject: [PATCH 068/124] =?UTF-8?q?fix(flutter):=20=E7=BC=A9=E5=B0=8F?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E6=9A=82=E5=81=9C=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/player_center_toggle.dart | 4 ++-- test/player/player_center_toggle_test.dart | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/pages/player/widgets/player_center_toggle.dart b/lib/pages/player/widgets/player_center_toggle.dart index 4ea318f..cfb03c9 100644 --- a/lib/pages/player/widgets/player_center_toggle.dart +++ b/lib/pages/player/widgets/player_center_toggle.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -const double _toggleSize = 84; -const double _iconSize = 48; +const double _toggleSize = 68; +const double _iconSize = 36; const Color _toggleBackground = Colors.black26; const List _iconShadows = [ Shadow(color: Colors.black54, blurRadius: 12), diff --git a/test/player/player_center_toggle_test.dart b/test/player/player_center_toggle_test.dart index fdcc6d2..94b051a 100644 --- a/test/player/player_center_toggle_test.dart +++ b/test/player/player_center_toggle_test.dart @@ -6,7 +6,7 @@ import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; void main() { - testWidgets('should_use_one_shared_84px_translucent_toggle_style', ( + testWidgets('should_use_one_shared_68px_translucent_toggle_style', ( tester, ) async { await tester.pumpWidget( @@ -34,7 +34,7 @@ void main() { Key('test-center-toggle-a'), Key('player-play-toggle'), ]) { - expect(tester.getSize(find.byKey(key)), const Size(84, 84)); + expect(tester.getSize(find.byKey(key)), const Size(68, 68)); // 圆形底改为半透明黑色,不再是完全透明或蓝色实底。 final container = tester.widget( find.descendant(of: find.byKey(key), matching: find.byType(Container)), @@ -45,7 +45,7 @@ void main() { final icon = tester.widget( find.descendant(of: find.byKey(key), matching: find.byType(Icon)), ); - expect(icon.size, 48); + expect(icon.size, 36); expect(icon.color, AppColors.onPrimary); expect(icon.shadows, isNotNull); expect(icon.shadows, isNotEmpty); From 9175a7f253a26714b0e502bb0ead8a8b8e32e1c1 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:46:08 +0800 Subject: [PATCH 069/124] =?UTF-8?q?fix(flutter):=20=E9=A6=96=E9=A1=B5=20fe?= =?UTF-8?q?ed=20=E9=A1=B6=E6=A0=8F=E5=9B=BA=E5=AE=9A=E6=82=AC=E6=B5=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 55 +++- .../player/widgets/home_feed_header.dart | 275 ++++++++++++++++++ .../player/widgets/home_feed_overlay.dart | 117 +------- .../widgets/home_feed_overlay_parts.dart | 161 ---------- test/home/home_preview_layout_test.dart | 63 +--- test/home/k28_a_navigation_test.dart | 15 + 6 files changed, 341 insertions(+), 345 deletions(-) create mode 100644 lib/pages/player/widgets/home_feed_header.dart diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 6ee2abb..6b77976 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -23,6 +23,7 @@ import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; +import 'widgets/home_feed_header.dart'; import 'widgets/home_feed_overlay.dart'; import 'widgets/exit_clear_screen_button.dart'; import 'widgets/player_center_toggle.dart'; @@ -187,6 +188,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { Widget _buildPlayer(BuildContext context) { final interactions = context.watch(); + final l10n = AppLocalizations.of(context)!; final indices = logic.visibleIndices; final pages = logic.pages; final content = AnimatedBuilder( @@ -205,21 +207,55 @@ class _PlayerPageState extends State with WidgetsBindingObserver { _buildEpisode(context, indices[position], interactions), ), ); + final body = Stack( + fit: StackFit.expand, + children: [ + content, + if (widget.homeMode && !_boosting && !logic.pip && !logic.pipPreparing) + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: HomeFeedHeader( + l10n: l10n, + onRefresh: () async { + final ok = await widget.onRefreshFeed?.call() ?? true; + if (context.mounted && !ok) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.catalogError))); + } + return ok; + }, + onSearch: widget.onSearch ?? () => logic.showDiscover(context), + onMore: _openCurrentMore, + ), + ), + ), + ], + ); return AnnotatedRegion( value: SystemUiOverlayStyle.dark, child: Theme( data: AppTheme.light(), child: widget.embedded - ? content + ? body : Scaffold( key: const Key('player-scaffold'), backgroundColor: Colors.transparent, - body: content, + body: body, ), ), ); } + void _openCurrentMore() { + final entry = logic.queue.entryAt(logic.session.currentIndex); + _openMore(context, entry.episode); + } + Widget _buildEpisode( BuildContext context, int index, @@ -417,26 +453,13 @@ class _PlayerPageState extends State with WidgetsBindingObserver { }, onComments: () => logic.showComments(context, drama), onShare: () => logic.shareDrama(context, drama), - onEpisodes: () => logic.showEpisodes(context), onDetail: () => unawaited(logic.openDetail(context)), onOpenFullPlayer: () async { await widget.onOpenFullPlayer?.call(drama, episode); if (!mounted || !widget.active) return; logic.session.setUserWantsPlay(true); }, - onRefreshFeed: () async { - final ok = await widget.onRefreshFeed?.call() ?? true; - if (context.mounted && !ok) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.catalogError))); - } - return ok; - }, - onDownload: () => requestEpisodeDownload(context, episode.id), - onClear: () => setState(() => _chromeVisible = false), - onSearch: widget.onSearch ?? () => logic.showDiscover(context), - onMore: () => _openMore(context, episode), + onMore: _openCurrentMore, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) diff --git a/lib/pages/player/widgets/home_feed_header.dart b/lib/pages/player/widgets/home_feed_header.dart new file mode 100644 index 0000000..fad82ac --- /dev/null +++ b/lib/pages/player/widgets/home_feed_header.dart @@ -0,0 +1,275 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../l10n/app_localizations.dart'; + +const double _topIconSize = 36; +const double _topIconGlyph = 22; +const double _tabGap = 22; +const double _tabFontSize = 15; +const double _underlineWidth = 18; +const double _underlineHeight = 2; + +/// 首页 feed 固定顶栏:刷新 / 漫剧·真人剧·推荐 / 搜索。 +class HomeFeedHeader extends StatefulWidget { + const HomeFeedHeader({ + super.key, + required this.l10n, + required this.onRefresh, + required this.onSearch, + required this.onMore, + }); + + final AppLocalizations l10n; + final Future Function() onRefresh; + final VoidCallback onSearch; + final VoidCallback onMore; + + @override + State createState() => _HomeFeedHeaderState(); +} + +class _HomeFeedHeaderState extends State { + _FeedTab _tab = _FeedTab.recommend; + bool _refreshing = false; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), + child: Row( + children: [ + _RefreshButton( + tooltip: widget.l10n.retry, + dimension: _topIconSize, + glyphSize: _topIconGlyph, + refreshing: _refreshing, + onTap: _refresh, + onLongPress: widget.onMore, + ), + Expanded( + child: Center( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var index = 0; index < _FeedTab.values.length; index++) + _tabSlot(_FeedTab.values[index], index), + ], + ), + ), + ), + ), + _TopIconButton( + key: const Key('home-feed-search'), + tooltip: widget.l10n.search, + icon: Icons.search_rounded, + dimension: _topIconSize, + glyphSize: _topIconGlyph, + onPressed: widget.onSearch, + ), + ], + ), + ); + } + + Widget _tabSlot(_FeedTab tab, int index) { + final item = _FeedTabItem( + tab: tab, + label: tab.label(widget.l10n), + selected: _tab == tab, + fontSize: _tabFontSize, + underlineWidth: _underlineWidth, + underlineHeight: _underlineHeight, + onTap: () => _selectTab(tab), + ); + if (index == 0) return item; + return Padding( + padding: const EdgeInsets.only(left: _tabGap), + child: item, + ); + } + + void _selectTab(_FeedTab tab) { + if (tab.comingSoon) { + _showComingSoon(); + return; + } + setState(() => _tab = tab); + } + + Future _refresh() async { + if (_refreshing) return; + setState(() => _refreshing = true); + await widget.onRefresh(); + if (mounted) setState(() => _refreshing = false); + } + + void _showComingSoon() { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon))); + } +} + +/// 顶部频道 tab:漫剧点击提示即将上线。 +enum _FeedTab { + anime, + real, + recommend; + + bool get comingSoon => this == _FeedTab.anime; + + String get keyName => switch (this) { + _FeedTab.anime => 'anime', + _FeedTab.real => 'real', + _FeedTab.recommend => 'recommend', + }; + + String label(AppLocalizations l10n) => switch (this) { + _FeedTab.anime => l10n.feedTabAnime, + _FeedTab.real => l10n.feedTabReal, + _FeedTab.recommend => l10n.feedTabForYou, + }; +} + +class _TopIconButton extends StatelessWidget { + const _TopIconButton({ + super.key, + required this.tooltip, + required this.icon, + required this.dimension, + required this.glyphSize, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final double dimension; + final double glyphSize; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: dimension, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon( + icon, + size: glyphSize, + color: Colors.white, + shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ); + } +} + +/// 刷新入口:点击重新加载 feed,长按打开更多(清屏等)。 +class _RefreshButton extends StatelessWidget { + const _RefreshButton({ + required this.tooltip, + required this.dimension, + required this.glyphSize, + required this.refreshing, + required this.onTap, + required this.onLongPress, + }); + + final String tooltip; + final double dimension; + final double glyphSize; + final bool refreshing; + final VoidCallback onTap; + final VoidCallback onLongPress; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: GestureDetector( + key: const Key('home-feed-refresh'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + onLongPress: onLongPress, + child: SizedBox.square( + dimension: dimension, + child: refreshing + ? const Center( + child: SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + key: Key('home-feed-refreshing'), + strokeWidth: 2, + color: Colors.white, + ), + ), + ) + : Icon( + Icons.refresh_rounded, + size: glyphSize, + color: Colors.white, + shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ), + ); + } +} + +class _FeedTabItem extends StatelessWidget { + const _FeedTabItem({ + required this.tab, + required this.label, + required this.selected, + required this.fontSize, + required this.underlineWidth, + required this.underlineHeight, + required this.onTap, + }); + + final _FeedTab tab; + final String label; + final bool selected; + final double fontSize; + final double underlineWidth; + final double underlineHeight; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: Key('home-feed-tab-${tab.keyName}'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + maxLines: 1, + style: TextStyle( + color: selected ? Colors.white : Colors.white70, + fontSize: fontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + const SizedBox(height: 4), + Container( + width: underlineWidth, + height: underlineHeight, + decoration: BoxDecoration( + color: selected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 18fd684..35a3374 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -15,12 +15,6 @@ part 'home_feed_overlay_parts.dart'; const Color _overlayPill = Color(0x66000000); const Color _fullBarBackground = Color(0x4D000000); -const double _topIconSize = 36; -const double _topIconGlyph = 22; -const double _tabGap = 22; -const double _tabFontSize = 15; -const double _underlineWidth = 18; -const double _underlineHeight = 2; const double _titleFontSize = 17; const double _metaFontSize = 12; const double _tagFontSize = 11; @@ -33,7 +27,7 @@ const List _boostTextShadows = [ const int _maxTags = 4; final RegExp _tagSplitter = RegExp(r'[,/、·]'); -/// 首页沉浸式 feed 浮层:顶部频道 tab、右侧互动栏、底部信息与整集进度条。 +/// 首页沉浸式 feed 浮层:手势层、右侧互动栏、底部信息与整集进度条。 class HomeFeedOverlay extends StatefulWidget { const HomeFeedOverlay({ super.key, @@ -49,23 +43,18 @@ class HomeFeedOverlay extends StatefulWidget { required this.onSave, required this.onComments, required this.onShare, - required this.onEpisodes, required this.onDetail, required this.onOpenFullPlayer, - required this.onRefreshFeed, - required this.onDownload, - required this.onClear, - required this.onSearch, - required this.onMore, required this.onTogglePlay, required this.onBoostStart, required this.onBoostEnd, - this.onScrubStart, - this.onSeek, + required this.onMore, this.likeCount, this.favoriteCount, this.commentCount, this.authorNote = '', + this.onScrubStart, + this.onSeek, }); final AppLocalizations l10n; @@ -84,28 +73,20 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onSave; final VoidCallback onComments; final VoidCallback onShare; - final VoidCallback onEpisodes; final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; - final Future Function() onRefreshFeed; - final VoidCallback onDownload; - final VoidCallback onClear; - final VoidCallback onSearch; - final VoidCallback onMore; final VoidCallback onTogglePlay; - final VoidCallback? onScrubStart; - final ValueChanged? onSeek; final VoidCallback onBoostStart; final VoidCallback onBoostEnd; + final VoidCallback onMore; + final VoidCallback? onScrubStart; + final ValueChanged? onSeek; @override State createState() => _HomeFeedOverlayState(); } class _HomeFeedOverlayState extends State { - _FeedTab _tab = _FeedTab.recommend; - bool _refreshing = false; - @override Widget build(BuildContext context) { return SafeArea( @@ -134,7 +115,6 @@ class _HomeFeedOverlayState extends State { else Column( children: [ - _header(context), const Spacer(), if (widget.visible) _bottomInfo(), _fullSeriesBar(), @@ -185,77 +165,6 @@ class _HomeFeedOverlayState extends State { ); } - Future _refresh() async { - if (_refreshing) return; - setState(() => _refreshing = true); - await widget.onRefreshFeed(); - if (mounted) setState(() => _refreshing = false); - } - - Widget _header(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), - child: Row( - children: [ - _RefreshButton( - tooltip: widget.l10n.retry, - dimension: _topIconSize, - glyphSize: _topIconGlyph, - refreshing: _refreshing, - onTap: _refresh, - onLongPress: widget.onMore, - ), - Expanded( - child: Center( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < _FeedTab.values.length; index++) - _tabSlot(_FeedTab.values[index], index), - ], - ), - ), - ), - ), - _TopIconButton( - key: const Key('home-feed-search'), - tooltip: widget.l10n.search, - icon: Icons.search_rounded, - dimension: _topIconSize, - glyphSize: _topIconGlyph, - onPressed: widget.onSearch, - ), - ], - ), - ); - } - - Widget _tabSlot(_FeedTab tab, int index) { - final item = _FeedTabItem( - tab: tab, - label: tab.label(widget.l10n), - selected: _tab == tab, - fontSize: _tabFontSize, - underlineWidth: _underlineWidth, - underlineHeight: _underlineHeight, - onTap: () => _selectTab(tab), - ); - if (index == 0) return item; - return Padding( - padding: const EdgeInsets.only(left: _tabGap), - child: item, - ); - } - - void _selectTab(_FeedTab tab) { - if (tab.comingSoon) { - _showComingSoon(); - return; - } - setState(() => _tab = tab); - } - Widget _rail() { return InteractionRail( items: [ @@ -377,11 +286,7 @@ class _HomeFeedOverlayState extends State { ), ), ), - const Icon( - Icons.chevron_right_rounded, - color: Colors.white, - size: 20, - ), + const Icon(Icons.chevron_right_rounded, color: Colors.white, size: 20), ], ), ); @@ -494,10 +399,4 @@ class _HomeFeedOverlayState extends State { ), ); } - - void _showComingSoon() { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon))); - } } diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 084fd93..c5cc0fe 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -7,167 +7,6 @@ const double _thumbActiveSize = 14; const double _trackActiveHeight = 6; const double _lineCenterFromBottom = 8; -/// 顶部频道 tab:漫剧点击提示即将上线。 -enum _FeedTab { - anime, - real, - recommend; - - bool get comingSoon => this == _FeedTab.anime; - - String get keyName => switch (this) { - _FeedTab.anime => 'anime', - _FeedTab.real => 'real', - _FeedTab.recommend => 'recommend', - }; - - String label(AppLocalizations l10n) => switch (this) { - _FeedTab.anime => l10n.feedTabAnime, - _FeedTab.real => l10n.feedTabReal, - _FeedTab.recommend => l10n.feedTabForYou, - }; -} - -class _TopIconButton extends StatelessWidget { - const _TopIconButton({ - super.key, - required this.tooltip, - required this.icon, - required this.dimension, - required this.glyphSize, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final double dimension; - final double glyphSize; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return SizedBox.square( - dimension: dimension, - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - icon: Icon( - icon, - size: glyphSize, - color: Colors.white, - shadows: const [Shadow(blurRadius: 6, color: Colors.black)], - ), - ), - ); - } -} - -/// 刷新入口:点击重新加载 feed,长按仍可打开更多(清屏等)。 -class _RefreshButton extends StatelessWidget { - const _RefreshButton({ - required this.tooltip, - required this.dimension, - required this.glyphSize, - required this.refreshing, - required this.onTap, - required this.onLongPress, - }); - - final String tooltip; - final double dimension; - final double glyphSize; - final bool refreshing; - final VoidCallback onTap; - final VoidCallback onLongPress; - - @override - Widget build(BuildContext context) { - return Tooltip( - message: tooltip, - child: GestureDetector( - key: const Key('home-feed-refresh'), - behavior: HitTestBehavior.opaque, - onTap: onTap, - onLongPress: onLongPress, - child: SizedBox.square( - dimension: dimension, - child: refreshing - ? const Center( - child: SizedBox.square( - dimension: 18, - child: CircularProgressIndicator( - key: Key('home-feed-refreshing'), - strokeWidth: 2, - color: Colors.white, - ), - ), - ) - : Icon( - Icons.refresh_rounded, - size: glyphSize, - color: Colors.white, - shadows: const [Shadow(blurRadius: 6, color: Colors.black)], - ), - ), - ), - ); - } -} - -class _FeedTabItem extends StatelessWidget { - const _FeedTabItem({ - required this.tab, - required this.label, - required this.selected, - required this.fontSize, - required this.underlineWidth, - required this.underlineHeight, - required this.onTap, - }); - - final _FeedTab tab; - final String label; - final bool selected; - final double fontSize; - final double underlineWidth; - final double underlineHeight; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - key: Key('home-feed-tab-${tab.keyName}'), - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - maxLines: 1, - style: TextStyle( - color: selected ? Colors.white : Colors.white70, - fontSize: fontSize, - fontWeight: selected ? FontWeight.w800 : FontWeight.w600, - shadows: const [Shadow(blurRadius: 6, color: Colors.black)], - ), - ), - const SizedBox(height: 4), - Container( - width: underlineWidth, - height: underlineHeight, - decoration: BoxDecoration( - color: selected ? Colors.white : Colors.transparent, - borderRadius: BorderRadius.circular(1), - ), - ), - ], - ), - ); - } -} - /// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。 class _FeedProgressBar extends StatefulWidget { const _FeedProgressBar({ diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index b281dfc..17224fc 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -15,6 +15,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; import 'package:video_player/video_player.dart'; @@ -118,13 +119,8 @@ void main() { onSave: () {}, onComments: () {}, onShare: () {}, - onEpisodes: () {}, onDetail: () {}, onOpenFullPlayer: () {}, - onRefreshFeed: () async => true, - onDownload: () {}, - onClear: () {}, - onSearch: () {}, onMore: () {}, ), ), @@ -156,10 +152,7 @@ void main() { ); }); - testWidgets('should_refresh_seek_and_open_player_from_feed_actions', ( - tester, - ) async { - var refreshed = 0; + testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async { var opened = 0; Duration? seeked; final controller = ValueNotifier( @@ -206,16 +199,8 @@ void main() { onSave: () {}, onComments: () {}, onShare: () {}, - onEpisodes: () {}, onDetail: () {}, onOpenFullPlayer: () => opened++, - onRefreshFeed: () async { - refreshed++; - return true; - }, - onDownload: () {}, - onClear: () {}, - onSearch: () {}, onMore: () {}, onScrubStart: () {}, onSeek: (value) => seeked = value, @@ -260,10 +245,6 @@ void main() { ), ); - await tester.tap(find.byKey(const Key('home-feed-refresh'))); - await tester.pump(); - expect(refreshed, 1); - final gesture = await tester.startGesture( tester.getCenter(find.byKey(const Key('home-feed-progress'))), ); @@ -321,42 +302,12 @@ void main() { localizationsDelegates: AppLocalizations.localizationsDelegates, home: Builder( builder: (context) => Scaffold( - body: HomeFeedOverlay( + body: HomeFeedHeader( l10n: AppLocalizations.of(context)!, - drama: const Drama( - id: 'refresh-drama', - title: 'Refresh Drama', - synopsis: 'synopsis', - posterUrl: '', - totalEpisodes: 8, - ), - episode: const Episode( - id: 'refresh-episode', - number: 1, - title: 'Episode 1', - isFree: true, - ), - controller: null, - liked: false, - saved: false, - visible: true, - boosting: false, - onTogglePlay: () {}, - onBoostStart: () {}, - onBoostEnd: () {}, - onLike: () {}, - onSave: () {}, - onComments: () {}, - onShare: () {}, - onEpisodes: () {}, - onDetail: () {}, - onOpenFullPlayer: () {}, - onRefreshFeed: () { + onRefresh: () { calls++; return completer.future; }, - onDownload: () {}, - onClear: () {}, onSearch: () {}, onMore: () {}, ), @@ -427,13 +378,8 @@ void main() { onSave: () {}, onComments: () {}, onShare: () {}, - onEpisodes: () {}, onDetail: () {}, onOpenFullPlayer: () {}, - onRefreshFeed: () async => true, - onDownload: () {}, - onClear: () {}, - onSearch: () {}, onMore: () {}, onScrubStart: () {}, onSeek: (value) => seeked = value, @@ -444,7 +390,6 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget); expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index bff3ca7..e4f86c5 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -170,12 +170,14 @@ void main() { expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byKey(const Key('home-feed-title')), findsNothing); expect(find.byKey(const Key('rail-save')), findsNothing); + expect(find.byKey(const Key('home-feed-search')), findsNothing); await gesture.up(); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(find.byKey(const Key('rail-save')), findsOneWidget); + expect(find.byKey(const Key('home-feed-search')), findsOneWidget); }); testWidgets('should_resume_playback_when_boosting_while_paused', ( @@ -243,6 +245,19 @@ void main() { expect(after, isNot(before)); }); + testWidgets('should_keep_feed_header_fixed_when_swiping', (tester) async { + await _pumpApp(tester); + final before = tester.getRect(find.byKey(const Key('home-feed-search'))); + + await tester.drag(find.byType(PageView).first, const Offset(0, -600)); + await tester.pumpAndSettle(); + + final after = tester.getRect(find.byKey(const Key('home-feed-search'))); + expect(after.top, closeTo(before.top, 0.5)); + expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget); + expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget); + }); + testWidgets('should_resume_feed_after_returning_from_full_player', ( tester, ) async { From 5283ab2549995f9e688fc10b2dfc0f86838360d5 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:52:41 +0800 Subject: [PATCH 070/124] =?UTF-8?q?feat(flutter):=20=E5=89=A7=E5=9C=BA?= =?UTF-8?q?=E9=A1=B5=E6=90=9C=E7=B4=A2=E4=B8=8E=E5=90=B8=E9=A1=B6=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E7=B2=BE=E8=87=B4=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 87 +++++++++++++++--- .../widgets/discover_search_field.dart | 90 ++++++++++++++----- test/discover/home_all_dramas_grid_test.dart | 60 +++++++++++++ 3 files changed, 204 insertions(+), 33 deletions(-) diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index dfc986f..9c90a4d 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -31,6 +31,8 @@ const double _categoryTabHeight = 44; const double _categoryTabGap = 20; const double _categorySelectedFontSize = 16; const double _categoryFontSize = 15; +const double _stickyHeaderHeight = 102; +const Duration _stickyHeaderTransition = Duration(milliseconds: 200); /// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 class DiscoverPage extends StatefulWidget { @@ -53,6 +55,7 @@ class DiscoverPage extends StatefulWidget { class _DiscoverPageState extends State { late final DiscoverLogic logic; + bool _headerScrolled = false; bool get embedded => widget.callbacks.embedded; @@ -60,9 +63,17 @@ class _DiscoverPageState extends State { void initState() { super.initState(); logic = DiscoverBinding().create(search: context.read()); + logic.scroll.addListener(_onScroll); if (widget.focusSearch) _focusOnFirstFrame(); } + void _onScroll() { + final scrolled = logic.scroll.hasClients && logic.scroll.offset > 0; + if (scrolled != _headerScrolled) { + setState(() => _headerScrolled = scrolled); + } + } + void _focusOnFirstFrame() { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && widget.active) logic.openSearch(); @@ -82,6 +93,7 @@ class _DiscoverPageState extends State { @override void dispose() { + logic.scroll.removeListener(_onScroll); logic.dispose(); super.dispose(); } @@ -124,20 +136,73 @@ class _DiscoverPageState extends State { } Widget _feed(AppLocalizations l10n, CategoryController categories) { + final showHeader = _showStickyHeader(categories); return GestureDetector( behavior: HitTestBehavior.translucent, onTap: logic.searchFocus.unfocus, - child: RefreshIndicator( - key: const Key('discover-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, - onRefresh: categories.load, - child: ListView( - controller: logic.scroll, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - children: _content(l10n, categories), + child: Stack( + children: [ + Positioned.fill( + child: RefreshIndicator( + key: const Key('discover-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + strokeWidth: 2, + edgeOffset: _stickyHeaderHeight + 8, + onRefresh: categories.load, + child: ListView( + controller: logic.scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.only( + top: showHeader ? _stickyHeaderHeight : 0, + bottom: AppDimens.spaceXl, + ), + children: _content(l10n, categories), + ), + ), + ), + if (showHeader) + Positioned( + top: 0, + left: 0, + right: 0, + child: _stickyHeader(l10n, categories), + ), + ], + ), + ); + } + + bool _showStickyHeader(CategoryController categories) { + if (logic.hasSearchQuery || categories.error != null) return false; + return !(categories.loading && categories.feed.sections.isEmpty); + } + + Widget _stickyHeader(AppLocalizations l10n, CategoryController categories) { + return AnimatedContainer( + key: const Key('theater-sticky-header'), + duration: _stickyHeaderTransition, + curve: Curves.easeOut, + decoration: BoxDecoration( + color: _headerScrolled ? AppColors.ink : Colors.transparent, + border: Border( + bottom: BorderSide( + color: _headerScrolled ? AppColors.border : Colors.transparent, + ), ), + boxShadow: _headerScrolled + ? const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ] + : const [], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [_categoryTabs(l10n, categories), _toolbar(l10n, categories)], ), ); } @@ -183,8 +248,6 @@ class _DiscoverPageState extends State { ? categories.feed.dramas : categories.dramasOf(logic.selectedCategoryId!); return [ - _categoryTabs(l10n, categories), - _toolbar(l10n, categories), if (dramas.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 40), diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index 74efb8d..3ba4707 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -6,10 +6,11 @@ import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; const double _searchHeight = 40; -const double _searchRadius = 18; +const double _searchRadius = 20; +const Duration _focusTransition = Duration(milliseconds: 160); -/// 剧场顶部整行搜索框。 -class DiscoverSearchField extends StatelessWidget { +/// 剧场顶部整行搜索框:白底细边,聚焦时主题色描边与更深的阴影。 +class DiscoverSearchField extends StatefulWidget { const DiscoverSearchField({ super.key, required this.controller, @@ -25,32 +26,84 @@ class DiscoverSearchField extends StatelessWidget { final bool loading; final double horizontalPadding; + @override + State createState() => _DiscoverSearchFieldState(); +} + +class _DiscoverSearchFieldState extends State { + bool _focused = false; + + @override + void initState() { + super.initState(); + widget.focusNode.addListener(_onFocusChanged); + } + + @override + void didUpdateWidget(covariant DiscoverSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + oldWidget.focusNode.removeListener(_onFocusChanged); + widget.focusNode.addListener(_onFocusChanged); + _focused = widget.focusNode.hasFocus; + } + } + + @override + void dispose() { + widget.focusNode.removeListener(_onFocusChanged); + super.dispose(); + } + + void _onFocusChanged() { + final focused = widget.focusNode.hasFocus; + if (focused != _focused) setState(() => _focused = focused); + } + @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), - child: SizedBox( + padding: EdgeInsets.symmetric(horizontal: widget.horizontalPadding), + child: AnimatedContainer( + key: const Key('discover-search-shell'), + duration: _focusTransition, + curve: Curves.easeOut, height: _searchHeight, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_searchRadius), + border: Border.all( + color: _focused ? AppColors.blue : AppColors.border, + ), + boxShadow: [ + BoxShadow( + color: _focused + ? AppColors.blue.withValues(alpha: 0.18) + : Colors.black.withValues(alpha: 0.04), + blurRadius: _focused ? 10 : 4, + offset: const Offset(0, 2), + ), + ], + ), child: TextField( key: const Key('discover-search'), - controller: controller, - focusNode: focusNode, - onChanged: onChanged, - onTapOutside: (_) => focusNode.unfocus(), + controller: widget.controller, + focusNode: widget.focusNode, + onChanged: widget.onChanged, + onTapOutside: (_) => widget.focusNode.unfocus(), textInputAction: TextInputAction.search, style: const TextStyle(color: AppColors.onSurface, fontSize: 14), decoration: InputDecoration( isDense: true, - filled: true, - fillColor: AppColors.card, + filled: false, hintText: AppLocalizations.of(context)!.searchDiscover, - hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12), + hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 13.5), prefixIcon: const Icon( Icons.search_rounded, color: AppColors.subtle, size: 17, ), - suffixIcon: loading + suffixIcon: widget.loading ? const SizedBox.square( dimension: 18, child: Center( @@ -63,14 +116,9 @@ class DiscoverSearchField extends StatelessWidget { minHeight: 0, ), contentPadding: const EdgeInsets.symmetric(horizontal: 12), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_searchRadius), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_searchRadius), - borderSide: const BorderSide(color: AppColors.blue, width: 1), - ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, ), ), ), diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 98f2ae1..4dc97f7 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -10,6 +10,7 @@ import 'package:fruitshorts/core/data/repositories/category_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/theme/app_colors.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'; @@ -170,6 +171,65 @@ void main() { expect(counter.categoryLoads + counter.feedLoads, greaterThan(before)); expect(find.byType(RefreshProgressIndicator), findsNothing); }); + + testWidgets('should_style_search_shell_and_focus_border', (tester) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + BoxDecoration shell() => + tester + .widget( + find.byKey(const Key('discover-search-shell')), + ) + .decoration! + as BoxDecoration; + + expect(shell().borderRadius, BorderRadius.circular(20)); + expect((shell().border! as Border).top.color, AppColors.border); + + await tester.tap(find.byKey(const Key('discover-search'))); + await tester.pump(const Duration(milliseconds: 200)); + + expect((shell().border! as Border).top.color, AppColors.blue); + }); + + testWidgets('should_pin_theater_filters_when_scrolling', (tester) async { + await _pumpTheater( + tester, + categoryRepository: _GridCategoryRepository( + discoverFeed: _tallDiscoverFeed, + ), + ); + BoxDecoration header() => + tester + .widget( + find.byKey(const Key('theater-sticky-header')), + ) + .decoration! + as BoxDecoration; + final before = tester.getRect( + find.byKey(const Key('theater-sticky-header')), + ); + expect(header().color, Colors.transparent); + + await tester.drag(_verticalFeed, const Offset(0, -220)); + await tester.pumpAndSettle(); + + final after = tester.getRect( + find.byKey(const Key('theater-sticky-header')), + ); + expect(after.top, closeTo(before.top, 0.5)); + expect(header().color, AppColors.ink); + }); + + testWidgets('should_use_compact_refresh_indicator', (tester) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + final indicator = tester.widget( + find.byKey(const Key('discover-refresh')), + ); + + expect(indicator.color, AppColors.blue); + expect(indicator.strokeWidth, 2); + expect(indicator.edgeOffset, 110); + }); } final Finder _verticalFeed = find.byWidgetPredicate( From eccd3c6dc1b5a6b17f7647b47e191a778df9db33 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:58:18 +0800 Subject: [PATCH 071/124] =?UTF-8?q?fix(flutter):=20=E5=89=A7=E5=9C=BA?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A1=86=E6=94=B9=E4=B8=BA=E6=B5=85=E7=81=B0?= =?UTF-8?q?=E8=83=B6=E5=9B=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/discover_search_field.dart | 98 ++++++++++--------- test/discover/home_all_dramas_grid_test.dart | 10 +- 2 files changed, 61 insertions(+), 47 deletions(-) diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index 3ba4707..4fd553c 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -5,11 +5,14 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; -const double _searchHeight = 40; -const double _searchRadius = 20; +const double _searchHeight = 44; +const double _searchRadius = 22; +const double _searchPadding = 14; +const double _iconSize = 18; +const double _iconGap = 8; const Duration _focusTransition = Duration(milliseconds: 160); -/// 剧场顶部整行搜索框:白底细边,聚焦时主题色描边与更深的阴影。 +/// 剧场顶部整行搜索框:浅灰胶囊,聚焦时变白并显示主题色描边。 class DiscoverSearchField extends StatefulWidget { const DiscoverSearchField({ super.key, @@ -69,57 +72,62 @@ class _DiscoverSearchFieldState extends State { duration: _focusTransition, curve: Curves.easeOut, height: _searchHeight, + padding: const EdgeInsets.symmetric(horizontal: _searchPadding), decoration: BoxDecoration( - color: AppColors.card, + color: _focused ? AppColors.card : AppColors.commentInput, borderRadius: BorderRadius.circular(_searchRadius), border: Border.all( - color: _focused ? AppColors.blue : AppColors.border, + color: _focused ? AppColors.blue : Colors.transparent, ), - boxShadow: [ - BoxShadow( - color: _focused - ? AppColors.blue.withValues(alpha: 0.18) - : Colors.black.withValues(alpha: 0.04), - blurRadius: _focused ? 10 : 4, - offset: const Offset(0, 2), - ), - ], ), - child: TextField( - key: const Key('discover-search'), - controller: widget.controller, - focusNode: widget.focusNode, - onChanged: widget.onChanged, - onTapOutside: (_) => widget.focusNode.unfocus(), - textInputAction: TextInputAction.search, - style: const TextStyle(color: AppColors.onSurface, fontSize: 14), - decoration: InputDecoration( - isDense: true, - filled: false, - hintText: AppLocalizations.of(context)!.searchDiscover, - hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 13.5), - prefixIcon: const Icon( + child: Row( + children: [ + const Icon( Icons.search_rounded, color: AppColors.subtle, - size: 17, + size: _iconSize, ), - suffixIcon: widget.loading - ? const SizedBox.square( - dimension: 18, - child: Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : null, - suffixIconConstraints: const BoxConstraints( - minWidth: 0, - minHeight: 0, + const SizedBox(width: _iconGap), + Expanded( + child: TextField( + key: const Key('discover-search'), + controller: widget.controller, + focusNode: widget.focusNode, + onChanged: widget.onChanged, + onTapOutside: (_) => widget.focusNode.unfocus(), + textInputAction: TextInputAction.search, + textAlignVertical: TextAlignVertical.center, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + ), + decoration: InputDecoration( + isDense: true, + hintText: AppLocalizations.of(context)!.searchDiscover, + hintStyle: const TextStyle( + color: AppColors.subtle, + fontSize: 14, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + suffixIcon: widget.loading + ? const SizedBox.square( + dimension: 18, + child: Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : null, + suffixIconConstraints: const BoxConstraints( + minWidth: 0, + minHeight: 0, + ), + ), + ), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - ), + ], ), ), ); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 4dc97f7..982c8a6 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -182,12 +182,18 @@ void main() { .decoration! as BoxDecoration; - expect(shell().borderRadius, BorderRadius.circular(20)); - expect((shell().border! as Border).top.color, AppColors.border); + expect(shell().borderRadius, BorderRadius.circular(22)); + expect(shell().color, AppColors.commentInput); + expect((shell().border! as Border).top.color, Colors.transparent); + expect( + tester.getSize(find.byKey(const Key('discover-search-shell'))).height, + 44, + ); await tester.tap(find.byKey(const Key('discover-search'))); await tester.pump(const Duration(milliseconds: 200)); + expect(shell().color, AppColors.card); expect((shell().border! as Border).top.color, AppColors.blue); }); From bfba2b7c72aa7e3041c21c3c9c6be26bb82cc657 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:03:02 +0800 Subject: [PATCH 072/124] =?UTF-8?q?feat(flutter):=20=E5=89=A7=E5=9C=BA?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A1=86=E6=94=B9=E7=99=BD=E5=BA=95=E5=B9=B6?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=A6=9C=E5=8D=95=E6=96=B0=E5=89=A7=E9=A2=84?= =?UTF-8?q?=E7=BA=A6=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/plain_page_scaffold.dart | 80 +++++++ lib/l10n/app_en.arb | 10 + lib/l10n/app_id.arb | 5 + lib/l10n/app_localizations.dart | 30 +++ lib/l10n/app_localizations_en.dart | 17 ++ lib/l10n/app_localizations_id.dart | 17 ++ lib/l10n/app_localizations_zh.dart | 17 ++ lib/l10n/app_zh.arb | 5 + lib/pages/discover/discover_view.dart | 15 +- .../widgets/discover_search_field.dart | 2 +- lib/pages/new_releases/new_releases_page.dart | 43 ++++ lib/pages/ranking/ranking_page.dart | 196 ++++++++++++++++++ lib/pages/reserve/reserve_page.dart | 48 +++++ test/discover/home_all_dramas_grid_test.dart | 2 +- test/discover/theater_pages_test.dart | 110 ++++++++++ 15 files changed, 588 insertions(+), 9 deletions(-) create mode 100644 lib/core/widgets/plain_page_scaffold.dart create mode 100644 lib/pages/new_releases/new_releases_page.dart create mode 100644 lib/pages/ranking/ranking_page.dart create mode 100644 lib/pages/reserve/reserve_page.dart create mode 100644 test/discover/theater_pages_test.dart diff --git a/lib/core/widgets/plain_page_scaffold.dart b/lib/core/widgets/plain_page_scaffold.dart new file mode 100644 index 0000000..640baf0 --- /dev/null +++ b/lib/core/widgets/plain_page_scaffold.dart @@ -0,0 +1,80 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + +/// 二级页面通用骨架:返回栏 + 标题/副标题 + 可滚动内容,无底部导航。 +class PlainPageScaffold extends StatelessWidget { + const PlainPageScaffold({ + super.key, + required this.pageKey, + required this.backKey, + required this.title, + required this.child, + this.subtitle, + }); + + final Key pageKey; + final Key backKey; + final String title; + final String? subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + key: pageKey, + backgroundColor: AppColors.ink, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(6, 4, 14, 0), + child: Row( + children: [ + IconButton( + key: backKey, + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon( + Icons.chevron_left_rounded, + color: AppColors.onSurface, + size: 26, + ), + ), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ), + if (subtitle != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + subtitle!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ), + Expanded(child: child), + ], + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ee56f24..35d401b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -587,6 +587,16 @@ "rankingSubtitle": "Ranked from today's live catalog feed.", "rankingHot": "Trending", "rankingNew": "New releases", + "newReleasesSubtitle": "Newest arrivals", + "reserveSubtitle": "Reserve before launch", + "reserveEmptyTitle": "No reservations yet", + "reserveEmptyHint": "Reserve new dramas here once announced", + "topLabel": "TOP {n}", + "@topLabel": { + "placeholders": { + "n": {"type": "int"} + } + }, "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 4cba543..ffa2ba1 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -564,6 +564,11 @@ "rankingSubtitle": "Diurutkan dari feed katalog terkini.", "rankingHot": "Populer", "rankingNew": "Rilis baru", + "newReleasesSubtitle": "Rilis terbaru", + "reserveSubtitle": "Reservasi sebelum rilis", + "reserveEmptyTitle": "Belum ada reservasi", + "reserveEmptyHint": "Reservasi drama baru di sini", + "topLabel": "TOP {n}", "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 776aa99..cfe633f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2518,6 +2518,36 @@ abstract class AppLocalizations { /// **'New releases'** String get rankingNew; + /// No description provided for @newReleasesSubtitle. + /// + /// In en, this message translates to: + /// **'Newest arrivals'** + String get newReleasesSubtitle; + + /// No description provided for @reserveSubtitle. + /// + /// In en, this message translates to: + /// **'Reserve before launch'** + String get reserveSubtitle; + + /// No description provided for @reserveEmptyTitle. + /// + /// In en, this message translates to: + /// **'No reservations yet'** + String get reserveEmptyTitle; + + /// No description provided for @reserveEmptyHint. + /// + /// In en, this message translates to: + /// **'Reserve new dramas here once announced'** + String get reserveEmptyHint; + + /// No description provided for @topLabel. + /// + /// In en, this message translates to: + /// **'TOP {n}'** + String topLabel(int n); + /// No description provided for @rankingEmptyTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 02eb36f..1c38a02 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1302,6 +1302,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rankingNew => 'New releases'; + @override + String get newReleasesSubtitle => 'Newest arrivals'; + + @override + String get reserveSubtitle => 'Reserve before launch'; + + @override + String get reserveEmptyTitle => 'No reservations yet'; + + @override + String get reserveEmptyHint => 'Reserve new dramas here once announced'; + + @override + String topLabel(int n) { + return 'TOP $n'; + } + @override String get rankingEmptyTitle => 'No ranked stories yet'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 5be257a..0e12a5c 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1302,6 +1302,23 @@ class AppLocalizationsId extends AppLocalizations { @override String get rankingNew => 'Rilis baru'; + @override + String get newReleasesSubtitle => 'Rilis terbaru'; + + @override + String get reserveSubtitle => 'Reservasi sebelum rilis'; + + @override + String get reserveEmptyTitle => 'Belum ada reservasi'; + + @override + String get reserveEmptyHint => 'Reservasi drama baru di sini'; + + @override + String topLabel(int n) { + return 'TOP $n'; + } + @override String get rankingEmptyTitle => 'Belum ada peringkat'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 10f6cae..074008a 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1280,6 +1280,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String get rankingNew => '新剧'; + @override + String get newReleasesSubtitle => '最新上架短剧'; + + @override + String get reserveSubtitle => '上线前预约,开播即通知'; + + @override + String get reserveEmptyTitle => '暂无预约剧集'; + + @override + String get reserveEmptyHint => '新剧上线后可在这里预约'; + + @override + String topLabel(int n) { + return 'TOP $n'; + } + @override String get rankingEmptyTitle => '暂无榜单内容'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 85c6b4b..e98add6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -526,6 +526,11 @@ "rankingSubtitle": "依据目录实时分区排序。", "rankingHot": "热播", "rankingNew": "新剧", + "newReleasesSubtitle": "最新上架短剧", + "reserveSubtitle": "上线前预约,开播即通知", + "reserveEmptyTitle": "暂无预约剧集", + "reserveEmptyHint": "新剧上线后可在这里预约", + "topLabel": "TOP {n}", "rankingEmptyTitle": "暂无榜单内容", "rankingEmptyBody": "新剧上线后会自动进入榜单。", "rankingTopPick": "今日榜首" diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 9c90a4d..c5492a4 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -15,6 +15,9 @@ import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; +import '../new_releases/new_releases_page.dart'; +import '../ranking/ranking_page.dart'; +import '../reserve/reserve_page.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'discover_types.dart'; @@ -338,7 +341,7 @@ class _DiscoverPageState extends State { icon: Icons.local_fire_department_rounded, color: AppColors.blue, label: l10n.rankingTab, - onTap: () => _showComingSoon(l10n), + onTap: () => _openPage(context, const RankingPage()), ), ), const SizedBox(width: _toolbarGap), @@ -348,7 +351,7 @@ class _DiscoverPageState extends State { icon: Icons.play_circle_fill_rounded, color: _newIconColor, label: l10n.newDramas, - onTap: () => _showComingSoon(l10n), + onTap: () => _openPage(context, const NewReleasesPage()), ), ), const SizedBox(width: _toolbarGap), @@ -358,7 +361,7 @@ class _DiscoverPageState extends State { icon: Icons.calendar_month_rounded, color: AppColors.blue, label: l10n.reserve, - onTap: () => _showComingSoon(l10n), + onTap: () => _openPage(context, const ReservePage()), ), ), ], @@ -447,10 +450,8 @@ class _DiscoverPageState extends State { ); } - void _showComingSoon(AppLocalizations l10n) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.comingSoon))); + void _openPage(BuildContext context, Widget page) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } Widget _block(Widget child) => diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index 4fd553c..f4a889d 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -74,7 +74,7 @@ class _DiscoverSearchFieldState extends State { height: _searchHeight, padding: const EdgeInsets.symmetric(horizontal: _searchPadding), decoration: BoxDecoration( - color: _focused ? AppColors.card : AppColors.commentInput, + color: AppColors.card, borderRadius: BorderRadius.circular(_searchRadius), border: Border.all( color: _focused ? AppColors.blue : Colors.transparent, diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart new file mode 100644 index 0000000..d10e4a8 --- /dev/null +++ b/lib/pages/new_releases/new_releases_page.dart @@ -0,0 +1,43 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/services/catalog_controller.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; +import '../../l10n/app_localizations.dart'; +import '../discover/widgets/drama_grid.dart'; +import '../drama_detail/drama_detail_view.dart'; + +/// 剧场新剧页:按 id 倒序展示最新上架短剧。 +class NewReleasesPage extends StatelessWidget { + const NewReleasesPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final dramas = [...context.read().dramas] + ..sort((a, b) => b.id.compareTo(a.id)); + return PlainPageScaffold( + pageKey: const Key('new-releases-page'), + backKey: const Key('new-releases-back'), + title: l10n.rankingNew, + subtitle: l10n.newReleasesSubtitle, + child: dramas.isEmpty + ? EmptyState(icon: Icons.fiber_new_outlined, title: l10n.noStories) + : SingleChildScrollView( + padding: const EdgeInsets.only(top: 4, bottom: 24), + child: DramaGrid( + l10n: l10n, + dramas: dramas, + onOpen: (drama) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DramaDetailPage(drama: drama), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart new file mode 100644 index 0000000..6f40663 --- /dev/null +++ b/lib/pages/ranking/ranking_page.dart @@ -0,0 +1,196 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/services/catalog_controller.dart'; +import '../../core/formatting/count_format.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; +import '../../core/widgets/remote_image.dart'; +import '../../l10n/app_localizations.dart'; +import '../drama_detail/drama_detail_view.dart'; + +const int _topCount = 50; +const double _rankColumnWidth = 56; +const double _coverWidth = 72; +const double _coverHeight = 96; +const double _rowGap = 12; +const Color _rankSilver = Color(0xFFB8860B); +const Color _rankBronze = Color(0xFF8C837B); + +/// 剧场榜单页:按播放量降序展示 TOP 榜单。 +class RankingPage extends StatelessWidget { + const RankingPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final dramas = [...context.read().dramas] + ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + final top = dramas.take(_topCount).toList(); + return PlainPageScaffold( + pageKey: const Key('ranking-page'), + backKey: const Key('ranking-back'), + title: l10n.topCharts, + subtitle: l10n.rankingSubtitle, + child: top.isEmpty + ? EmptyState(icon: Icons.leaderboard_outlined, title: l10n.noStories) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(14, 4, 14, 24), + itemCount: top.length, + separatorBuilder: (_, _) => const SizedBox(height: _rowGap), + itemBuilder: (context, index) => + _RankingRow(index: index, drama: top[index], l10n: l10n), + ), + ); + } +} + +class _RankingRow extends StatelessWidget { + const _RankingRow({ + required this.index, + required this.drama, + required this.l10n, + }); + + final int index; + final Drama drama; + final AppLocalizations l10n; + + int get _rank => index + 1; + + Color get _rankColor => switch (_rank) { + 1 => AppColors.blue, + 2 => _rankSilver, + 3 => _rankBronze, + _ => AppColors.subtle, + }; + + double get _rankSize => _rank == 1 ? 22 : (_rank <= 3 ? 20 : 18); + + @override + Widget build(BuildContext context) { + final genre = drama.genre.isEmpty ? drama.category : drama.genre; + return Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: Key('ranking-row-$index'), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + SizedBox( + width: _rankColumnWidth, + child: Text( + l10n.topLabel(_rank), + key: Key('ranking-top-$_rank'), + textAlign: TextAlign.center, + maxLines: 1, + style: TextStyle( + color: _rankColor, + fontSize: _rankSize, + fontWeight: FontWeight.w900, + ), + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: _coverWidth, + height: _coverHeight, + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackAspectRatio: 3 / 4, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + drama.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + if (genre.isNotEmpty) ...[ + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.raised, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + genre, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + ), + ), + ), + ], + const SizedBox(height: 8), + Row( + children: [ + const Icon( + Icons.local_fire_department_rounded, + color: AppColors.blue, + size: 12, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + l10n.heatLabel( + formatInteractionCount(drama.viewCount), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/reserve/reserve_page.dart b/lib/pages/reserve/reserve_page.dart new file mode 100644 index 0000000..a680410 --- /dev/null +++ b/lib/pages/reserve/reserve_page.dart @@ -0,0 +1,48 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; +import '../../l10n/app_localizations.dart'; + +/// 剧场预约页:暂无预约数据源,展示空态与即将上线说明。 +class ReservePage extends StatelessWidget { + const ReservePage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return PlainPageScaffold( + pageKey: const Key('reserve-page'), + backKey: const Key('reserve-back'), + title: l10n.reserve, + subtitle: l10n.reserveSubtitle, + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + EmptyState( + key: const Key('reserve-empty'), + icon: Icons.event_available_outlined, + title: l10n.reserveEmptyTitle, + subtitle: l10n.reserveEmptyHint, + ), + const SizedBox(height: 12), + TextButton( + onPressed: null, + child: Text( + l10n.comingSoon, + style: const TextStyle(color: AppColors.subtle), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 982c8a6..19dc45a 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -183,7 +183,7 @@ void main() { as BoxDecoration; expect(shell().borderRadius, BorderRadius.circular(22)); - expect(shell().color, AppColors.commentInput); + expect(shell().color, AppColors.card); expect((shell().border! as Border).top.color, Colors.transparent); expect( tester.getSize(find.byKey(const Key('discover-search-shell'))).height, diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart new file mode 100644 index 0000000..d18f091 --- /dev/null +++ b/test/discover/theater_pages_test.dart @@ -0,0 +1,110 @@ +// 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/discover/widgets/drama_grid.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.empty(); + }); + + testWidgets('should_open_ranking_sorted_by_views', (tester) async { + await _pumpTheater(tester); + await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + expect(find.text('TOP 1'), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('ranking-row-0')), + matching: find.text('The Signal'), + ), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('ranking-row-0'))); + await tester.pumpAndSettle(); + expect(find.byType(DramaDetailPage), findsOneWidget); + }); + + testWidgets('should_open_new_releases_sorted_by_id', (tester) async { + await _pumpTheater(tester); + await tester.tap(find.byKey(const Key('theater-toolbar-new'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('new-releases-page')), findsOneWidget); + final grid = tester.widget(find.byType(DramaGrid)); + expect(grid.dramas.map((drama) => drama.id).toList(), [ + 'vengeance-demo', + 'the-signal-demo', + 'city-lights-demo', + ]); + }); + + testWidgets('should_open_reserve_empty_state', (tester) async { + await _pumpTheater(tester); + await tester.tap(find.byKey(const Key('theater-toolbar-reserve'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('reserve-page')), findsOneWidget); + expect(find.byKey(const Key('reserve-empty')), findsOneWidget); + expect(find.text('Coming soon'), findsOneWidget); + expect( + tester.widget(find.byType(TextButton)).onPressed, + isNull, + ); + }); + + testWidgets('should_go_back_to_theater_from_ranking_page', (tester) async { + await _pumpTheater(tester); + await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('ranking-back'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('ranking-page')), findsNothing); + expect(find.byKey(const Key('discover-search')), findsOneWidget); + }); +} + +Future _pumpTheater(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, + searchRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); +} From 881088a428fad27486e8d4a04e2b584804309bb6 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:08:43 +0800 Subject: [PATCH 073/124] =?UTF-8?q?feat(flutter):=20=E6=A6=9C=E5=8D=95?= =?UTF-8?q?=E9=A1=B5=E4=B8=89=E5=BC=BA=E6=A8=AA=E6=8E=92=E4=B8=8E=E7=AC=AC?= =?UTF-8?q?=E5=9B=9B=E5=90=8D=E8=B5=B7=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/ranking/ranking_page.dart | 210 ++++++++++++++++++++++---- test/discover/theater_pages_test.dart | 72 ++++++++- 2 files changed, 247 insertions(+), 35 deletions(-) diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 6f40663..34de13b 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -15,14 +15,23 @@ import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; const int _topCount = 50; -const double _rankColumnWidth = 56; -const double _coverWidth = 72; -const double _coverHeight = 96; +const double _podiumGap = 8; +const double _centerCoverWidth = 120; +const double _centerCoverHeight = 160; +const double _sideCoverWidth = 92; +const double _sideCoverHeight = 124; +const double _coverRadius = 12; +const double _titleSlotHeight = 24; const double _rowGap = 12; -const Color _rankSilver = Color(0xFFB8860B); -const Color _rankBronze = Color(0xFF8C837B); +const double _rankColumnWidth = 56; +const double _listCoverWidth = 72; +const double _listCoverHeight = 96; +const Color _goldStart = Color(0xFFF3B34C); +const Color _goldEnd = Color(0xFFD9A23A); +const Color _bronzeStart = Color(0xFFCD7F32); +const Color _bronzeEnd = Color(0xFFA9713A); -/// 剧场榜单页:按播放量降序展示 TOP 榜单。 +/// 剧场榜单页:前三名横排领奖台,第 4 名起纵向列表。 class RankingPage extends StatelessWidget { const RankingPage({super.key}); @@ -32,6 +41,8 @@ class RankingPage extends StatelessWidget { final dramas = [...context.read().dramas] ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); final top = dramas.take(_topCount).toList(); + final podium = top.take(3).toList(); + final rest = top.skip(3).toList(); return PlainPageScaffold( pageKey: const Key('ranking-page'), backKey: const Key('ranking-back'), @@ -39,17 +50,171 @@ class RankingPage extends StatelessWidget { subtitle: l10n.rankingSubtitle, child: top.isEmpty ? EmptyState(icon: Icons.leaderboard_outlined, title: l10n.noStories) - : ListView.separated( - padding: const EdgeInsets.fromLTRB(14, 4, 14, 24), - itemCount: top.length, - separatorBuilder: (_, _) => const SizedBox(height: _rowGap), - itemBuilder: (context, index) => - _RankingRow(index: index, drama: top[index], l10n: l10n), + : ListView( + padding: const EdgeInsets.only(top: 4, bottom: 24), + children: [ + if (podium.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _Podium(podium: podium, l10n: l10n), + ), + if (rest.isNotEmpty) ...[ + const SizedBox(height: 16), + for (var index = 0; index < rest.length; index++) ...[ + if (index > 0) const SizedBox(height: _rowGap), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _RankingRow( + index: index + 3, + drama: rest[index], + l10n: l10n, + ), + ), + ], + ], + ], ), ); } } +class _Podium extends StatelessWidget { + const _Podium({required this.podium, required this.l10n}); + + final List podium; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox(width: _sideCoverWidth, child: _slot(2)), + const SizedBox(width: _podiumGap), + SizedBox(width: _centerCoverWidth, child: _slot(1)), + const SizedBox(width: _podiumGap), + SizedBox(width: _sideCoverWidth, child: _slot(3)), + ], + ); + } + + Widget _slot(int rank) { + if (podium.length < rank) return const SizedBox.shrink(); + return _PodiumCard(rank: rank, drama: podium[rank - 1], l10n: l10n); + } +} + +class _PodiumCard extends StatelessWidget { + const _PodiumCard({ + required this.rank, + required this.drama, + required this.l10n, + }); + + final int rank; + final Drama drama; + final AppLocalizations l10n; + + bool get _center => rank == 1; + + double get _width => _center ? _centerCoverWidth : _sideCoverWidth; + double get _height => _center ? _centerCoverHeight : _sideCoverHeight; + + LinearGradient get _badgeGradient => switch (rank) { + 1 => const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue, AppColors.bluePressed], + ), + 2 => const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_goldStart, _goldEnd], + ), + _ => const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_bronzeStart, _bronzeEnd], + ), + }; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(_coverRadius), + child: SizedBox( + key: Key('ranking-cover-$rank'), + width: _width, + height: _height, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackAspectRatio: 3 / 4, + ), + Positioned( + top: 8, + left: 8, + child: Container( + key: Key('ranking-top-$rank'), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + gradient: _badgeGradient, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + l10n.topLabel(rank), + maxLines: 1, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 6), + SizedBox( + height: _titleSlotHeight, + child: Center( + child: Text( + drama.title, + key: Key('ranking-title-$rank'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.onSurface, + fontSize: _center ? 15 : 13, + fontWeight: _center ? FontWeight.w800 : FontWeight.w700, + ), + ), + ), + ), + ], + ), + ); + } +} + class _RankingRow extends StatelessWidget { const _RankingRow({ required this.index, @@ -63,15 +228,6 @@ class _RankingRow extends StatelessWidget { int get _rank => index + 1; - Color get _rankColor => switch (_rank) { - 1 => AppColors.blue, - 2 => _rankSilver, - 3 => _rankBronze, - _ => AppColors.subtle, - }; - - double get _rankSize => _rank == 1 ? 22 : (_rank <= 3 ? 20 : 18); - @override Widget build(BuildContext context) { final genre = drama.genre.isEmpty ? drama.category : drama.genre; @@ -101,18 +257,18 @@ class _RankingRow extends StatelessWidget { key: Key('ranking-top-$_rank'), textAlign: TextAlign.center, maxLines: 1, - style: TextStyle( - color: _rankColor, - fontSize: _rankSize, - fontWeight: FontWeight.w900, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 18, + fontWeight: FontWeight.w800, ), ), ), ClipRRect( borderRadius: BorderRadius.circular(10), child: SizedBox( - width: _coverWidth, - height: _coverHeight, + width: _listCoverWidth, + height: _listCoverHeight, child: RemoteImage( drama.posterUrl, fit: BoxFit.cover, diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index d18f091..bf6d11b 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/drama.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'; @@ -31,14 +32,11 @@ void main() { expect(find.byKey(const Key('ranking-page')), findsOneWidget); expect(find.text('TOP 1'), findsOneWidget); expect( - find.descendant( - of: find.byKey(const Key('ranking-row-0')), - matching: find.text('The Signal'), - ), - findsOneWidget, + tester.widget(find.byKey(const Key('ranking-title-1'))).data, + 'The Signal', ); - await tester.tap(find.byKey(const Key('ranking-row-0'))); + await tester.tap(find.byKey(const Key('ranking-top-1'))); await tester.pumpAndSettle(); expect(find.byType(DramaDetailPage), findsOneWidget); }); @@ -71,6 +69,44 @@ void main() { ); }); + testWidgets('should_render_podium_with_center_on_top', (tester) async { + await _pumpTheater(tester); + await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); + await tester.pumpAndSettle(); + + final top1 = tester.getRect(find.byKey(const Key('ranking-cover-1'))); + final top2 = tester.getRect(find.byKey(const Key('ranking-cover-2'))); + final top3 = tester.getRect(find.byKey(const Key('ranking-cover-3'))); + expect(top1.size, const Size(120, 160)); + expect(top2.size, const Size(92, 124)); + expect(top3.size, const Size(92, 124)); + expect(top2.left, lessThan(top1.left)); + expect(top3.left, greaterThan(top1.right)); + expect(top1.bottom, closeTo(top2.bottom, 0.5)); + expect(top1.bottom, closeTo(top3.bottom, 0.5)); + expect(find.byKey(const Key('ranking-row-3')), findsNothing); + }); + + testWidgets('should_list_rank_four_and_below', (tester) async { + await _pumpTheater(tester, dramas: _rankDramas(5)); + await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('ranking-row-3')), findsOneWidget); + expect(find.byKey(const Key('ranking-row-2')), findsNothing); + expect(find.byKey(const Key('ranking-top-4')), findsOneWidget); + }); + + testWidgets('should_truncate_podium_when_less_than_three', (tester) async { + await _pumpTheater(tester, dramas: _rankDramas(1)); + await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('ranking-top-1')), findsOneWidget); + expect(find.byKey(const Key('ranking-top-2')), findsNothing); + expect(find.byKey(const Key('ranking-top-3')), findsNothing); + }); + testWidgets('should_go_back_to_theater_from_ranking_page', (tester) async { await _pumpTheater(tester); await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); @@ -84,12 +120,12 @@ void main() { }); } -Future _pumpTheater(WidgetTester tester) async { +Future _pumpTheater(WidgetTester tester, {List? dramas}) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final catalog = FakeCatalogRepository(); + final catalog = FakeCatalogRepository(dramas: dramas); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, @@ -108,3 +144,23 @@ Future _pumpTheater(WidgetTester tester) async { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); } + +List _rankDramas(int count) => [ + for (var index = 0; index < count; index++) + Drama( + id: 'rank-$index', + title: 'Rank $index', + synopsis: 'synopsis', + posterUrl: '', + totalEpisodes: 10, + viewCount: (count - index) * 100, + episodes: [ + Episode( + id: 'rank-$index-1', + number: 1, + title: 'Episode 1', + isFree: true, + ), + ], + ), +]; From 04a1a4ee82dfcb5c1d6806ff845b9c17332c86be Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:13:12 +0800 Subject: [PATCH 074/124] =?UTF-8?q?feat(flutter):=20=E6=A6=9C=E5=8D=95?= =?UTF-8?q?=E5=89=8D=E4=B8=89=E9=93=BA=E6=BB=A1=E4=B8=8E=E5=89=A7=E5=9C=BA?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=90=B8=E9=A1=B6=E4=B8=8B=E5=88=92=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 45 ++++++--- lib/pages/ranking/ranking_page.dart | 100 ++++++++++++------- test/discover/home_all_dramas_grid_test.dart | 23 ++++- test/discover/theater_pages_test.dart | 14 ++- 4 files changed, 130 insertions(+), 52 deletions(-) diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index c5492a4..a74cb56 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -34,7 +34,7 @@ const double _categoryTabHeight = 44; const double _categoryTabGap = 20; const double _categorySelectedFontSize = 16; const double _categoryFontSize = 15; -const double _stickyHeaderHeight = 102; +const double _stickyHeaderHeight = 44; const Duration _stickyHeaderTransition = Duration(milliseconds: 200); /// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 @@ -203,10 +203,7 @@ class _DiscoverPageState extends State { ] : const [], ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [_categoryTabs(l10n, categories), _toolbar(l10n, categories)], - ), + child: _categoryTabs(l10n, categories), ); } @@ -251,6 +248,7 @@ class _DiscoverPageState extends State { ? categories.feed.dramas : categories.dramasOf(logic.selectedCategoryId!); return [ + _toolbar(l10n, categories), if (dramas.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 40), @@ -279,6 +277,7 @@ class _DiscoverPageState extends State { if (index == 0) { return _categoryTab( key: const Key('theater-category-all'), + id: 'all', label: l10n.all, selected: logic.selectedCategoryId == null, onTap: () => logic.selectCategory(null), @@ -287,6 +286,7 @@ class _DiscoverPageState extends State { final category = categories.categories[index - 1]; return _categoryTab( key: Key('theater-category-${category.id}'), + id: category.id, label: category.name, selected: logic.selectedCategoryId == category.id, onTap: () => logic.selectCategory(category.id), @@ -298,6 +298,7 @@ class _DiscoverPageState extends State { Widget _categoryTab({ required Key key, + required String id, required String label, required bool selected, required VoidCallback onTap, @@ -306,16 +307,32 @@ class _DiscoverPageState extends State { key: key, behavior: HitTestBehavior.opaque, onTap: onTap, - child: Center( - child: Text( - label, - maxLines: 1, - style: TextStyle( - color: selected ? AppColors.onSurface : AppColors.muted, - fontSize: selected ? _categorySelectedFontSize : _categoryFontSize, - fontWeight: selected ? FontWeight.w800 : FontWeight.w500, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + maxLines: 1, + style: TextStyle( + color: selected ? AppColors.onSurface : AppColors.muted, + fontSize: selected + ? _categorySelectedFontSize + : _categoryFontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w500, + ), ), - ), + const SizedBox(height: 4), + Container( + key: Key('theater-category-underline-$id'), + width: 18, + height: 2, + decoration: BoxDecoration( + color: selected ? AppColors.blue : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + ], ), ); } diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 34de13b..5e8be44 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -16,10 +16,6 @@ import '../drama_detail/drama_detail_view.dart'; const int _topCount = 50; const double _podiumGap = 8; -const double _centerCoverWidth = 120; -const double _centerCoverHeight = 160; -const double _sideCoverWidth = 92; -const double _sideCoverHeight = 124; const double _coverRadius = 12; const double _titleSlotHeight = 24; const double _rowGap = 12; @@ -87,14 +83,13 @@ class _Podium extends StatelessWidget { @override Widget build(BuildContext context) { return Row( - mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ - SizedBox(width: _sideCoverWidth, child: _slot(2)), + Expanded(flex: 100, child: _slot(2)), const SizedBox(width: _podiumGap), - SizedBox(width: _centerCoverWidth, child: _slot(1)), + Expanded(flex: 115, child: _slot(1)), const SizedBox(width: _podiumGap), - SizedBox(width: _sideCoverWidth, child: _slot(3)), + Expanded(flex: 100, child: _slot(3)), ], ); } @@ -118,9 +113,6 @@ class _PodiumCard extends StatelessWidget { bool get _center => rank == 1; - double get _width => _center ? _centerCoverWidth : _sideCoverWidth; - double get _height => _center ? _centerCoverHeight : _sideCoverHeight; - LinearGradient get _badgeGradient => switch (rank) { 1 => const LinearGradient( begin: Alignment.topLeft, @@ -150,10 +142,9 @@ class _PodiumCard extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(_coverRadius), - child: SizedBox( + child: AspectRatio( key: Key('ranking-cover-$rank'), - width: _width, - height: _height, + aspectRatio: 3 / 4, child: Stack( fit: StackFit.expand, children: [ @@ -228,9 +219,21 @@ class _RankingRow extends StatelessWidget { int get _rank => index + 1; + List _tagValues() { + final result = []; + for (final value in [drama.genre, drama.category]) { + for (final part in value.split(RegExp(r'[,/、·]'))) { + final tag = part.trim(); + if (tag.isEmpty || result.contains(tag)) continue; + result.add(tag); + if (result.length == 3) return result; + } + } + return result; + } + @override Widget build(BuildContext context) { - final genre = drama.genre.isEmpty ? drama.category : drama.genre; return Container( decoration: BoxDecoration( color: AppColors.card, @@ -293,26 +296,46 @@ class _RankingRow extends StatelessWidget { fontWeight: FontWeight.w700, ), ), - if (genre.isNotEmpty) ...[ + if (drama.synopsis.trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + drama.synopsis, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + height: 1.3, + ), + ), + ], + if (_tagValues().isNotEmpty) ...[ const SizedBox(height: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppColors.raised, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - genre, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 11, - ), - ), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + for (final tag in _tagValues()) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.raised, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + tag, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + ), + ), + ), + ], ), ], const SizedBox(height: 8), @@ -337,6 +360,15 @@ class _RankingRow extends StatelessWidget { ), ), ), + const SizedBox(width: 8), + Text( + l10n.episodeCount(drama.episodeCount), + maxLines: 1, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + ), + ), ], ), ], diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 19dc45a..05d7fed 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -226,6 +226,27 @@ void main() { expect(header().color, AppColors.ink); }); + testWidgets('should_show_category_underline_for_selection', (tester) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + Color underline(String id) => + (tester + .widget( + find.byKey(Key('theater-category-underline-$id')), + ) + .decoration! + as BoxDecoration) + .color!; + + expect(underline('all'), AppColors.blue); + expect(underline('7'), Colors.transparent); + + await tester.tap(find.byKey(const Key('theater-category-7'))); + await tester.pumpAndSettle(); + + expect(underline('all'), Colors.transparent); + expect(underline('7'), AppColors.blue); + }); + testWidgets('should_use_compact_refresh_indicator', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); final indicator = tester.widget( @@ -234,7 +255,7 @@ void main() { expect(indicator.color, AppColors.blue); expect(indicator.strokeWidth, 2); - expect(indicator.edgeOffset, 110); + expect(indicator.edgeOffset, 52); }); } diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index bf6d11b..e0fc3dc 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -77,9 +77,10 @@ void main() { final top1 = tester.getRect(find.byKey(const Key('ranking-cover-1'))); final top2 = tester.getRect(find.byKey(const Key('ranking-cover-2'))); final top3 = tester.getRect(find.byKey(const Key('ranking-cover-3'))); - expect(top1.size, const Size(120, 160)); - expect(top2.size, const Size(92, 124)); - expect(top3.size, const Size(92, 124)); + expect(top1.width, greaterThan(top2.width)); + expect(top3.width, closeTo(top2.width, 0.5)); + expect(top2.left, closeTo(14, 0.5)); + expect(top3.right, closeTo(376, 0.5)); expect(top2.left, lessThan(top1.left)); expect(top3.left, greaterThan(top1.right)); expect(top1.bottom, closeTo(top2.bottom, 0.5)); @@ -95,6 +96,13 @@ void main() { expect(find.byKey(const Key('ranking-row-3')), findsOneWidget); expect(find.byKey(const Key('ranking-row-2')), findsNothing); expect(find.byKey(const Key('ranking-top-4')), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('ranking-row-3')), + matching: find.text('synopsis'), + ), + findsOneWidget, + ); }); testWidgets('should_truncate_podium_when_less_than_three', (tester) async { From 720fd269359ead392bc284e6fec37fcf64b4f577 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:15:27 +0800 Subject: [PATCH 075/124] =?UTF-8?q?fix(flutter):=20=E5=89=A7=E5=9C=BA?= =?UTF-8?q?=E4=B8=8E=E6=A6=9C=E5=8D=95=E5=8D=A1=E7=89=87=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E8=BF=9B=E6=92=AD=E6=94=BE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 8 ++-- lib/pages/home/home_logic.dart | 30 +------------- lib/pages/new_releases/new_releases_page.dart | 11 +++-- lib/pages/player/open_player.dart | 41 +++++++++++++++++++ lib/pages/ranking/ranking_page.dart | 12 +++--- test/discover/theater_pages_test.dart | 17 +++++++- 6 files changed, 72 insertions(+), 47 deletions(-) create mode 100644 lib/pages/player/open_player.dart diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index a74cb56..e83b882 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -14,8 +16,8 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; -import '../drama_detail/drama_detail_view.dart'; import '../new_releases/new_releases_page.dart'; +import '../player/open_player.dart'; import '../ranking/ranking_page.dart'; import '../reserve/reserve_page.dart'; import 'discover_binding.dart'; @@ -489,9 +491,7 @@ class _DiscoverPageState extends State { callback(drama); return; } - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), - ); + unawaited(playDramaFromCatalog(context, drama)); } Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index f8b5110..ae4310f 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -1,17 +1,13 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; -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_types.dart'; import '../drama_detail/drama_detail_view.dart'; import '../favorites/favorites_page.dart'; import '../mine/mine_types.dart'; -import '../player/player_view.dart'; +import '../player/open_player.dart'; import '../watch_records/watch_records_page.dart'; class HomeLogic extends ChangeNotifier { @@ -73,29 +69,7 @@ class HomeLogic extends ChangeNotifier { BuildContext context, Drama drama, { String? episodeId, - }) async { - // 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。 - var playable = drama; - try { - playable = await context.read().requireDetail(drama); - } catch (_) { - // 取详情失败就用卡片自带的那一集起播。 - } - if (!context.mounted) return; - final index = episodeId == null - ? 0 - : playable.episodes.indexWhere((episode) => episode.id == episodeId); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlayerPage( - drama: playable, - initialIndex: index < 0 ? 0 : index, - wallet: context.read(), - downloads: context.read(), - ), - ), - ); - } + }) => playDramaFromCatalog(context, drama, episodeId: episodeId); void openProfile() => select(3); diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index d10e4a8..ec9aee8 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,7 +10,7 @@ import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../discover/widgets/drama_grid.dart'; -import '../drama_detail/drama_detail_view.dart'; +import '../player/open_player.dart'; /// 剧场新剧页:按 id 倒序展示最新上架短剧。 class NewReleasesPage extends StatelessWidget { @@ -31,11 +33,8 @@ class NewReleasesPage extends StatelessWidget { child: DramaGrid( l10n: l10n, dramas: dramas, - onOpen: (drama) => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DramaDetailPage(drama: drama), - ), - ), + onOpen: (drama) => + unawaited(playDramaFromCatalog(context, drama)), ), ), ); diff --git a/lib/pages/player/open_player.dart b/lib/pages/player/open_player.dart new file mode 100644 index 0000000..af7ea66 --- /dev/null +++ b/lib/pages/player/open_player.dart @@ -0,0 +1,41 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +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 'player_view.dart'; + +/// 目录卡片统一入口:补齐详情后直接进入全屏播放页。 +Future playDramaFromCatalog( + BuildContext context, + Drama drama, { + String? episodeId, +}) async { + final catalog = context.read(); + final wallet = context.read(); + final downloads = context.read(); + var playable = drama; + try { + playable = await catalog.requireDetail(drama); + } catch (_) { + // 取详情失败就用卡片自带的可播预览集起播。 + } + if (!context.mounted) return; + final index = episodeId == null + ? 0 + : playable.episodes.indexWhere((episode) => episode.id == episodeId); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage( + drama: playable, + initialIndex: index < 0 ? 0 : index, + wallet: wallet, + downloads: downloads, + ), + ), + ); +} diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 5e8be44..0e34da9 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -12,7 +14,7 @@ import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; -import '../drama_detail/drama_detail_view.dart'; +import '../player/open_player.dart'; const int _topCount = 50; const double _podiumGap = 8; @@ -134,9 +136,7 @@ class _PodiumCard extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), - ), + onTap: () => unawaited(playDramaFromCatalog(context, drama)), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -246,9 +246,7 @@ class _RankingRow extends StatelessWidget { clipBehavior: Clip.antiAlias, child: InkWell( key: Key('ranking-row-$index'), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), - ), + onTap: () => unawaited(playDramaFromCatalog(context, drama)), child: Padding( padding: const EdgeInsets.all(10), child: Row( diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index e0fc3dc..ddb4580 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -8,7 +8,6 @@ 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/discover/widgets/drama_grid.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'; @@ -38,7 +37,7 @@ void main() { await tester.tap(find.byKey(const Key('ranking-top-1'))); await tester.pumpAndSettle(); - expect(find.byType(DramaDetailPage), findsOneWidget); + expect(find.byKey(const Key('player-scaffold')), findsOneWidget); }); testWidgets('should_open_new_releases_sorted_by_id', (tester) async { @@ -115,6 +114,20 @@ void main() { expect(find.byKey(const Key('ranking-top-3')), findsNothing); }); + testWidgets('should_open_player_from_new_releases_card', (tester) async { + await _pumpTheater(tester); + await tester.tap(find.byKey(const Key('theater-toolbar-new'))); + await tester.pumpAndSettle(); + + final card = find.byKey(const Key('home-drama-card-the-signal-demo')); + await tester.ensureVisible(card); + await tester.pumpAndSettle(); + await tester.tap(card); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-scaffold')), findsOneWidget); + }); + testWidgets('should_go_back_to_theater_from_ranking_page', (tester) async { await _pumpTheater(tester); await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); From d1abfd47c7d81f9ef3a7d23fddf9b0ff4df98774 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:17:46 +0800 Subject: [PATCH 076/124] =?UTF-8?q?fix(flutter):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=89=A7=E5=9C=BA=E5=90=B8=E9=A1=B6=E5=88=86=E7=B1=BB=E8=A1=8C?= =?UTF-8?q?=E9=97=AA=E7=83=81=E5=B9=B6=E5=8E=8B=E7=BC=A9=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 57 +++++++++++--------- test/discover/home_all_dramas_grid_test.dart | 40 ++++++++++++++ 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index e83b882..42de61e 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -60,7 +60,7 @@ class DiscoverPage extends StatefulWidget { class _DiscoverPageState extends State { late final DiscoverLogic logic; - bool _headerScrolled = false; + final ValueNotifier _headerPinned = ValueNotifier(false); bool get embedded => widget.callbacks.embedded; @@ -73,10 +73,8 @@ class _DiscoverPageState extends State { } void _onScroll() { - final scrolled = logic.scroll.hasClients && logic.scroll.offset > 0; - if (scrolled != _headerScrolled) { - setState(() => _headerScrolled = scrolled); - } + final pinned = logic.scroll.hasClients && logic.scroll.offset > 0; + if (pinned != _headerPinned.value) _headerPinned.value = pinned; } void _focusOnFirstFrame() { @@ -99,6 +97,7 @@ class _DiscoverPageState extends State { @override void dispose() { logic.scroll.removeListener(_onScroll); + _headerPinned.dispose(); logic.dispose(); super.dispose(); } @@ -184,28 +183,36 @@ class _DiscoverPageState extends State { } Widget _stickyHeader(AppLocalizations l10n, CategoryController categories) { - return AnimatedContainer( - key: const Key('theater-sticky-header'), - duration: _stickyHeaderTransition, - curve: Curves.easeOut, - decoration: BoxDecoration( - color: _headerScrolled ? AppColors.ink : Colors.transparent, - border: Border( - bottom: BorderSide( - color: _headerScrolled ? AppColors.border : Colors.transparent, + // 只有背景随吸顶状态变化,分类行内容(child)不参与重建,避免上滑闪烁。 + return ValueListenableBuilder( + valueListenable: _headerPinned, + builder: (context, pinned, child) => AnimatedContainer( + key: const Key('theater-sticky-header'), + duration: _stickyHeaderTransition, + curve: Curves.easeOut, + decoration: BoxDecoration( + color: pinned ? AppColors.ink : Colors.transparent, + border: Border( + bottom: BorderSide( + color: pinned ? AppColors.border : Colors.transparent, + ), ), + boxShadow: pinned + ? const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ] + : const [], ), - boxShadow: _headerScrolled - ? const [ - BoxShadow( - color: Color(0x14000000), - blurRadius: 8, - offset: Offset(0, 2), - ), - ] - : const [], + child: child, + ), + child: KeyedSubtree( + key: const Key('theater-sticky-content'), + child: _categoryTabs(l10n, categories), ), - child: _categoryTabs(l10n, categories), ); } @@ -341,7 +348,7 @@ class _DiscoverPageState extends State { Widget _toolbar(AppLocalizations l10n, CategoryController categories) { return Padding( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 12), + padding: const EdgeInsets.fromLTRB(14, 2, 14, 12), child: Row( children: [ Expanded( diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 05d7fed..2212541 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -226,6 +226,46 @@ void main() { expect(header().color, AppColors.ink); }); + testWidgets('should_keep_category_row_stable_across_scrolls', (tester) async { + await _pumpTheater( + tester, + categoryRepository: _GridCategoryRepository( + discoverFeed: _tallDiscoverFeed, + ), + ); + double categoryTop() => + tester.getRect(find.byKey(const Key('theater-category-chips'))).top; + final initial = categoryTop(); + + await tester.drag(_verticalFeed, const Offset(0, -120)); + await tester.pumpAndSettle(); + final once = categoryTop(); + await tester.drag(_verticalFeed, const Offset(0, -120)); + await tester.pumpAndSettle(); + final twice = categoryTop(); + await tester.drag(_verticalFeed, const Offset(0, 400)); + await tester.pumpAndSettle(); + final backToTop = categoryTop(); + + expect(once, closeTo(initial, 0.5)); + expect(twice, closeTo(initial, 0.5)); + expect(backToTop, closeTo(initial, 0.5)); + }); + + testWidgets('should_keep_small_gap_between_category_and_toolbar', ( + tester, + ) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + final categoryBottom = tester + .getRect(find.byKey(const Key('theater-category-chips'))) + .bottom; + final toolbarTop = tester + .getRect(find.byKey(const Key('theater-toolbar-filter'))) + .top; + + expect(toolbarTop - categoryBottom, inInclusiveRange(0, 6)); + }); + testWidgets('should_show_category_underline_for_selection', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); Color underline(String id) => From 6e9a93c92079f91d8f632a94b83d470eda85af2d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:21:54 +0800 Subject: [PATCH 077/124] =?UTF-8?q?feat(flutter):=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E5=89=A7=E5=9C=BA=E5=B0=81=E9=9D=A2=E8=A7=92=E6=A0=87=E5=B9=B6?= =?UTF-8?q?=E9=87=8D=E5=81=9A=E5=88=9B=E4=BD=9C=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_id.dart | 6 + lib/l10n/app_localizations_zh.dart | 6 + lib/l10n/app_zh.arb | 2 + lib/pages/create/create_view.dart | 283 +++++++++++++++---- lib/pages/discover/widgets/drama_grid.dart | 24 -- test/discover/home_all_dramas_grid_test.dart | 1 + test/home/k28_a_navigation_test.dart | 7 + 11 files changed, 277 insertions(+), 74 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 35d401b..6c5ae4e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -154,6 +154,8 @@ "createAiSubtitle": "Start from a story or an idea", "createUploadTitle": "Upload and publish", "createUploadSubtitle": "Publish a finished short drama", + "createMottoPrimary": "Your imagination", + "createMottoSecondary": "Can be seen by more people", "weekdayMon": "Mon", "weekdayTue": "Tue", "weekdayWed": "Wed", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index ffa2ba1..4a47fe5 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -133,6 +133,8 @@ "createAiSubtitle": "Mulai dari cerita atau ide", "createUploadTitle": "Unggah dan terbitkan", "createUploadSubtitle": "Terbitkan drama pendek yang sudah selesai", + "createMottoPrimary": "Imajinasimu", + "createMottoSecondary": "Bisa dilihat lebih banyak orang", "weekdayMon": "Sen", "weekdayTue": "Sel", "weekdayWed": "Rab", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cfe633f..817d79b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -664,6 +664,18 @@ abstract class AppLocalizations { /// **'Publish a finished short drama'** String get createUploadSubtitle; + /// No description provided for @createMottoPrimary. + /// + /// In en, this message translates to: + /// **'Your imagination'** + String get createMottoPrimary; + + /// No description provided for @createMottoSecondary. + /// + /// In en, this message translates to: + /// **'Can be seen by more people'** + String get createMottoSecondary; + /// No description provided for @weekdayMon. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1c38a02..63e059f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -317,6 +317,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get createUploadSubtitle => 'Publish a finished short drama'; + @override + String get createMottoPrimary => 'Your imagination'; + + @override + String get createMottoSecondary => 'Can be seen by more people'; + @override String get weekdayMon => 'Mon'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 0e12a5c..c8a1408 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -318,6 +318,12 @@ class AppLocalizationsId extends AppLocalizations { String get createUploadSubtitle => 'Terbitkan drama pendek yang sudah selesai'; + @override + String get createMottoPrimary => 'Imajinasimu'; + + @override + String get createMottoSecondary => 'Bisa dilihat lebih banyak orang'; + @override String get weekdayMon => 'Sen'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 074008a..00adeb8 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -312,6 +312,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get createUploadSubtitle => '发布已完成的漫剧'; + @override + String get createMottoPrimary => '你的想象'; + + @override + String get createMottoSecondary => '可以被更多人看见'; + @override String get weekdayMon => '周一'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e98add6..b52e17e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -133,6 +133,8 @@ "createAiSubtitle": "从故事或想法开始", "createUploadTitle": "上传发布", "createUploadSubtitle": "发布已完成的漫剧", + "createMottoPrimary": "你的想象", + "createMottoSecondary": "可以被更多人看见", "weekdayMon": "周一", "weekdayTue": "周二", "weekdayWed": "周三", diff --git a/lib/pages/create/create_view.dart b/lib/pages/create/create_view.dart index 2004eaf..d5cf3a4 100644 --- a/lib/pages/create/create_view.dart +++ b/lib/pages/create/create_view.dart @@ -3,11 +3,19 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; -/// K28-B 创作页:AI 制作与上传发布入口。 +const double _cardHeight = 196; +const double _cardRadius = 20; +const double _arrowSize = 36; +const double _illustrationWidth = 156; +const double _illustrationHeight = 88; +const double _mottoLineWidth = 96; +const List _aiGradientStart = [Color(0xFFFFE3D0), Color(0xFFFFC9AC)]; +const List _uploadGradient = [Color(0xFFFFEAD9), Color(0xFFFFD3B3)]; + +/// 创作页:AI 制作与上传发布两张主卡,底部品牌标语。 class CreatePage extends StatelessWidget { const CreatePage({super.key}); @@ -20,46 +28,72 @@ class CreatePage extends StatelessWidget { bottom: false, child: ListView( key: const Key('create-page'), - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - AppDimens.spaceXl, - AppDimens.pageHorizontal, - AppDimens.spaceXl, - ), + padding: const EdgeInsets.fromLTRB(16, 28, 16, 24), children: [ Text( l10n.createTab, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: AppDimens.heroTitle, + fontSize: 28, fontWeight: FontWeight.w800, ), ), - const SizedBox(height: AppDimens.spaceSm), + const SizedBox(height: 6), Text( l10n.createSubtitle, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.body, - ), + style: const TextStyle(color: AppColors.muted, fontSize: 13), ), - const SizedBox(height: AppDimens.spaceXl), - _CreateEntry( + const SizedBox(height: 24), + _CreateCard( key: const Key('create-ai'), - icon: Icons.auto_awesome_rounded, title: l10n.createAiTitle, subtitle: l10n.createAiSubtitle, + illustration: const _AiIllustration(), onTap: () => _openPlaceholder(context, l10n.createAiTitle), ), - const SizedBox(height: AppDimens.spaceMd), - _CreateEntry( + const SizedBox(height: 16), + _CreateCard( key: const Key('create-upload'), - icon: Icons.upload_file_rounded, title: l10n.createUploadTitle, subtitle: l10n.createUploadSubtitle, + illustration: const _UploadIllustration(), onTap: () => _openPlaceholder(context, l10n.createUploadTitle), ), + const SizedBox(height: 32), + Text( + l10n.createMottoPrimary, + key: const Key('create-motto-primary'), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + l10n.createMottoSecondary, + key: const Key('create-motto-secondary'), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + Center( + child: Container( + key: const Key('create-motto-line'), + width: _mottoLineWidth, + height: 2, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(1), + ), + ), + ), ], ), ), @@ -73,18 +107,18 @@ class CreatePage extends StatelessWidget { } } -class _CreateEntry extends StatelessWidget { - const _CreateEntry({ +class _CreateCard extends StatelessWidget { + const _CreateCard({ super.key, - required this.icon, required this.title, required this.subtitle, + required this.illustration, required this.onTap, }); - final IconData icon; final String title; final String subtitle; + final Widget illustration; final VoidCallback onTap; @override @@ -92,57 +126,66 @@ class _CreateEntry extends StatelessWidget { return Container( decoration: BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(_cardRadius), boxShadow: const [AppTheme.cardShadow], ), child: Material( color: Colors.transparent, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(_cardRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(AppDimens.cardPadding), - child: Row( + child: SizedBox( + height: _cardHeight, + child: Stack( children: [ - Container( - width: AppDimens.buttonLarge, - height: AppDimens.buttonLarge, - decoration: BoxDecoration( - color: AppColors.badgePink, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: Icon(icon, color: AppColors.secondary, size: 22), - ), - const SizedBox(width: AppDimens.spaceMd), - Expanded( + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 68, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Text( title, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w700, + fontSize: 22, + fontWeight: FontWeight.w900, ), ), - const SizedBox(height: AppDimens.spaceXs), + const SizedBox(height: 6), Text( subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, - fontSize: AppDimens.caption, + fontSize: 13, ), ), ], ), ), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.subtle, - size: 20, + Positioned( + top: 16, + right: 16, + child: Container( + width: _arrowSize, + height: _arrowSize, + decoration: const BoxDecoration( + color: AppColors.raised, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.arrow_forward_rounded, + color: AppColors.blue, + size: 18, + ), + ), ), + Positioned(right: 14, bottom: 14, child: illustration), ], ), ), @@ -152,6 +195,146 @@ class _CreateEntry extends StatelessWidget { } } +class _AiIllustration extends StatelessWidget { + const _AiIllustration(); + + @override + Widget build(BuildContext context) { + return Container( + width: _illustrationWidth, + height: _illustrationHeight, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: _aiGradientStart, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Stack( + children: [ + const Positioned( + left: 12, + top: 10, + child: Icon( + Icons.auto_awesome_rounded, + color: AppColors.secondary, + size: 30, + ), + ), + const Positioned( + right: 14, + top: 8, + child: Icon( + Icons.auto_awesome_rounded, + color: AppColors.blue, + size: 12, + ), + ), + Positioned( + right: 32, + top: 30, + child: Container( + width: 5, + height: 5, + decoration: BoxDecoration( + color: AppColors.blue.withValues(alpha: 0.55), + shape: BoxShape.circle, + ), + ), + ), + const Positioned( + left: 12, + bottom: 10, + child: Text( + 'Every Story Matters', + style: TextStyle( + color: AppColors.muted, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} + +class _UploadIllustration extends StatelessWidget { + const _UploadIllustration(); + + @override + Widget build(BuildContext context) { + return Container( + width: _illustrationWidth, + height: _illustrationHeight, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: _uploadGradient, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Stack( + children: [ + const Positioned( + left: 12, + top: 8, + child: Icon( + Icons.cloud_upload_rounded, + color: AppColors.secondary, + size: 30, + ), + ), + Positioned( + right: 16, + top: 12, + child: Container( + width: 30, + height: 20, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon( + Icons.movie_creation_outlined, + color: AppColors.muted, + size: 12, + ), + ), + ), + Positioned( + right: 52, + top: 34, + child: Container( + width: 5, + height: 5, + decoration: BoxDecoration( + color: AppColors.blue.withValues(alpha: 0.55), + shape: BoxShape.circle, + ), + ), + ), + const Positioned( + left: 12, + bottom: 10, + child: Text( + 'Ready to publish', + style: TextStyle( + color: AppColors.muted, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} + class _CreatePlaceholderPage extends StatelessWidget { const _CreatePlaceholderPage({required this.title}); @@ -173,12 +356,12 @@ class _CreatePlaceholderPage extends StatelessWidget { color: AppColors.blue, size: 42, ), - const SizedBox(height: AppDimens.spaceMd), + const SizedBox(height: 12), Text( l10n.comingSoon, style: const TextStyle( color: AppColors.onSurface, - fontSize: AppDimens.sectionTitle, + fontSize: 16, fontWeight: FontWeight.w700, ), ), diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index 3f003b4..b58d8cf 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -23,7 +23,6 @@ const double _tagChipFontSize = 11; const double _heatFontSize = 9; const double _heatIconSize = 11; const int _maxTags = 3; -const Color _freeBadgeColor = Color(0xFF12B76A); const Color _heatBackground = Color(0x99000000); const Color _tagChipBackground = Color(0xFFF4F1EE); final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -86,9 +85,6 @@ class DramaGridCard extends StatelessWidget { final Drama drama; final VoidCallback onTap; - bool get _isFree => - drama.freeEpisodes > 0 || drama.episodes.any((episode) => episode.isFree); - @override Widget build(BuildContext context) { return GestureDetector( @@ -118,7 +114,6 @@ class DramaGridCard extends StatelessWidget { fit: StackFit.expand, children: [ _DiscoverCoverImage(path: drama.posterUrl), - Positioned(top: 8, left: 8, child: _tagBadge()), Positioned(left: 8, bottom: 8, child: _heatBadge()), ], ), @@ -126,25 +121,6 @@ class DramaGridCard extends StatelessWidget { ); } - Widget _tagBadge() { - return Container( - key: Key('drama-tag-${drama.id}'), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: _freeBadgeColor, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - child: Text( - _isFree ? l10n.free : l10n.originalTag, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 10, - fontWeight: FontWeight.w700, - ), - ), - ); - } - Widget _heatBadge() { return Container( key: Key('drama-heat-${drama.id}'), diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 2212541..aba5f18 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -47,6 +47,7 @@ void main() { find.byKey(const Key('drama-tag-chip-city-lights-demo-0')), findsOneWidget, ); + expect(find.byKey(const Key('drama-tag-city-lights-demo')), findsNothing); expect(tester.takeException(), isNull); }); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index e4f86c5..89324f9 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -40,6 +40,13 @@ void main() { expect(find.text('Stories become short dramas'), findsOneWidget); expect(find.byKey(const Key('create-ai')), findsOneWidget); expect(find.byKey(const Key('create-upload')), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('create-ai'))).height, 196); + expect(find.byKey(const Key('create-motto-primary')), findsOneWidget); + expect(find.byKey(const Key('create-motto-secondary')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('create-motto-line'))), + const Size(96, 2), + ); await tester.tap(find.byKey(const Key('create-ai'))); await tester.pumpAndSettle(); From 6463436a0d9f8507d0efe1958fcba743a7a6e076 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:25:15 +0800 Subject: [PATCH 078/124] =?UTF-8?q?fix(flutter):=20=E5=90=B8=E9=A1=B6?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E8=A1=8C=E8=83=8C=E6=99=AF=E5=B8=B8=E9=A9=BB?= =?UTF-8?q?=E6=B8=85=E9=99=A4=E9=97=AA=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 36 +++++++++++--------- test/discover/home_all_dramas_grid_test.dart | 11 +++++- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 42de61e..4927b76 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -37,7 +37,7 @@ const double _categoryTabGap = 20; const double _categorySelectedFontSize = 16; const double _categoryFontSize = 15; const double _stickyHeaderHeight = 44; -const Duration _stickyHeaderTransition = Duration(milliseconds: 200); +const Duration _stickyHeaderTransition = Duration(milliseconds: 160); /// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 class DiscoverPage extends StatefulWidget { @@ -113,19 +113,23 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); - final content = SafeArea( - bottom: !embedded, - child: Column( - children: [ - DiscoverHeader( - controller: logic.search, - focusNode: logic.searchFocus, - onChanged: (_) => logic.onSearchChanged(), - loading: logic.searchLoading, - ), - Expanded(child: _feed(l10n, categories)), - if (!embedded) _bottomNav(l10n), - ], + final content = ColoredBox( + key: const Key('theater-page-surface'), + color: AppColors.ink, + child: SafeArea( + bottom: !embedded, + child: Column( + children: [ + DiscoverHeader( + controller: logic.search, + focusNode: logic.searchFocus, + onChanged: (_) => logic.onSearchChanged(), + loading: logic.searchLoading, + ), + Expanded(child: _feed(l10n, categories)), + if (!embedded) _bottomNav(l10n), + ], + ), ), ); return AnnotatedRegion( @@ -183,7 +187,7 @@ class _DiscoverPageState extends State { } Widget _stickyHeader(AppLocalizations l10n, CategoryController categories) { - // 只有背景随吸顶状态变化,分类行内容(child)不参与重建,避免上滑闪烁。 + // 背景常驻 ink,只有分隔线/阴影随吸顶状态过渡,避免透明→实色的闪动。 return ValueListenableBuilder( valueListenable: _headerPinned, builder: (context, pinned, child) => AnimatedContainer( @@ -191,7 +195,7 @@ class _DiscoverPageState extends State { duration: _stickyHeaderTransition, curve: Curves.easeOut, decoration: BoxDecoration( - color: pinned ? AppColors.ink : Colors.transparent, + color: AppColors.ink, border: Border( bottom: BorderSide( color: pinned ? AppColors.border : Colors.transparent, diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index aba5f18..4401ea1 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -215,7 +215,7 @@ void main() { final before = tester.getRect( find.byKey(const Key('theater-sticky-header')), ); - expect(header().color, Colors.transparent); + expect(header().color, AppColors.ink); await tester.drag(_verticalFeed, const Offset(0, -220)); await tester.pumpAndSettle(); @@ -227,6 +227,15 @@ void main() { expect(header().color, AppColors.ink); }); + testWidgets('should_paint_theater_surface_with_ink', (tester) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + final surface = tester.widget( + find.byKey(const Key('theater-page-surface')), + ); + + expect(surface.color, AppColors.ink); + }); + testWidgets('should_keep_category_row_stable_across_scrolls', (tester) async { await _pumpTheater( tester, From 4f81bcf2b1d46047f26c34fd120da51dfa02e2b0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:28:09 +0800 Subject: [PATCH 079/124] =?UTF-8?q?feat(flutter):=20=E6=96=B0=E5=89=A7?= =?UTF-8?q?=E9=A6=96=E6=98=A0=E5=8E=85=E4=B8=8E=E6=A1=A3=E6=9C=9F=E9=A2=84?= =?UTF-8?q?=E7=BA=A6=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 16 + lib/l10n/app_id.arb | 11 + lib/l10n/app_localizations.dart | 66 +++ lib/l10n/app_localizations_en.dart | 35 ++ lib/l10n/app_localizations_id.dart | 35 ++ lib/l10n/app_localizations_zh.dart | 35 ++ lib/l10n/app_zh.arb | 11 + lib/pages/new_releases/new_releases_page.dart | 428 +++++++++++++++++- lib/pages/reserve/reserve_page.dart | 376 +++++++++++++-- test/discover/theater_pages_test.dart | 46 +- 10 files changed, 998 insertions(+), 61 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6c5ae4e..692373b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -156,6 +156,22 @@ "createUploadSubtitle": "Publish a finished short drama", "createMottoPrimary": "Your imagination", "createMottoSecondary": "Can be seen by more people", + "newReleasesTitle": "New premieres", + "newReleasesCount": "{count} new this week", + "@newReleasesCount": { + "placeholders": { + "count": {"type": "int"} + } + }, + "premiereToday": "Today's premiere", + "newBadge": "NEW", + "reserveTitle": "Schedule booking", + "goNewReleases": "Browse new releases", + "reserveStepsTitle": "What you get after reserving", + "reserveStep1": "One-tap reserve", + "reserveStep2": "Premiere reminder", + "reserveStep3": "Watch right away", + "reserveWeek": "This week", "weekdayMon": "Mon", "weekdayTue": "Tue", "weekdayWed": "Wed", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4a47fe5..286f866 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -135,6 +135,17 @@ "createUploadSubtitle": "Terbitkan drama pendek yang sudah selesai", "createMottoPrimary": "Imajinasimu", "createMottoSecondary": "Bisa dilihat lebih banyak orang", + "newReleasesTitle": "Premiere baru", + "newReleasesCount": "{count} baru minggu ini", + "premiereToday": "Tayang perdana hari ini", + "newBadge": "BARU", + "reserveTitle": "Jadwal reservasi", + "goNewReleases": "Lihat rilis baru", + "reserveStepsTitle": "Yang kamu dapat setelah reservasi", + "reserveStep1": "Reservasi sekali ketuk", + "reserveStep2": "Pengingat tayang", + "reserveStep3": "Langsung tonton", + "reserveWeek": "Minggu ini", "weekdayMon": "Sen", "weekdayTue": "Sel", "weekdayWed": "Rab", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 817d79b..d74c2aa 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -676,6 +676,72 @@ abstract class AppLocalizations { /// **'Can be seen by more people'** String get createMottoSecondary; + /// No description provided for @newReleasesTitle. + /// + /// In en, this message translates to: + /// **'New premieres'** + String get newReleasesTitle; + + /// No description provided for @newReleasesCount. + /// + /// In en, this message translates to: + /// **'{count} new this week'** + String newReleasesCount(int count); + + /// No description provided for @premiereToday. + /// + /// In en, this message translates to: + /// **'Today\'s premiere'** + String get premiereToday; + + /// No description provided for @newBadge. + /// + /// In en, this message translates to: + /// **'NEW'** + String get newBadge; + + /// No description provided for @reserveTitle. + /// + /// In en, this message translates to: + /// **'Schedule booking'** + String get reserveTitle; + + /// No description provided for @goNewReleases. + /// + /// In en, this message translates to: + /// **'Browse new releases'** + String get goNewReleases; + + /// No description provided for @reserveStepsTitle. + /// + /// In en, this message translates to: + /// **'What you get after reserving'** + String get reserveStepsTitle; + + /// No description provided for @reserveStep1. + /// + /// In en, this message translates to: + /// **'One-tap reserve'** + String get reserveStep1; + + /// No description provided for @reserveStep2. + /// + /// In en, this message translates to: + /// **'Premiere reminder'** + String get reserveStep2; + + /// No description provided for @reserveStep3. + /// + /// In en, this message translates to: + /// **'Watch right away'** + String get reserveStep3; + + /// No description provided for @reserveWeek. + /// + /// In en, this message translates to: + /// **'This week'** + String get reserveWeek; + /// No description provided for @weekdayMon. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 63e059f..3f7e73d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -323,6 +323,41 @@ class AppLocalizationsEn extends AppLocalizations { @override String get createMottoSecondary => 'Can be seen by more people'; + @override + String get newReleasesTitle => 'New premieres'; + + @override + String newReleasesCount(int count) { + return '$count new this week'; + } + + @override + String get premiereToday => 'Today\'s premiere'; + + @override + String get newBadge => 'NEW'; + + @override + String get reserveTitle => 'Schedule booking'; + + @override + String get goNewReleases => 'Browse new releases'; + + @override + String get reserveStepsTitle => 'What you get after reserving'; + + @override + String get reserveStep1 => 'One-tap reserve'; + + @override + String get reserveStep2 => 'Premiere reminder'; + + @override + String get reserveStep3 => 'Watch right away'; + + @override + String get reserveWeek => 'This week'; + @override String get weekdayMon => 'Mon'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index c8a1408..12239da 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -324,6 +324,41 @@ class AppLocalizationsId extends AppLocalizations { @override String get createMottoSecondary => 'Bisa dilihat lebih banyak orang'; + @override + String get newReleasesTitle => 'Premiere baru'; + + @override + String newReleasesCount(int count) { + return '$count baru minggu ini'; + } + + @override + String get premiereToday => 'Tayang perdana hari ini'; + + @override + String get newBadge => 'BARU'; + + @override + String get reserveTitle => 'Jadwal reservasi'; + + @override + String get goNewReleases => 'Lihat rilis baru'; + + @override + String get reserveStepsTitle => 'Yang kamu dapat setelah reservasi'; + + @override + String get reserveStep1 => 'Reservasi sekali ketuk'; + + @override + String get reserveStep2 => 'Pengingat tayang'; + + @override + String get reserveStep3 => 'Langsung tonton'; + + @override + String get reserveWeek => 'Minggu ini'; + @override String get weekdayMon => 'Sen'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 00adeb8..8cbbaad 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -318,6 +318,41 @@ class AppLocalizationsZh extends AppLocalizations { @override String get createMottoSecondary => '可以被更多人看见'; + @override + String get newReleasesTitle => '新剧首映'; + + @override + String newReleasesCount(int count) { + return '本周上新 $count 部'; + } + + @override + String get premiereToday => '今日首映'; + + @override + String get newBadge => 'NEW'; + + @override + String get reserveTitle => '档期预约'; + + @override + String get goNewReleases => '去新剧页看看'; + + @override + String get reserveStepsTitle => '预约后你会得到'; + + @override + String get reserveStep1 => '一键预约'; + + @override + String get reserveStep2 => '开播提醒'; + + @override + String get reserveStep3 => '立即追剧'; + + @override + String get reserveWeek => '本周待播'; + @override String get weekdayMon => '周一'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b52e17e..c0caaac 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -135,6 +135,17 @@ "createUploadSubtitle": "发布已完成的漫剧", "createMottoPrimary": "你的想象", "createMottoSecondary": "可以被更多人看见", + "newReleasesTitle": "新剧首映", + "newReleasesCount": "本周上新 {count} 部", + "premiereToday": "今日首映", + "newBadge": "NEW", + "reserveTitle": "档期预约", + "goNewReleases": "去新剧页看看", + "reserveStepsTitle": "预约后你会得到", + "reserveStep1": "一键预约", + "reserveStep2": "开播提醒", + "reserveStep3": "立即追剧", + "reserveWeek": "本周待播", "weekdayMon": "周一", "weekdayTue": "周二", "weekdayWed": "周三", diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index ec9aee8..b540b8a 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -3,16 +3,24 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; -import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/plain_page_scaffold.dart'; +import '../../core/formatting/count_format.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; -import '../discover/widgets/drama_grid.dart'; import '../player/open_player.dart'; -/// 剧场新剧页:按 id 倒序展示最新上架短剧。 +const int _moreLimit = 12; +const int _filmHoles = 8; +const Color _premiereTop = Color(0xFF1F1B16); +const Color _premiereBottom = Color(0xFF2E2119); +const Color _filmHole = Color(0x33FFFFFF); + +/// 新剧页:暗色首映厅,今日首映大卡 + 横向更多新剧。 class NewReleasesPage extends StatelessWidget { const NewReleasesPage({super.key}); @@ -21,22 +29,406 @@ class NewReleasesPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final dramas = [...context.read().dramas] ..sort((a, b) => b.id.compareTo(a.id)); - return PlainPageScaffold( - pageKey: const Key('new-releases-page'), - backKey: const Key('new-releases-back'), - title: l10n.rankingNew, - subtitle: l10n.newReleasesSubtitle, - child: dramas.isEmpty - ? EmptyState(icon: Icons.fiber_new_outlined, title: l10n.noStories) - : SingleChildScrollView( - padding: const EdgeInsets.only(top: 4, bottom: 24), - child: DramaGrid( - l10n: l10n, - dramas: dramas, - onOpen: (drama) => - unawaited(playDramaFromCatalog(context, drama)), + final hero = dramas.isEmpty ? null : dramas.first; + final more = dramas.skip(1).take(_moreLimit).toList(); + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + key: const Key('new-releases-page'), + body: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [_premiereTop, _premiereBottom], + ), + ), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(context, l10n, dramas.length), + Expanded( + child: hero == null + ? Center( + child: Text( + l10n.noStories, + key: const Key('new-releases-empty'), + style: const TextStyle(color: Colors.white70), + ), + ) + : ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: _heroCard(context, l10n, hero), + ), + const SizedBox(height: 14), + const _FilmStrip(), + const SizedBox(height: 14), + if (more.isNotEmpty) ...[ + _moreHeader(l10n), + const SizedBox(height: 10), + SizedBox( + height: 196, + child: ListView.separated( + key: const Key('new-releases-row'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + itemCount: more.length, + separatorBuilder: (_, _) => + const SizedBox(width: 10), + itemBuilder: (context, index) => _moreCard( + context, + l10n, + more[index], + index, + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _header(BuildContext context, AppLocalizations l10n, int count) { + return Padding( + padding: const EdgeInsets.fromLTRB(6, 4, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + key: const Key('new-releases-back'), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon( + Icons.chevron_left_rounded, + color: Colors.white, + size: 26, + ), + ), + Expanded( + child: Text( + l10n.newReleasesTitle, + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w900, + ), + ), + ), + Container( + key: const Key('new-releases-count'), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + l10n.newReleasesCount(count), + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + l10n.newReleasesSubtitle, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + ], + ), + ); + } + + Widget _heroCard(BuildContext context, AppLocalizations l10n, Drama drama) { + return GestureDetector( + key: const Key('new-releases-hero'), + onTap: () => unawaited(playDramaFromCatalog(context, drama)), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackAspectRatio: 16 / 9, + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00000000), Color(0xB3000000)], + ), + ), + ), + Positioned( + top: 10, + left: 10, + child: _newBadge( + l10n, + key: const Key('new-releases-hero-badge'), + ), + ), + Positioned( + left: 12, + right: 64, + bottom: 10, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.premiereToday, + style: const TextStyle( + color: Colors.white70, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + drama.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon( + Icons.local_fire_department_rounded, + color: AppColors.blue, + size: 12, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + l10n.heatLabel( + formatInteractionCount(drama.viewCount), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + ), + ), + ), + ], + ), + ], + ), + ), + Positioned( + right: 12, + bottom: 12, + child: Container( + key: const Key('new-releases-hero-play'), + width: 40, + height: 40, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 22, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _moreHeader(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: Text( + l10n.rankingNew, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w800, ), ), + ), + Text( + l10n.seeAll, + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ); + } + + Widget _moreCard( + BuildContext context, + AppLocalizations l10n, + Drama drama, + int index, + ) { + return GestureDetector( + key: Key('new-release-card-${drama.id}'), + onTap: () => unawaited(playDramaFromCatalog(context, drama)), + child: SizedBox( + width: 108, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 108, + height: 144, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackAspectRatio: 3 / 4, + ), + Positioned( + top: 8, + left: 8, + child: _newBadge( + l10n, + key: Key('new-release-badge-$index'), + compact: true, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 6), + Text( + drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + _releaseDate(index), + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ), + ); + } + + /// 目录暂无上架时间字段,用倒序位次生成近几日的展示日期。 + String _releaseDate(int index) { + final date = DateTime.now().subtract(Duration(days: index + 1)); + final month = date.month.toString().padLeft(2, '0'); + final day = date.day.toString().padLeft(2, '0'); + return '$month/$day'; + } + + Widget _newBadge( + AppLocalizations l10n, { + required Key key, + bool compact = false, + }) { + return Container( + key: key, + padding: EdgeInsets.symmetric( + horizontal: compact ? 6 : 8, + vertical: compact ? 2 : 3, + ), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + l10n.newBadge, + style: TextStyle( + color: Colors.white, + fontSize: compact ? 9 : 10, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + +class _FilmStrip extends StatelessWidget { + const _FilmStrip(); + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('new-releases-film'), + height: 22, + color: const Color(0x1A000000), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [_holes(), _holes()], + ), + ); + } + + Widget _holes() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (var index = 0; index < _filmHoles; index++) + Container( + width: 6, + height: 8, + decoration: BoxDecoration( + color: _filmHole, + borderRadius: BorderRadius.circular(2), + ), + ), + ], ); } } diff --git a/lib/pages/reserve/reserve_page.dart b/lib/pages/reserve/reserve_page.dart index a680410..1d8189b 100644 --- a/lib/pages/reserve/reserve_page.dart +++ b/lib/pages/reserve/reserve_page.dart @@ -3,46 +3,360 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; +import '../new_releases/new_releases_page.dart'; -/// 剧场预约页:暂无预约数据源,展示空态与即将上线说明。 -class ReservePage extends StatelessWidget { +const double _ticketRadius = 20; +const double _notchSize = 24; +const Color _ticketDash = Color(0x66FF7A1A); + +/// 预约页:档期周历 + 电影票式空态 + 预约权益说明。 +class ReservePage extends StatefulWidget { const ReservePage({super.key}); + @override + State createState() => _ReservePageState(); +} + +class _ReservePageState extends State { + late final List _week; + late int _selected; + + @override + void initState() { + super.initState(); + final today = DateTime.now(); + final monday = today.subtract(Duration(days: today.weekday - 1)); + _week = [ + for (var index = 0; index < 7; index++) monday.add(Duration(days: index)), + ]; + _selected = today.weekday - 1; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return PlainPageScaffold( - pageKey: const Key('reserve-page'), - backKey: const Key('reserve-back'), - title: l10n.reserve, - subtitle: l10n.reserveSubtitle, - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - EmptyState( - key: const Key('reserve-empty'), - icon: Icons.event_available_outlined, - title: l10n.reserveEmptyTitle, - subtitle: l10n.reserveEmptyHint, - ), - const SizedBox(height: 12), - TextButton( - onPressed: null, - child: Text( - l10n.comingSoon, - style: const TextStyle(color: AppColors.subtle), - ), - ), - ], - ), + return Scaffold( + key: const Key('reserve-page'), + backgroundColor: AppColors.ink, + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), + children: [ + _header(context, l10n), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _weekStrip(l10n), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: _ticketCard(context, l10n), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 0), + child: _steps(l10n), + ), + ], ), ), ); } + + Widget _header(BuildContext context, AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB(6, 0, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + key: const Key('reserve-back'), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon( + Icons.chevron_left_rounded, + color: AppColors.onSurface, + size: 26, + ), + ), + Expanded( + child: Text( + l10n.reserveTitle, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 24, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + l10n.reserveSubtitle, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ), + ], + ), + ); + } + + Widget _weekStrip(AppLocalizations l10n) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.reserveWeek, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + SizedBox( + key: const Key('reserve-week-strip'), + height: 56, + child: Row( + children: [ + for (var index = 0; index < _week.length; index++) ...[ + if (index > 0) const SizedBox(width: 6), + Expanded(child: _dayCapsule(index)), + ], + ], + ), + ), + ], + ); + } + + Widget _dayCapsule(int index) { + final date = _week[index]; + final selected = _selected == index; + final weekdays = ['一', '二', '三', '四', '五', '六', '日']; + return GestureDetector( + key: Key('reserve-day-$index'), + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _selected = index), + child: Container( + decoration: BoxDecoration( + color: selected ? AppColors.blue : AppColors.card, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? AppColors.blue : AppColors.border, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + weekdays[date.weekday - 1], + style: TextStyle( + color: selected ? Colors.white : AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + '${date.day}', + style: TextStyle( + color: selected ? Colors.white : AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ); + } + + Widget _ticketCard(BuildContext context, AppLocalizations l10n) { + return LayoutBuilder( + builder: (context, constraints) => Stack( + clipBehavior: Clip.none, + children: [ + Container( + key: const Key('reserve-empty'), + width: double.infinity, + padding: const EdgeInsets.fromLTRB(28, 26, 28, 22), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_ticketRadius), + ), + child: Column( + children: [ + Container( + width: 56, + height: 56, + decoration: const BoxDecoration( + color: AppColors.raised, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.notifications_active_rounded, + color: AppColors.blue, + size: 32, + ), + ), + const SizedBox(height: 12), + Text( + l10n.reserveEmptyTitle, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 17, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + l10n.reserveEmptyHint, + maxLines: 2, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + const SizedBox(height: 16), + SizedBox( + height: 40, + child: FilledButton( + key: const Key('reserve-go-new'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const NewReleasesPage(), + ), + ), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + l10n.goNewReleases, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + Positioned(left: -_notchSize / 2, top: 120, child: _notch()), + Positioned(right: -_notchSize / 2, top: 120, child: _notch()), + Positioned.fill( + child: IgnorePointer( + child: CustomPaint(painter: _DashedTicketPainter()), + ), + ), + ], + ), + ); + } + + Widget _notch() { + return Container( + width: _notchSize, + height: _notchSize, + decoration: const BoxDecoration( + color: AppColors.ink, + shape: BoxShape.circle, + ), + ); + } + + Widget _steps(AppLocalizations l10n) { + final steps = [l10n.reserveStep1, l10n.reserveStep2, l10n.reserveStep3]; + return Column( + key: const Key('reserve-steps'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.reserveStepsTitle, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 10), + for (var index = 0; index < steps.length; index++) ...[ + if (index > 0) const SizedBox(height: 8), + Container( + key: Key('reserve-step-${index + 1}'), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 10), + Text( + steps[index], + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ], + ); + } +} + +class _DashedTicketPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = _ticketDash + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + final path = Path() + ..addRRect( + RRect.fromRectAndRadius( + Offset.zero & size, + const Radius.circular(_ticketRadius), + ), + ); + for (final metric in path.computeMetrics()) { + var distance = 0.0; + while (distance < metric.length) { + final next = (distance + 6).clamp(0.0, metric.length); + canvas.drawPath(metric.extractPath(distance, next), paint); + distance = next + 4; + } + } + } + + @override + bool shouldRepaint(covariant _DashedTicketPainter oldDelegate) => false; } diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index ddb4580..8294393 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -4,10 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/theme/app_colors.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/discover/widgets/drama_grid.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'; @@ -46,12 +46,20 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('new-releases-page')), findsOneWidget); - final grid = tester.widget(find.byType(DramaGrid)); - expect(grid.dramas.map((drama) => drama.id).toList(), [ - 'vengeance-demo', - 'the-signal-demo', - 'city-lights-demo', - ]); + expect(find.byKey(const Key('new-releases-hero')), findsOneWidget); + expect(find.text('Vengeance Rising'), findsWidgets); + expect( + find.byKey(const Key('new-release-card-the-signal-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('new-release-card-city-lights-demo')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('new-releases-hero'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-scaffold')), findsOneWidget); }); testWidgets('should_open_reserve_empty_state', (tester) async { @@ -60,12 +68,26 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('reserve-page')), findsOneWidget); + expect(find.byKey(const Key('reserve-week-strip')), findsOneWidget); + for (var index = 0; index < 7; index++) { + expect(find.byKey(Key('reserve-day-$index')), findsOneWidget); + } expect(find.byKey(const Key('reserve-empty')), findsOneWidget); - expect(find.text('Coming soon'), findsOneWidget); - expect( - tester.widget(find.byType(TextButton)).onPressed, - isNull, + for (var index = 1; index <= 3; index++) { + expect(find.byKey(Key('reserve-step-$index')), findsOneWidget); + } + final todayIndex = DateTime.now().weekday - 1; + final today = tester.widget( + find.descendant( + of: find.byKey(Key('reserve-day-$todayIndex')), + matching: find.byType(Container), + ), ); + expect((today.decoration! as BoxDecoration).color, AppColors.blue); + + await tester.tap(find.byKey(const Key('reserve-go-new'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('new-releases-page')), findsOneWidget); }); testWidgets('should_render_podium_with_center_on_top', (tester) async { @@ -119,7 +141,7 @@ void main() { await tester.tap(find.byKey(const Key('theater-toolbar-new'))); await tester.pumpAndSettle(); - final card = find.byKey(const Key('home-drama-card-the-signal-demo')); + final card = find.byKey(const Key('new-release-card-the-signal-demo')); await tester.ensureVisible(card); await tester.pumpAndSettle(); await tester.tap(card); From 850f8e7c8c6fbb42b425e9802de19fbfecf066d0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:39:46 +0800 Subject: [PATCH 080/124] =?UTF-8?q?feat(flutter):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=89=A7=E5=9C=BA=E7=AD=9B=E9=80=89=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 11 + lib/l10n/app_id.arb | 11 + lib/l10n/app_localizations.dart | 66 +++ lib/l10n/app_localizations_en.dart | 33 ++ lib/l10n/app_localizations_id.dart | 33 ++ lib/l10n/app_localizations_zh.dart | 33 ++ lib/l10n/app_zh.arb | 11 + lib/pages/discover/discover_view.dart | 44 +- lib/pages/filter/filter_page.dart | 463 ++++++++++++++++++ .../filter/widgets/filter_result_card.dart | 122 +++++ test/discover/filter_page_test.dart | 218 +++++++++ test/discover/home_all_dramas_grid_test.dart | 19 +- 12 files changed, 1012 insertions(+), 52 deletions(-) create mode 100644 lib/pages/filter/filter_page.dart create mode 100644 lib/pages/filter/widgets/filter_result_card.dart create mode 100644 test/discover/filter_page_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 692373b..c4de9e9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,6 +29,17 @@ "romance": "Romance", "actionGenre": "Action", "filter": "Filter", + "filterGroupCategory": "All genres", + "filterGroupTheme": "All themes", + "filterGroupLanguage": "All languages", + "filterGroupSort": "All recommendations", + "sortNewest": "Newest", + "sortHottest": "Hottest", + "expandFilters": "Expand", + "collapseFilters": "Collapse", + "filterEmpty": "No dramas match these filters", + "hotBadge": "Hit", + "filterAll": "All", "seriesMeta": "Series · {episodes} eps | {viewers} watching", "@seriesMeta": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 286f866..669b9d3 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -29,6 +29,17 @@ "romance": "Romantis", "actionGenre": "Aksi", "filter": "Filter", + "filterGroupCategory": "Semua genre", + "filterGroupTheme": "Semua tema", + "filterGroupLanguage": "Semua bahasa", + "filterGroupSort": "Semua rekomendasi", + "sortNewest": "Terbaru", + "sortHottest": "Terpopuler", + "expandFilters": "Perluas", + "collapseFilters": "Ciutkan", + "filterEmpty": "Tidak ada drama yang cocok", + "hotBadge": "Hits", + "filterAll": "Semua", "seriesMeta": "Serial · {episodes} eps | {viewers} menonton", "watchFullSeries": "Tonton serial lengkap · {episodes} eps", "feedTabAnime": "Anime", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d74c2aa..6b99116 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -274,6 +274,72 @@ abstract class AppLocalizations { /// **'Filter'** String get filter; + /// No description provided for @filterGroupCategory. + /// + /// In en, this message translates to: + /// **'All genres'** + String get filterGroupCategory; + + /// No description provided for @filterGroupTheme. + /// + /// In en, this message translates to: + /// **'All themes'** + String get filterGroupTheme; + + /// No description provided for @filterGroupLanguage. + /// + /// In en, this message translates to: + /// **'All languages'** + String get filterGroupLanguage; + + /// No description provided for @filterGroupSort. + /// + /// In en, this message translates to: + /// **'All recommendations'** + String get filterGroupSort; + + /// No description provided for @sortNewest. + /// + /// In en, this message translates to: + /// **'Newest'** + String get sortNewest; + + /// No description provided for @sortHottest. + /// + /// In en, this message translates to: + /// **'Hottest'** + String get sortHottest; + + /// No description provided for @expandFilters. + /// + /// In en, this message translates to: + /// **'Expand'** + String get expandFilters; + + /// No description provided for @collapseFilters. + /// + /// In en, this message translates to: + /// **'Collapse'** + String get collapseFilters; + + /// No description provided for @filterEmpty. + /// + /// In en, this message translates to: + /// **'No dramas match these filters'** + String get filterEmpty; + + /// No description provided for @hotBadge. + /// + /// In en, this message translates to: + /// **'Hit'** + String get hotBadge; + + /// No description provided for @filterAll. + /// + /// In en, this message translates to: + /// **'All'** + String get filterAll; + /// No description provided for @seriesMeta. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3f7e73d..239ce3d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -98,6 +98,39 @@ class AppLocalizationsEn extends AppLocalizations { @override String get filter => 'Filter'; + @override + String get filterGroupCategory => 'All genres'; + + @override + String get filterGroupTheme => 'All themes'; + + @override + String get filterGroupLanguage => 'All languages'; + + @override + String get filterGroupSort => 'All recommendations'; + + @override + String get sortNewest => 'Newest'; + + @override + String get sortHottest => 'Hottest'; + + @override + String get expandFilters => 'Expand'; + + @override + String get collapseFilters => 'Collapse'; + + @override + String get filterEmpty => 'No dramas match these filters'; + + @override + String get hotBadge => 'Hit'; + + @override + String get filterAll => 'All'; + @override String seriesMeta(String episodes, String viewers) { return 'Series · $episodes eps | $viewers watching'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 12239da..c7da015 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -98,6 +98,39 @@ class AppLocalizationsId extends AppLocalizations { @override String get filter => 'Filter'; + @override + String get filterGroupCategory => 'Semua genre'; + + @override + String get filterGroupTheme => 'Semua tema'; + + @override + String get filterGroupLanguage => 'Semua bahasa'; + + @override + String get filterGroupSort => 'Semua rekomendasi'; + + @override + String get sortNewest => 'Terbaru'; + + @override + String get sortHottest => 'Terpopuler'; + + @override + String get expandFilters => 'Perluas'; + + @override + String get collapseFilters => 'Ciutkan'; + + @override + String get filterEmpty => 'Tidak ada drama yang cocok'; + + @override + String get hotBadge => 'Hits'; + + @override + String get filterAll => 'Semua'; + @override String seriesMeta(String episodes, String viewers) { return 'Serial · $episodes eps | $viewers menonton'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8cbbaad..49f554d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -95,6 +95,39 @@ class AppLocalizationsZh extends AppLocalizations { @override String get filter => '筛选'; + @override + String get filterGroupCategory => '全部体裁'; + + @override + String get filterGroupTheme => '全部主题'; + + @override + String get filterGroupLanguage => '全部语言'; + + @override + String get filterGroupSort => '全部推荐'; + + @override + String get sortNewest => '最新上架'; + + @override + String get sortHottest => '最高热度'; + + @override + String get expandFilters => '展开'; + + @override + String get collapseFilters => '收起'; + + @override + String get filterEmpty => '没有符合条件的短剧'; + + @override + String get hotBadge => '爆剧'; + + @override + String get filterAll => '全部'; + @override String seriesMeta(String episodes, String viewers) { return '系列剧 · 共$episodes集 | 共$viewers人在追'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c0caaac..8e430b3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -29,6 +29,17 @@ "romance": "都市爱情", "actionGenre": "动作", "filter": "筛选", + "filterGroupCategory": "全部体裁", + "filterGroupTheme": "全部主题", + "filterGroupLanguage": "全部语言", + "filterGroupSort": "全部推荐", + "sortNewest": "最新上架", + "sortHottest": "最高热度", + "expandFilters": "展开", + "collapseFilters": "收起", + "filterEmpty": "没有符合条件的短剧", + "hotBadge": "爆剧", + "filterAll": "全部", "seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追", "watchFullSeries": "观看完整剧集 · 全{episodes}集", "feedTabAnime": "漫剧", diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 4927b76..cb68f9d 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -16,6 +16,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; +import '../filter/filter_page.dart'; import '../new_releases/new_releases_page.dart'; import '../player/open_player.dart'; import '../ranking/ranking_page.dart'; @@ -261,7 +262,7 @@ class _DiscoverPageState extends State { ? categories.feed.dramas : categories.dramasOf(logic.selectedCategoryId!); return [ - _toolbar(l10n, categories), + _toolbar(l10n), if (dramas.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 40), @@ -350,7 +351,7 @@ class _DiscoverPageState extends State { ); } - Widget _toolbar(AppLocalizations l10n, CategoryController categories) { + Widget _toolbar(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.fromLTRB(14, 2, 14, 12), child: Row( @@ -361,7 +362,7 @@ class _DiscoverPageState extends State { icon: Icons.tune_rounded, color: _filterIconColor, label: l10n.filter, - onTap: () => _openFilterSheet(l10n, categories), + onTap: () => _openPage(context, const FilterPage()), ), ), const SizedBox(width: _toolbarGap), @@ -443,43 +444,6 @@ class _DiscoverPageState extends State { ); } - Future _openFilterSheet( - AppLocalizations l10n, - CategoryController categories, - ) async { - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: ListView( - key: const Key('theater-filter-sheet'), - shrinkWrap: true, - padding: const EdgeInsets.only(bottom: 12), - children: [ - ListTile( - key: const Key('theater-filter-option-all'), - title: Text(l10n.all), - onTap: () { - Navigator.pop(sheetContext); - logic.selectCategory(null); - }, - ), - for (final category in categories.categories) - ListTile( - key: Key('theater-filter-option-${category.id}'), - title: Text(category.name), - onTap: () { - Navigator.pop(sheetContext); - logic.selectCategory(category.id); - }, - ), - ], - ), - ), - ); - } - void _openPage(BuildContext context, Widget page) { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } diff --git a/lib/pages/filter/filter_page.dart b/lib/pages/filter/filter_page.dart new file mode 100644 index 0000000..3828c02 --- /dev/null +++ b/lib/pages/filter/filter_page.dart @@ -0,0 +1,463 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/category_controller.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; +import 'widgets/filter_result_card.dart'; + +const String _allValue = '__all__'; +const double _pageHorizontal = 14; +const double _groupHorizontal = 12; +const double _groupVertical = 9; +const double _labelWidth = 84; +const double _groupGap = 10; +const double _optionHorizontal = 5; +const double _optionVertical = 5; +const double _optionRunGap = 4; +const double _cardGap = 14; +const double _gridAspectRatio = 0.56; +const double _groupFontSize = 13; +const double _labelFontSize = 12; + +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + +/// 剧场筛选页:分组条件实时过滤三列短剧结果。 +class FilterPage extends StatefulWidget { + const FilterPage({super.key}); + + @override + State createState() => _FilterPageState(); +} + +class _FilterPageState extends State { + bool _expanded = false; + String _category = _allValue; + String _theme = _allValue; + String _language = _allValue; + _SortMode _sort = _SortMode.recommended; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final categories = context.watch(); + final catalog = context.watch(); + final categoryOptions = _uniqueValues( + categories.categories.map((category) => category.name), + ); + final themeOptions = _uniqueValues(catalog.dramas.expand(_themeValues)); + final languageOptions = _uniqueValues( + catalog.dramas.map((drama) => drama.language), + ); + final results = _filtered(catalog.dramas); + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark, + child: Scaffold( + key: const Key('filter-page'), + backgroundColor: AppColors.ink, + body: SafeArea( + child: Column( + children: [ + _topBar(l10n), + Expanded( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _groups( + l10n, + categoryOptions, + themeOptions, + languageOptions, + ), + ), + if (results.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: const Key('filter-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.filterEmpty, + subtitle: l10n.emptyHint, + ), + ) + else + SliverPadding( + key: const Key('filter-results'), + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 10, + _pageHorizontal, + 24, + ), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: _cardGap, + mainAxisSpacing: _cardGap, + childAspectRatio: _gridAspectRatio, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => FilterResultCard( + drama: results[index], + l10n: l10n, + categoryLabel: _metaCategory(results[index]), + ), + childCount: results.length, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _topBar(AppLocalizations l10n) { + return SizedBox( + height: 52, + child: Row( + children: [ + SizedBox( + key: const Key('filter-back'), + width: 48, + height: 48, + child: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + icon: const Icon( + Icons.chevron_left_rounded, + color: AppColors.onSurface, + size: 26, + ), + ), + ), + Expanded( + child: Center( + child: Text( + l10n.filter, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + const SizedBox(width: 48), + ], + ), + ); + } + + Widget _groups( + AppLocalizations l10n, + List categories, + List themes, + List languages, + ) { + return Padding( + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 2, + _pageHorizontal, + 0, + ), + child: Column( + key: const Key('filter-groups'), + children: [ + _group( + group: _FilterGroup.category, + label: _selectedLabel(_category, l10n.filterGroupCategory), + values: categories, + selected: _category, + onSelected: (value) => setState(() => _category = value), + allLabel: l10n.filterAll, + ), + _group( + group: _FilterGroup.theme, + label: _selectedLabel(_theme, l10n.filterGroupTheme), + values: themes, + selected: _theme, + onSelected: (value) => setState(() => _theme = value), + allLabel: l10n.filterAll, + ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _expanded + ? Column( + children: [ + _group( + group: _FilterGroup.language, + label: _languageLabel(_language, l10n), + values: languages, + selected: _language, + onSelected: (value) => + setState(() => _language = value), + allLabel: l10n.filterAll, + optionLabel: (value) => _languageLabel(value, l10n), + ), + _group( + group: _FilterGroup.sort, + label: _sortLabel(_sort, l10n), + values: const ['newest', 'hottest'], + selected: switch (_sort) { + _SortMode.recommended => _allValue, + _SortMode.newest => 'newest', + _SortMode.hottest => 'hottest', + }, + onSelected: (value) => setState(() { + _sort = switch (value) { + 'newest' => _SortMode.newest, + 'hottest' => _SortMode.hottest, + _ => _SortMode.recommended, + }; + }), + allLabel: l10n.filterGroupSort, + optionLabel: (value) => switch (value) { + 'newest' => l10n.sortNewest, + 'hottest' => l10n.sortHottest, + _ => l10n.filterGroupSort, + }, + ), + ], + ) + : const SizedBox.shrink(), + ), + TextButton.icon( + key: const Key('filter-expand'), + onPressed: () => setState(() => _expanded = !_expanded), + style: TextButton.styleFrom( + foregroundColor: AppColors.muted, + minimumSize: const Size(88, 38), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + iconAlignment: IconAlignment.end, + icon: Icon( + _expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 18, + ), + label: Text( + _expanded ? l10n.collapseFilters : l10n.expandFilters, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } + + Widget _group({ + required _FilterGroup group, + required String label, + required List values, + required String selected, + required ValueChanged onSelected, + required String allLabel, + String Function(String value)? optionLabel, + }) { + final options = [ + _FilterOption(key: 'all', value: _allValue, label: allLabel), + for (final value in values) + _FilterOption( + key: value, + value: value, + label: optionLabel?.call(value) ?? value, + ), + ]; + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: _groupHorizontal, + vertical: _groupVertical, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + key: Key('filter-group-label-${group.name}'), + width: _labelWidth, + constraints: const BoxConstraints(minHeight: 28), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.badgeBeige, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.blue, + fontSize: _labelFontSize, + fontWeight: FontWeight.w700, + height: 1.15, + ), + ), + ), + const SizedBox(width: _groupGap), + Expanded( + child: Wrap( + spacing: 8, + runSpacing: _optionRunGap, + children: [ + for (final option in options) + _option( + group: group, + option: option, + selected: selected == option.value, + onTap: () => onSelected(option.value), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _option({ + required _FilterGroup group, + required _FilterOption option, + required bool selected, + required VoidCallback onTap, + }) { + return GestureDetector( + key: Key('filter-option-${group.name}-${option.key}'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _optionHorizontal, + vertical: _optionVertical, + ), + child: Text( + option.label, + style: TextStyle( + color: selected ? AppColors.blue : AppColors.onSurface, + fontSize: _groupFontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w500, + ), + ), + ), + ); + } + + List _filtered(List dramas) { + final result = dramas.where((drama) { + if (_category != _allValue && drama.category != _category) return false; + if (_theme != _allValue && !_themeValues(drama).contains(_theme)) { + return false; + } + if (_language != _allValue && drama.language != _language) return false; + return true; + }).toList(); + switch (_sort) { + case _SortMode.recommended: + break; + case _SortMode.newest: + result.sort((a, b) => _compareIds(b.id, a.id)); + case _SortMode.hottest: + result.sort((a, b) => b.viewCount.compareTo(a.viewCount)); + } + return result; + } + + String _selectedLabel(String value, String fallback) { + if (value == _allValue) return fallback; + return value; + } + + String _languageLabel(String value, AppLocalizations l10n) { + if (value == _allValue) return l10n.filterGroupLanguage; + return _languageNames[value.toLowerCase()] ?? value; + } + + String _sortLabel(_SortMode value, AppLocalizations l10n) { + return switch (value) { + _SortMode.recommended => l10n.filterGroupSort, + _SortMode.newest => l10n.sortNewest, + _SortMode.hottest => l10n.sortHottest, + }; + } + + String _metaCategory(Drama drama) { + if (drama.category.trim().isNotEmpty) return drama.category.trim(); + final themes = _themeValues(drama); + return themes.isEmpty ? '' : themes.first; + } +} + +class _FilterOption { + const _FilterOption({ + required this.key, + required this.value, + required this.label, + }); + + final String key; + final String value; + final String label; +} + +enum _FilterGroup { category, theme, language, sort } + +enum _SortMode { recommended, newest, hottest } + +const Map _languageNames = { + 'zh': '简体中文', + 'zh-cn': '简体中文', + 'zh-hans': '简体中文', + 'en': 'English', + 'en-us': 'English', + 'id': 'Bahasa Indonesia', + 'id-id': 'Bahasa Indonesia', +}; + +List _themeValues(Drama drama) { + final values = []; + for (final source in [drama.genre, drama.category]) { + for (final part in source.split(_tagSplitter)) { + final value = part.trim(); + if (value.isNotEmpty && !values.contains(value)) values.add(value); + } + } + return values; +} + +List _uniqueValues(Iterable source) { + final result = []; + for (final raw in source) { + final value = raw.trim(); + if (value.isNotEmpty && !result.contains(value)) result.add(value); + } + return result; +} + +int _compareIds(String a, String b) { + final numberA = int.tryParse(a); + final numberB = int.tryParse(b); + if (numberA != null && numberB != null) return numberA.compareTo(numberB); + return a.compareTo(b); +} diff --git a/lib/pages/filter/widgets/filter_result_card.dart b/lib/pages/filter/widgets/filter_result_card.dart new file mode 100644 index 0000000..d51a8a2 --- /dev/null +++ b/lib/pages/filter/widgets/filter_result_card.dart @@ -0,0 +1,122 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../player/open_player.dart'; + +const double _coverRadius = 10; +const double _titleGap = 6; +const double _metaGap = 2; +const double _badgeHorizontal = 5; +const double _badgeVertical = 2; +const double _badgeRadius = 6; +const double _titleFontSize = 12.5; +const double _metaFontSize = 11; +const double _badgeFontSize = 9; + +/// 筛选结果三列卡片:封面、标题、集数与热度和分类元信息。 +class FilterResultCard extends StatelessWidget { + const FilterResultCard({ + super.key, + required this.drama, + required this.l10n, + required this.categoryLabel, + }); + + final Drama drama; + final AppLocalizations l10n; + final String categoryLabel; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: Key('filter-card-${drama.id}'), + behavior: HitTestBehavior.opaque, + onTap: () => unawaited(playDramaFromCatalog(context, drama)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(_coverRadius), + child: AspectRatio( + aspectRatio: 3 / 4, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackAspectRatio: 3 / 4, + ), + if (drama.isHot) + Positioned( + top: 6, + right: 6, + child: Container( + key: Key('filter-badge-hot-${drama.id}'), + padding: const EdgeInsets.symmetric( + horizontal: _badgeHorizontal, + vertical: _badgeVertical, + ), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(_badgeRadius), + ), + child: Text( + l10n.hotBadge, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: _badgeFontSize, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: _titleGap), + Text( + drama.title, + key: Key('filter-title-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: _titleFontSize, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: _metaGap), + Text( + _meta(), + key: Key('filter-meta-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: _metaFontSize, + ), + ), + ], + ), + ); + } + + String _meta() { + return [ + l10n.episodeCountLabel(drama.episodeCount), + formatInteractionCount(drama.viewCount), + if (categoryLabel.isNotEmpty) categoryLabel, + ].join(' · '); + } +} diff --git a/test/discover/filter_page_test.dart b/test/discover/filter_page_test.dart new file mode 100644 index 0000000..cbfea3b --- /dev/null +++ b/test/discover/filter_page_test.dart @@ -0,0 +1,218 @@ +// 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/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.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: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.empty(); + }); + + testWidgets('should_expand_all_filter_groups_when_expand_is_tapped', ( + tester, + ) async { + await _pumpFilterPage(tester); + + expect(find.byKey(const Key('filter-page')), findsOneWidget); + expect( + find.byKey(const Key('filter-group-label-category')), + findsOneWidget, + ); + expect(find.byKey(const Key('filter-group-label-theme')), findsOneWidget); + expect(find.byKey(const Key('filter-group-label-language')), findsNothing); + expect(find.byKey(const Key('filter-group-label-sort')), findsNothing); + + await tester.tap(find.byKey(const Key('filter-expand'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('filter-group-label-language')), + findsOneWidget, + ); + expect(find.byKey(const Key('filter-group-label-sort')), findsOneWidget); + }); + + testWidgets('should_filter_and_sort_dramas_when_options_are_tapped', ( + tester, + ) async { + await _pumpFilterPage(tester); + await tester.tap(find.byKey(const Key('filter-expand'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('filter-option-category-Romance'))); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byKey(const Key('filter-group-label-category')), + matching: find.text('Romance'), + ), + findsOneWidget, + ); + expect(find.byKey(const Key('filter-card-1001')), findsOneWidget); + expect(find.byKey(const Key('filter-card-1003')), findsOneWidget); + expect(find.byKey(const Key('filter-card-1002')), findsNothing); + + await tester.tap(find.byKey(const Key('filter-option-sort-hottest'))); + await tester.pumpAndSettle(); + + final hottestFirst = tester.getTopLeft( + find.byKey(const Key('filter-card-1001')), + ); + final hottestSecond = tester.getTopLeft( + find.byKey(const Key('filter-card-1003')), + ); + expect(hottestFirst.dx, lessThan(hottestSecond.dx)); + + await tester.tap(find.byKey(const Key('filter-option-sort-newest'))); + await tester.pumpAndSettle(); + + final newestFirst = tester.getTopLeft( + find.byKey(const Key('filter-card-1003')), + ); + final newestSecond = tester.getTopLeft( + find.byKey(const Key('filter-card-1001')), + ); + expect(newestFirst.dx, lessThan(newestSecond.dx)); + }); + + testWidgets('should_open_player_when_filter_card_is_tapped', (tester) async { + await _pumpFilterPage(tester); + + expect(find.byKey(const Key('filter-badge-hot-1001')), findsOneWidget); + expect(find.byKey(const Key('filter-badge-hot-1003')), findsNothing); + final meta = tester.widget(find.byKey(const Key('filter-meta-1001'))); + expect(meta.data, contains('12')); + expect(meta.data, contains('300')); + expect(meta.data, contains('Romance')); + + await tester.tap(find.byKey(const Key('filter-card-1001'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-scaffold')), findsOneWidget); + }); + + testWidgets('should_show_empty_state_when_filters_match_nothing', ( + tester, + ) async { + await _pumpFilterPage(tester); + await tester.tap(find.byKey(const Key('filter-expand'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('filter-option-category-Romance'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('filter-option-language-en'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('filter-empty')), findsOneWidget); + }); +} + +Future _pumpFilterPage(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(dramas: _filterDramas); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + searchRepository: catalog, + categoryRepository: FakeCategoryRepository( + catalog: catalog, + categories: const [ + ContentCategory(id: '1', name: 'Romance'), + ContentCategory(id: '2', name: 'Action'), + ], + ), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('theater-toolbar-filter'))); + await tester.pumpAndSettle(); +} + +final List _filterDramas = [ + _drama( + id: '1001', + title: 'Alpha', + category: 'Romance', + genre: 'Suspense · Mystery', + language: 'id', + viewCount: 300, + isHot: true, + ), + _drama( + id: '1002', + title: 'Beta', + category: 'Action', + genre: 'Action · Thriller', + language: 'en', + viewCount: 500, + ), + _drama( + id: '1003', + title: 'Gamma', + category: 'Romance', + genre: 'Suspense', + language: 'zh-CN', + viewCount: 100, + ), +]; + +Drama _drama({ + required String id, + required String title, + required String category, + required String genre, + required String language, + required int viewCount, + bool isHot = false, +}) { + return Drama( + id: id, + title: title, + synopsis: '$title synopsis', + posterUrl: '', + category: category, + genre: genre, + totalEpisodes: 12, + viewCount: viewCount, + language: language, + isHot: isHot, + detailLoaded: true, + episodes: [ + Episode( + id: '$id-1', + number: 1, + title: 'Episode 1', + isFree: true, + videoUrl: 'https://example.test/$id.mp4', + ), + ], + ); +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 4401ea1..77c34fd 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -51,25 +51,20 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_open_filter_sheet_and_apply_category', (tester) async { + testWidgets('should_open_filter_page_from_theater_toolbar', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); await tester.tap(find.byKey(const Key('theater-toolbar-filter'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('theater-filter-sheet')), findsOneWidget); - await tester.tap(find.byKey(const Key('theater-filter-option-7'))); + expect(find.byKey(const Key('filter-page')), findsOneWidget); + expect(find.byKey(const Key('filter-groups')), findsOneWidget); + + await tester.tap(find.byKey(const Key('filter-back'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('theater-filter-sheet')), findsNothing); - expect( - find.byKey(const Key('home-drama-card-city-lights-demo')), - findsOneWidget, - ); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsNothing, - ); + expect(find.byKey(const Key('filter-page')), findsNothing); + expect(find.byKey(const Key('discover-search')), findsOneWidget); }); testWidgets('should_filter_grid_when_category_chip_is_tapped', ( From 69faa7818c0510da687af8ad52113f1f12161ab0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:46:53 +0800 Subject: [PATCH 081/124] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E5=81=9A?= =?UTF-8?q?=E7=9F=AD=E5=89=A7=E8=AF=A6=E6=83=85=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_id.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_zh.arb | 1 + lib/pages/drama_detail/drama_detail_view.dart | 485 ++++++------------ .../widgets/drama_detail_bottom_bar.dart | 109 ++++ .../widgets/drama_detail_header.dart | 236 +++++++++ .../widgets/drama_detail_sections.dart | 406 +++++++++++++++ test/drama_detail/drama_detail_view_test.dart | 352 ++++++++----- 12 files changed, 1136 insertions(+), 470 deletions(-) create mode 100644 lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart create mode 100644 lib/pages/drama_detail/widgets/drama_detail_header.dart create mode 100644 lib/pages/drama_detail/widgets/drama_detail_sections.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c4de9e9..bdfda89 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -276,6 +276,7 @@ } }, "episodes": "Episodes", + "synopsisTitle": "Synopsis", "watchEpisodeOne": "Watch episode 1", "continueEpisode": "Continue episode {number}", "@continueEpisode": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 669b9d3..eba5ffb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -250,6 +250,7 @@ } }, "episodes": "Episode", + "synopsisTitle": "Sinopsis", "watchEpisodeOne": "Tonton episode 1", "continueEpisode": "Lanjut episode {number}", "@continueEpisode": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6b99116..a646edf 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1168,6 +1168,12 @@ abstract class AppLocalizations { /// **'Episodes'** String get episodes; + /// No description provided for @synopsisTitle. + /// + /// In en, this message translates to: + /// **'Synopsis'** + String get synopsisTitle; + /// No description provided for @watchEpisodeOne. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 239ce3d..f7bca93 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -583,6 +583,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get episodes => 'Episodes'; + @override + String get synopsisTitle => 'Synopsis'; + @override String get watchEpisodeOne => 'Watch episode 1'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index c7da015..08a7bbb 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -584,6 +584,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get episodes => 'Episode'; + @override + String get synopsisTitle => 'Sinopsis'; + @override String get watchEpisodeOne => 'Tonton episode 1'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 49f554d..f29c0bd 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -576,6 +576,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get episodes => '选集'; + @override + String get synopsisTitle => '简介'; + @override String get watchEpisodeOne => '观看第 1 集'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8e430b3..00541ed 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -236,6 +236,7 @@ "episodeCount": "{count} 集", "technicalDemoCount": "免费试看 · {count} 集", "episodes": "选集", + "synopsisTitle": "简介", "watchEpisodeOne": "观看第 1 集", "continueEpisode": "续看第 {number} 集", "freeTestMedia": "复用同一演示视频 · 免费", diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 0f9ef45..208ab48 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -16,22 +16,29 @@ import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../interaction/widgets/report_dialog.dart'; import 'drama_detail_binding.dart'; import 'drama_detail_logic.dart'; +import 'widgets/drama_detail_bottom_bar.dart'; +import 'widgets/drama_detail_header.dart'; +import 'widgets/drama_detail_sections.dart'; const String _menuFavorite = 'favorite'; const String _menuDownload = 'download'; const String _menuShare = 'share'; const String _menuReport = 'report'; +const int _maxTags = 4; +const double _moreSize = 36; +const double _moreIconSize = 18; +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + +/// 短剧详情页:暖色主信息、简介、标签、选集和固定播放入口。 class DramaDetailPage extends StatefulWidget { const DramaDetailPage({super.key, required this.drama}); @@ -87,15 +94,52 @@ class _DramaDetailPageState extends State { if (logic.failed) return _failed(l10n); final drama = logic.drama; if (drama.episodes.isEmpty) return _empty(l10n, drama); - + final saved = context.watch().isSaved(drama.id); + final continueIndex = logic.continueIndex; return Scaffold( backgroundColor: AppColors.ink, + bottomNavigationBar: DramaDetailBottomBar( + l10n: l10n, + continueIndex: continueIndex, + onPlay: () => logic.openPlayer(context, continueIndex ?? 0), + resume: _resume(l10n), + ), body: CustomScrollView( key: const Key('detail-scroll'), slivers: [ - SliverToBoxAdapter(child: _hero(context, l10n, drama)), - SliverToBoxAdapter(child: _content(context, l10n, drama)), - const SliverToBoxAdapter(child: SizedBox(height: 28)), + SliverToBoxAdapter( + child: DramaDetailHeader( + l10n: l10n, + drama: drama, + saved: saved, + actions: DramaDetailHeaderActions( + onBack: () => Navigator.of(context).maybePop(), + onShare: () => unawaited(_shareDrama(l10n, drama)), + onFavorite: () => unawaited(_toggleFavorite(l10n)), + moreMenu: _moreMenu(context, l10n, saved), + ), + ), + ), + SliverToBoxAdapter( + child: DramaStatsCard(l10n: l10n, drama: drama), + ), + SliverToBoxAdapter( + child: DramaSynopsisSection( + l10n: l10n, + synopsis: localizedDramaSynopsis(l10n, drama), + ), + ), + if (_tagValues(drama).isNotEmpty) + SliverToBoxAdapter(child: DramaTags(tags: _tagValues(drama))), + SliverToBoxAdapter( + child: DramaEpisodeGrid( + l10n: l10n, + drama: drama, + continueIndex: continueIndex, + onOpen: (index) => logic.openPlayer(context, index), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), ], ), ); @@ -104,8 +148,8 @@ class _DramaDetailPageState extends State { Widget _loading(AppLocalizations l10n) => Scaffold( backgroundColor: AppColors.ink, appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, + backgroundColor: AppColors.surfaceTop, + foregroundColor: AppColors.onSurface, title: Text(localizedDramaTitle(l10n, widget.drama)), ), body: const Center(child: CircularProgressIndicator()), @@ -114,8 +158,8 @@ class _DramaDetailPageState extends State { Widget _failed(AppLocalizations l10n) => Scaffold( backgroundColor: AppColors.ink, appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, + backgroundColor: AppColors.surfaceTop, + foregroundColor: AppColors.onSurface, title: Text(localizedDramaTitle(l10n, widget.drama)), ), body: Center( @@ -140,8 +184,8 @@ class _DramaDetailPageState extends State { Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold( backgroundColor: AppColors.ink, appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, + backgroundColor: AppColors.surfaceTop, + foregroundColor: AppColors.onSurface, title: Text(localizedDramaTitle(l10n, drama)), ), body: EmptyState( @@ -154,236 +198,45 @@ class _DramaDetailPageState extends State { ), ); - Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) { - return SizedBox( - key: const Key('detail-hero'), - height: 190, - child: Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, + Widget? _resume(AppLocalizations l10n) { + final fraction = _continueFraction; + final progressError = logic.progressError; + if (fraction == null && progressError == null) return null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (fraction != null) + DramaResumeBar( + fraction: fraction, + label: l10n.resumeProgress( + _clock(logic.progress!.position), + _clock(logic.progress!.duration), + ), ), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0x33000000), AppColors.onSurface], + if (progressError != null) ...[ + if (fraction != null) const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + errorText(l10n, progressError, l10n.progressError), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.muted, fontSize: 11), + ), ), - ), - ), - Positioned( - top: MediaQuery.paddingOf(context).top + 14, - left: 14, - child: _RoundAction( - key: const Key('detail-back'), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Icons.chevron_left_rounded, - onPressed: () => Navigator.of(context).maybePop(), - ), - ), - Positioned( - top: MediaQuery.paddingOf(context).top + 14, - right: 14, - child: _moreMenu(context, l10n), + TextButton( + onPressed: () => unawaited(logic.loadContinue()), + child: Text(l10n.retry), + ), + ], ), ], - ), + ], ); } - Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) { - final continueIndex = logic.continueIndex; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.episodeCount(drama.episodeCount), - key: const Key('detail-episode-count'), - style: const TextStyle( - color: AppColors.secondary, - fontSize: 9, - fontWeight: FontWeight.w800, - letterSpacing: 1, - ), - ), - const SizedBox(height: 7), - Text( - localizedDramaTitle(l10n, drama), - key: const Key('detail-title'), - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 18, - fontWeight: FontWeight.w800, - height: 1.2, - ), - ), - const SizedBox(height: 10), - Text( - localizedDramaSynopsis(l10n, drama), - key: const Key('detail-synopsis'), - style: const TextStyle( - color: AppColors.muted, - fontSize: 11, - height: 1.6, - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - height: 40, - child: FilledButton.icon( - key: const Key('detail-cta'), - onPressed: () => logic.openPlayer(context, continueIndex ?? 0), - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppDimens.buttonRadius), - ), - textStyle: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - ), - ), - icon: const Icon(Icons.play_arrow_rounded, size: 18), - label: Text( - continueIndex == null - ? l10n.startWatching - : l10n.continueEpisode(continueIndex + 1), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - if (_continueFraction != null) ...[ - const SizedBox(height: 8), - _ResumeBar( - fraction: _continueFraction!, - label: l10n.resumeProgress( - _clock(logic.progress!.position), - _clock(logic.progress!.duration), - ), - ), - ], - if (logic.progressError != null) ...[ - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Text( - errorText(l10n, logic.progressError, l10n.progressError), - style: const TextStyle( - color: AppColors.muted, - fontSize: 11, - ), - ), - ), - TextButton( - onPressed: () => unawaited(logic.loadContinue()), - child: Text(l10n.retry), - ), - ], - ), - ], - const SizedBox(height: 18), - Text( - l10n.episodes, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 8), - LayoutBuilder( - builder: (context, constraints) { - final sixColumnsWidth = (constraints.maxWidth - 5 * 8) / 6; - final columns = sixColumnsWidth >= 40 ? 6 : 5; - final selectedIndex = continueIndex ?? 0; - final selectedNumber = drama.episodes.isEmpty - ? null - : drama.episodes[selectedIndex].number; - final ordered = [ - for (var index = 0; index < drama.episodes.length; index++) - (index: index, episode: drama.episodes[index]), - ]..sort((a, b) => a.episode.number.compareTo(b.episode.number)); - return GridView.builder( - key: const Key('detail-episode-chips'), - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - itemCount: ordered.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 1.35, - ), - itemBuilder: (context, position) { - final item = ordered[position]; - return _episodeChip( - context, - item.episode, - item.index, - selectedNumber, - ); - }, - ); - }, - ), - ], - ), - ); - } - - Widget _episodeChip( - BuildContext context, - Episode episode, - int index, - int? selectedNumber, - ) { - final selected = episode.number == selectedNumber; - final locked = episode.locked || (!episode.isFree && index > 0); - return InkWell( - key: Key('detail-episode-chip-${episode.number}'), - onTap: () => logic.openPlayer(context, index), - borderRadius: BorderRadius.circular(9), - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected - ? AppColors.blue - : locked - ? AppColors.secondary.withValues(alpha: 0.16) - : AppColors.raised, - borderRadius: BorderRadius.circular(9), - ), - child: Text( - '${episode.number}', - style: TextStyle( - color: selected - ? AppColors.onPrimary - : locked - ? AppColors.secondary - : AppColors.muted, - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - ), - ); - } - - Widget _moreMenu(BuildContext context, AppLocalizations l10n) { - final saved = context.watch().isSaved(logic.drama.id); + Widget _moreMenu(BuildContext context, AppLocalizations l10n, bool saved) { return PopupMenuButton( key: const Key('detail-more'), tooltip: l10n.more, @@ -402,78 +255,64 @@ class _DramaDetailPageState extends State { }, itemBuilder: (_) => [ PopupMenuItem( - key: const Key('detail-favorite'), + key: const Key('detail-menu-favorite'), value: _menuFavorite, - child: Row( - children: [ - Icon( - saved ? Icons.star_rounded : Icons.star_border_rounded, - color: saved ? AppColors.blue : AppColors.muted, - size: 18, - ), - const SizedBox(width: 9), - Text(l10n.favorite), - ], + child: _menuItem( + saved ? Icons.star_rounded : Icons.star_border_rounded, + l10n.favorite, + saved ? AppColors.blue : AppColors.muted, ), ), PopupMenuItem( key: const Key('detail-download'), value: _menuDownload, - child: Row( - children: [ - const Icon( - Icons.download_outlined, - color: AppColors.muted, - size: 18, - ), - const SizedBox(width: 9), - Text(l10n.downloads), - ], + child: _menuItem( + Icons.download_outlined, + l10n.downloads, + AppColors.muted, ), ), PopupMenuItem( - key: const Key('detail-share'), + key: const Key('detail-menu-share'), value: _menuShare, - child: Row( - children: [ - const Icon( - Icons.share_outlined, - color: AppColors.muted, - size: 18, - ), - const SizedBox(width: 9), - Text(l10n.share), - ], - ), + child: _menuItem(Icons.share_outlined, l10n.share, AppColors.muted), ), PopupMenuItem( key: const Key('detail-report'), value: _menuReport, - child: Row( - children: [ - const Icon(Icons.flag_outlined, color: AppColors.muted, size: 18), - const SizedBox(width: 9), - Text(l10n.reportDrama), - ], + child: _menuItem( + Icons.flag_outlined, + l10n.reportDrama, + AppColors.muted, ), ), ], child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.card.withValues(alpha: 0.92), + width: _moreSize, + height: _moreSize, + decoration: const BoxDecoration( + color: AppColors.card, shape: BoxShape.circle, ), child: const Icon( Icons.more_horiz_rounded, - size: 18, + size: _moreIconSize, color: AppColors.onSurface, ), ), ); } + Widget _menuItem(IconData icon, String label, Color color) { + return Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 9), + Text(label), + ], + ); + } + Future _shareDrama(AppLocalizations l10n, Drama drama) async { try { await const PlatformShareService().shareDrama( @@ -536,6 +375,23 @@ class _DramaDetailPageState extends State { return record.fraction; } + List _tagValues(Drama drama) { + final result = []; + for (final source in [drama.category, drama.genre]) { + for (final part in source.split(_tagSplitter)) { + final value = part.trim(); + if (value.isNotEmpty && !result.contains(value)) result.add(value); + if (result.length == _maxTags) return result; + } + } + final language = drama.language.trim(); + if (language.isNotEmpty) { + final label = _languageNames[language.toLowerCase()] ?? language; + if (!result.contains(label)) result.add(label); + } + return result.take(_maxTags).toList(); + } + String _clock(Duration duration) { final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); @@ -545,65 +401,12 @@ class _DramaDetailPageState extends State { } } -class _RoundAction extends StatelessWidget { - const _RoundAction({ - super.key, - required this.tooltip, - required this.icon, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - return Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.card.withValues(alpha: 0.92), - shape: BoxShape.circle, - ), - child: IconButton( - tooltip: tooltip.isEmpty ? null : tooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - icon: Icon(icon, size: 18, color: AppColors.onSurface), - ), - ); - } -} - -class _ResumeBar extends StatelessWidget { - const _ResumeBar({required this.fraction, this.label}); - - final double fraction; - final String? label; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator( - value: fraction, - minHeight: 3, - backgroundColor: AppColors.border, - valueColor: const AlwaysStoppedAnimation(AppColors.blue), - ), - ), - if (label != null) ...[ - const SizedBox(height: 4), - Text( - label!, - style: const TextStyle(color: AppColors.muted, fontSize: 10), - ), - ], - ], - ); - } -} +const Map _languageNames = { + 'zh': '简体中文', + 'zh-cn': '简体中文', + 'zh-hans': '简体中文', + 'en': 'English', + 'en-us': 'English', + 'id': 'Bahasa Indonesia', + 'id-id': 'Bahasa Indonesia', +}; diff --git a/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart b/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart new file mode 100644 index 0000000..e2ae9ab --- /dev/null +++ b/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart @@ -0,0 +1,109 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _ctaHeight = 46; +const double _ctaRadius = 23; +const double _horizontal = 14; + +/// 详情页固定底部按钮与可选续播信息。 +class DramaDetailBottomBar extends StatelessWidget { + const DramaDetailBottomBar({ + super.key, + required this.l10n, + required this.continueIndex, + required this.onPlay, + this.resume, + }); + + final AppLocalizations l10n; + final int? continueIndex; + final VoidCallback onPlay; + final Widget? resume; + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('detail-bottom-bar'), + decoration: const BoxDecoration( + color: AppColors.ink, + border: Border(top: BorderSide(color: AppColors.border)), + ), + child: SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(_horizontal, 8, _horizontal, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (resume != null) ...[resume!, const SizedBox(height: 8)], + SizedBox( + width: double.infinity, + height: _ctaHeight, + child: FilledButton.icon( + key: const Key('detail-cta'), + onPressed: onPlay, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_ctaRadius), + ), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 20), + label: Text( + continueIndex == null + ? l10n.startWatching + : l10n.continueEpisode(continueIndex! + 1), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 详情页续播进度条与可选时间说明。 +class DramaResumeBar extends StatelessWidget { + const DramaResumeBar({super.key, required this.fraction, this.label}); + + final double fraction; + final String? label; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: fraction, + minHeight: 3, + backgroundColor: AppColors.border, + valueColor: const AlwaysStoppedAnimation(AppColors.blue), + ), + ), + if (label != null) ...[ + const SizedBox(height: 3), + Text( + label!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.muted, fontSize: 10), + ), + ], + ], + ); + } +} diff --git a/lib/pages/drama_detail/widgets/drama_detail_header.dart b/lib/pages/drama_detail/widgets/drama_detail_header.dart new file mode 100644 index 0000000..e414d8a --- /dev/null +++ b/lib/pages/drama_detail/widgets/drama_detail_header.dart @@ -0,0 +1,236 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _horizontal = 14; +const double _topGap = 8; +const double _posterWidth = 96; +const double _posterHeight = 128; +const double _posterRadius = 12; +const double _infoGap = 14; +const double _favoriteHeight = 34; +const double _favoriteRadius = 17; +const double _favoriteHorizontal = 12; +const double _circleSize = 36; +const double _iconSize = 18; + +/// 详情页顶部操作与短剧主信息所需的交互回调。 +class DramaDetailHeaderActions { + const DramaDetailHeaderActions({ + required this.onBack, + required this.onShare, + required this.onFavorite, + required this.moreMenu, + }); + + final VoidCallback onBack; + final VoidCallback onShare; + final VoidCallback onFavorite; + final Widget moreMenu; +} + +/// 详情页暖色渐变头部:操作行、封面、标题、集数与收藏按钮。 +class DramaDetailHeader extends StatelessWidget { + const DramaDetailHeader({ + super.key, + required this.l10n, + required this.drama, + required this.saved, + required this.actions, + }); + + final AppLocalizations l10n; + final Drama drama; + final bool saved; + final DramaDetailHeaderActions actions; + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('detail-hero'), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.surfaceTop, AppColors.ink], + ), + ), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(_horizontal, 8, _horizontal, 10), + child: Column( + children: [ + _actions(context), + const SizedBox(height: _topGap), + _identity(context), + ], + ), + ), + ), + ); + } + + Widget _actions(BuildContext context) { + return Row( + children: [ + _CircleAction( + actionKey: const Key('detail-back'), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Icons.chevron_left_rounded, + onPressed: actions.onBack, + ), + const Spacer(), + _CircleAction( + actionKey: const Key('detail-share'), + tooltip: l10n.share, + icon: Icons.share_outlined, + onPressed: actions.onShare, + ), + const SizedBox(width: 8), + actions.moreMenu, + ], + ); + } + + Widget _identity(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + key: const Key('detail-poster'), + width: _posterWidth, + height: _posterHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_posterRadius), + boxShadow: const [ + BoxShadow( + color: AppColors.cardShadow, + blurRadius: 16, + offset: Offset(0, 5), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(_posterRadius), + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackAspectRatio: 3 / 4, + ), + ), + ), + const SizedBox(width: _infoGap), + Expanded( + child: SizedBox( + height: _posterHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + key: const Key('detail-title'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + const SizedBox(height: 6), + Text( + l10n.episodeCountLabel(drama.episodeCount), + key: const Key('detail-episode-count'), + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + _favoriteButton(), + ], + ), + ), + ), + ], + ); + } + + Widget _favoriteButton() { + return GestureDetector( + onTap: actions.onFavorite, + child: Container( + key: const Key('detail-favorite'), + height: _favoriteHeight, + padding: const EdgeInsets.symmetric(horizontal: _favoriteHorizontal), + decoration: BoxDecoration( + color: saved ? AppColors.blue : AppColors.card, + borderRadius: BorderRadius.circular(_favoriteRadius), + border: Border.all(color: saved ? AppColors.blue : AppColors.blue), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + saved ? Icons.star_rounded : Icons.star_border_rounded, + size: 16, + color: saved ? AppColors.onPrimary : AppColors.blue, + ), + const SizedBox(width: 4), + Text( + l10n.favorite, + style: TextStyle( + color: saved ? AppColors.onPrimary : AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + } +} + +class _CircleAction extends StatelessWidget { + const _CircleAction({ + required this.actionKey, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final Key actionKey; + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Container( + key: actionKey, + width: _circleSize, + height: _circleSize, + decoration: const BoxDecoration( + color: AppColors.card, + shape: BoxShape.circle, + ), + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon(icon, size: _iconSize, color: AppColors.onSurface), + ), + ); + } +} diff --git a/lib/pages/drama_detail/widgets/drama_detail_sections.dart b/lib/pages/drama_detail/widgets/drama_detail_sections.dart new file mode 100644 index 0000000..1b8981f --- /dev/null +++ b/lib/pages/drama_detail/widgets/drama_detail_sections.dart @@ -0,0 +1,406 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _horizontal = 14; +const double _sectionTop = 16; +const double _statsVertical = 12; +const double _statsRadius = 14; +const double _dividerHeight = 28; +const int _synopsisLines = 3; +const int _gridColumns = 6; +const double _gridSpacing = 8; +const double _chipHeight = 44; +const double _chipRadius = 10; +const double _tagRadius = 999; +const double _tagHorizontal = 8; +const double _tagVertical = 4; +const double _lockSize = 10; +const TextStyle _synopsisStyle = TextStyle( + color: AppColors.muted, + fontSize: 13, + height: 1.5, +); + +/// 详情页三项数据卡:热度、收藏与点赞,缺失值不做伪造。 +class DramaStatsCard extends StatelessWidget { + const DramaStatsCard({super.key, required this.l10n, required this.drama}); + + final AppLocalizations l10n; + final Drama drama; + + @override + Widget build(BuildContext context) { + final favorite = drama.favoriteCount == null + ? l10n.episodeCountLabel(drama.episodeCount) + : '${formatInteractionCount(drama.favoriteCount!)} ${l10n.favorite}'; + final like = drama.likeCount == null + ? '—' + : '${formatInteractionCount(drama.likeCount!)} ${l10n.like}'; + return Container( + key: const Key('detail-stats'), + margin: const EdgeInsets.fromLTRB( + _horizontal, + _sectionTop, + _horizontal, + 0, + ), + padding: const EdgeInsets.symmetric(vertical: _statsVertical), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_statsRadius), + ), + child: Row( + children: [ + Expanded( + child: _StatCell( + valueKey: const Key('detail-stat-heat-value'), + icon: Icons.local_fire_department_rounded, + color: AppColors.blue, + value: l10n.heatLabel(formatInteractionCount(drama.viewCount)), + ), + ), + const _StatDivider(), + Expanded( + child: _StatCell( + valueKey: const Key('detail-stat-favorite-value'), + icon: Icons.star_rounded, + color: AppColors.gold, + value: favorite, + ), + ), + const _StatDivider(), + Expanded( + child: _StatCell( + valueKey: const Key('detail-stat-like-value'), + icon: Icons.favorite_rounded, + color: AppColors.favorite, + value: like, + ), + ), + ], + ), + ); + } +} + +/// 详情简介区:默认三行,超出时允许展开与收起。 +class DramaSynopsisSection extends StatefulWidget { + const DramaSynopsisSection({ + super.key, + required this.l10n, + required this.synopsis, + }); + + final AppLocalizations l10n; + final String synopsis; + + @override + State createState() => _DramaSynopsisSectionState(); +} + +class _DramaSynopsisSectionState extends State { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + return Padding( + key: const Key('detail-synopsis-section'), + padding: const EdgeInsets.fromLTRB( + _horizontal, + _sectionTop, + _horizontal, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.l10n.synopsisTitle, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + LayoutBuilder( + builder: (context, constraints) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.synopsis, + key: const Key('detail-synopsis'), + maxLines: _expanded ? null : _synopsisLines, + overflow: _expanded + ? TextOverflow.visible + : TextOverflow.ellipsis, + style: _synopsisStyle, + ), + if (_overflows(context, constraints.maxWidth)) + TextButton( + key: const Key('detail-synopsis-toggle'), + onPressed: () => setState(() => _expanded = !_expanded), + style: TextButton.styleFrom( + foregroundColor: AppColors.blue, + minimumSize: const Size(44, 34), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + _expanded + ? widget.l10n.showLess + : widget.l10n.expandSynopsis, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + bool _overflows(BuildContext context, double maxWidth) { + final painter = TextPainter( + text: TextSpan(text: widget.synopsis, style: _synopsisStyle), + maxLines: _synopsisLines, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(maxWidth: maxWidth); + final result = painter.didExceedMaxLines; + painter.dispose(); + return result; + } +} + +/// 详情页分类、题材与语言标签,最多展示四个。 +class DramaTags extends StatelessWidget { + const DramaTags({super.key, required this.tags}); + + final List tags; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(_horizontal, 12, _horizontal, 0), + child: Wrap( + key: const Key('detail-tags'), + spacing: 6, + runSpacing: 6, + children: [ + for (final tag in tags) + Container( + padding: const EdgeInsets.symmetric( + horizontal: _tagHorizontal, + vertical: _tagVertical, + ), + decoration: BoxDecoration( + color: AppColors.badgeBeige, + borderRadius: BorderRadius.circular(_tagRadius), + ), + child: Text( + tag, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +/// 详情页选集网格:免费、锁定与当前集使用不同状态样式。 +class DramaEpisodeGrid extends StatelessWidget { + const DramaEpisodeGrid({ + super.key, + required this.l10n, + required this.drama, + required this.continueIndex, + required this.onOpen, + }); + + final AppLocalizations l10n; + final Drama drama; + final int? continueIndex; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final ordered = [ + for (var index = 0; index < drama.episodes.length; index++) + (index: index, episode: drama.episodes[index]), + ]..sort((a, b) => a.episode.number.compareTo(b.episode.number)); + final selectedIndex = (continueIndex ?? 0).clamp( + 0, + drama.episodes.length - 1, + ); + final selectedNumber = drama.episodes[selectedIndex].number; + return Padding( + key: const Key('detail-episodes-section'), + padding: const EdgeInsets.fromLTRB( + _horizontal, + _sectionTop, + _horizontal, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + l10n.episodes, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const Spacer(), + Text( + l10n.episodeCountLabel(drama.episodeCount), + style: const TextStyle(color: AppColors.muted, fontSize: 11), + ), + ], + ), + const SizedBox(height: 8), + GridView.builder( + key: const Key('detail-episode-chips'), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: ordered.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _gridColumns, + mainAxisSpacing: _gridSpacing, + crossAxisSpacing: _gridSpacing, + mainAxisExtent: _chipHeight, + ), + itemBuilder: (context, position) { + final item = ordered[position]; + return _episodeChip(item.episode, item.index, selectedNumber); + }, + ), + ], + ), + ); + } + + Widget _episodeChip(Episode episode, int index, int selectedNumber) { + final selected = episode.number == selectedNumber; + final locked = episode.locked || (!episode.isFree && index > 0); + final background = selected + ? AppColors.blue + : locked + ? AppColors.raised + : AppColors.card; + final foreground = selected + ? AppColors.onPrimary + : locked + ? AppColors.muted + : AppColors.onSurface; + return Material( + key: Key('detail-episode-chip-${episode.number}'), + color: background, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_chipRadius), + side: BorderSide( + color: selected || locked ? Colors.transparent : AppColors.border, + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => onOpen(index), + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Text( + '${episode.number}', + style: TextStyle( + color: foreground, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + if (locked) + Positioned( + top: 4, + right: 4, + child: Icon( + Icons.lock_rounded, + key: Key('detail-episode-lock-${episode.number}'), + size: _lockSize, + color: selected ? AppColors.onPrimary : AppColors.subtle, + ), + ), + ], + ), + ), + ); + } +} + +class _StatCell extends StatelessWidget { + const _StatCell({ + required this.valueKey, + required this.icon, + required this.color, + required this.value, + }); + + final Key valueKey; + final IconData icon; + final Color color; + final String value; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 4), + Flexible( + child: Text( + value, + key: valueKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ); + } +} + +class _StatDivider extends StatelessWidget { + const _StatDivider(); + + @override + Widget build(BuildContext context) { + return Container(width: 1, height: _dividerHeight, color: AppColors.border); + } +} diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index ac4bd22..370b205 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -3,11 +3,17 @@ 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/data/models/auth_session.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/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.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'; @@ -15,13 +21,16 @@ 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'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/core/data/services/download_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_colors.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; @@ -29,94 +38,133 @@ import 'package:fruitshorts/pages/player/player_view.dart'; import '../support/fake_catalog.dart'; import '../support/fake_haptics.dart'; +import '../support/fake_http_adapter.dart'; import '../support/fake_interaction_repositories.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', ( + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_new_detail_layout_and_existing_menu', ( tester, ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + await _pumpDetail(tester); - await tester.pumpWidget(_host(_detailDrama())); - await tester.pumpAndSettle(); - - expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190); + expect(find.byKey(const Key('detail-hero')), findsOneWidget); expect( - tester.getSize(find.byKey(const Key('detail-back'))), - const Size(36, 36), + tester.getSize(find.byKey(const Key('detail-poster'))), + const Size(96, 128), ); expect( tester - .widget( - find.descendant( - of: find.byKey(const Key('detail-back')), - matching: find.byType(Icon), - ), - ) - .size, - 18, + .widget(find.byKey(const Key('detail-title'))) + .style + ?.fontSize, + 20, ); - expect(find.byKey(const Key('detail-cta')), findsOneWidget); - expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40); - expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget); - expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); - - final synopsis = tester.widget( - find.byKey(const Key('detail-synopsis')), + expect(find.byKey(const Key('detail-episode-count')), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('detail-favorite'))).height, 34); + final stats = tester.widget( + find.byKey(const Key('detail-stats')), ); - expect(synopsis.style?.fontSize, 11); - expect(synopsis.style?.height, 1.6); + final statsDecoration = stats.decoration! as BoxDecoration; + expect(statsDecoration.color, AppColors.card); + expect(statsDecoration.borderRadius, BorderRadius.circular(14)); + expect(find.byKey(const Key('detail-synopsis-section')), findsOneWidget); + expect(find.byKey(const Key('detail-tags')), findsOneWidget); + expect(find.byKey(const Key('detail-episodes-section')), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 46); + expect(find.byKey(const Key('detail-share')), findsOneWidget); + expect(find.byKey(const Key('detail-more')), findsOneWidget); await tester.tap(find.byKey(const Key('detail-more'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('detail-favorite')), findsOneWidget); + expect(find.byKey(const Key('detail-menu-favorite')), findsOneWidget); expect(find.byKey(const Key('detail-download')), findsOneWidget); - expect(find.byKey(const Key('detail-share')), findsOneWidget); + expect(find.byKey(const Key('detail-menu-share')), findsOneWidget); expect(find.byKey(const Key('detail-report')), findsOneWidget); expect(tester.takeException(), isNull); }); - testWidgets('should_fit_compact_viewport_when_detail_scrolls', ( + testWidgets('should_switch_favorite_button_style_when_tapped', ( tester, ) async { - tester.view.physicalSize = const Size(320, 568); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + await _pumpDetail(tester); + BoxDecoration decoration() => + tester + .widget(find.byKey(const Key('detail-favorite'))) + .decoration! + as BoxDecoration; - await tester.pumpWidget(_host(_detailDrama())); + expect(decoration().color, AppColors.card); + expect((decoration().border! as Border).top.color, AppColors.blue); + + await tester.tap(find.byKey(const Key('detail-favorite'))); await tester.pumpAndSettle(); - final grid = tester.widget( - find.byKey(const Key('detail-episode-chips')), - ); + + expect(decoration().color, AppColors.blue); expect( - (grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount) - .crossAxisCount, - 6, + find.descendant( + of: find.byKey(const Key('detail-favorite')), + matching: find.byIcon(Icons.star_rounded), + ), + findsOneWidget, ); - await tester.drag( - find.byKey(const Key('detail-scroll')), - const Offset(0, -520), - ); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); - expect(tester.takeException(), isNull); }); - testWidgets('should_render_sorted_six_column_grid_and_open_original_index', ( + testWidgets('should_expand_synopsis_when_it_exceeds_three_lines', ( tester, ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + await _pumpDetail(tester, drama: _detailDrama(longSynopsis: true)); + Text synopsis() => + tester.widget(find.byKey(const Key('detail-synopsis'))); - await tester.pumpWidget(_host(_detailDrama(count: 80))); + expect(synopsis().maxLines, 3); + final toggle = find.byKey(const Key('detail-synopsis-toggle')); + expect(toggle, findsOneWidget); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await tester.pumpAndSettle(); + + expect(synopsis().maxLines, isNull); + expect( + tester.widget(find.byKey(const Key('detail-synopsis'))).data, + contains('final secret'), + ); + }); + + testWidgets('should_render_stats_and_honest_empty_values', (tester) async { + await _pumpDetail(tester); + + expect(find.text('480.0万 heat'), findsOneWidget); + expect(find.text('3200 Favorite'), findsOneWidget); + expect(find.text('1.2万 Like'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await _pumpDetail(tester, drama: _detailDrama(withCounts: false)); + + expect( + tester + .widget(find.byKey(const Key('detail-stat-favorite-value'))) + .data, + '8 episodes', + ); + expect( + tester.widget(find.byKey(const Key('detail-stat-like-value'))).data, + '—', + ); + }); + + testWidgets('should_render_six_column_grid_and_open_original_index', ( + tester, + ) async { + await _pumpDetail(tester, drama: _detailDrama(count: 80, descending: true)); + await tester.ensureVisible( + find.byKey(const Key('detail-episodes-section')), + ); await tester.pumpAndSettle(); final grid = tester.widget( @@ -127,7 +175,7 @@ void main() { expect(delegate.crossAxisCount, 6); expect(delegate.mainAxisSpacing, 8); expect(delegate.crossAxisSpacing, 8); - expect(delegate.childAspectRatio, closeTo(1.35, 0.001)); + expect(delegate.mainAxisExtent, 44); final chip1 = find.byKey(const Key('detail-episode-chip-1')); final chip2 = find.byKey(const Key('detail-episode-chip-2')); @@ -139,81 +187,114 @@ void main() { greaterThan(tester.getTopLeft(chip1).dy), ); expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx)); - expect(tester.getSize(chip1), tester.getSize(chip2)); + expect(tester.getSize(chip1).height, 44); - await tester.drag( - find.byKey(const Key('detail-scroll')), - const Offset(0, -420), - ); - await tester.pumpAndSettle(); - final chip12 = find.byKey(const Key('detail-episode-chip-12')); - expect(chip12, findsOneWidget); - await tester.tap(chip12); - await tester.pumpAndSettle(); + expect(find.byKey(const Key('detail-episode-lock-2')), findsOneWidget); + expect(find.byKey(const Key('detail-episode-lock-1')), findsNothing); + await tester.tap(chip1); + await tester.pumpAndSettle(); final player = tester.widget(find.byType(PlayerPage)); - expect(player.initialIndex, 11); + expect(player.initialIndex, 79); }); - testWidgets('should_sort_episode_chips_ascending_by_number', (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(_host(_detailDrama(count: 80, descending: true))); - await tester.pumpAndSettle(); - - final chip1 = find.byKey(const Key('detail-episode-chip-1')); - final chip2 = find.byKey(const Key('detail-episode-chip-2')); - final chip6 = find.byKey(const Key('detail-episode-chip-6')); - final chip7 = find.byKey(const Key('detail-episode-chip-7')); - expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy); - expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx)); - expect( - tester.getTopLeft(chip7).dy, - greaterThan(tester.getTopLeft(chip1).dy), + testWidgets('should_fit_compact_viewport_and_keep_cta_visible', ( + tester, + ) async { + await _pumpDetail( + tester, + drama: _detailDrama(count: 80), + size: const Size(320, 568), ); - expect(tester.getSize(chip1), tester.getSize(chip2)); + + expect(find.byKey(const Key('detail-cta')), findsOneWidget); + await tester.ensureVisible( + find.byKey(const Key('detail-episodes-section')), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); + expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); + expect(tester.takeException(), isNull); }); } -Widget _host(Drama drama) { - final catalog = FakeCatalogRepository(dramas: [drama]); +Future _pumpDetail( + WidgetTester tester, { + Drama? drama, + Size size = const Size(390, 844), +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final target = drama ?? _detailDrama(); + final catalog = FakeCatalogRepository(dramas: [target]); final interactions = FakeInteractionRepository(); - return MultiProvider( - providers: [ - Provider.value( - value: InMemoryWatchHistoryRepository(), + final auth = await _signedInAuth(); + final favorites = FavoriteController( + InMemoryFavoriteRepository(cards: [target]), + ); + await favorites.load(); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value( + value: InMemoryWatchHistoryRepository(), + ), + Provider.value(value: FakePlaybackRepository()), + Provider.value(value: createWidgetPlayer), + Provider(create: (_) => PlaybackCoordinator()), + Provider.value(value: interactions), + Provider.value(value: FakeCommentRepository()), + Provider.value(value: FakeHaptics()), + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: favorites), + ChangeNotifierProvider( + create: (_) => InteractionController(interactions), + ), + ChangeNotifierProvider( + create: (_) => WalletController(_FakeCommerceRepository()), + ), + ChangeNotifierProvider( + create: (_) => DownloadController(_FakeDownloadRepository()), + ), + ChangeNotifierProvider( + create: (_) => CatalogController(catalog)..load(), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: DramaDetailPage(drama: target), ), - Provider.value(value: FakePlaybackRepository()), - Provider.value(value: createWidgetPlayer), - Provider(create: (_) => PlaybackCoordinator()), - Provider.value(value: interactions), - Provider.value(value: FakeCommentRepository()), - Provider.value(value: FakeHaptics()), - ChangeNotifierProvider( - create: (_) => FavoriteController(InMemoryFavoriteRepository()), - ), - ChangeNotifierProvider( - create: (_) => InteractionController(interactions), - ), - ChangeNotifierProvider( - create: (_) => WalletController(_FakeCommerceRepository()), - ), - ChangeNotifierProvider( - create: (_) => DownloadController(_FakeDownloadRepository()), - ), - ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()), - ], - child: MaterialApp( - theme: AppTheme.light(), - locale: const Locale('en'), - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalizations.localizationsDelegates, - home: DramaDetailPage(drama: drama), ), ); + await tester.pumpAndSettle(); +} + +Future _signedInAuth() async { + final preferences = SharedPreferencesAsync(); + final store = AuthSessionStore(preferences: preferences); + await store.save( + const AuthSession( + accessToken: 'access', + refreshToken: 'refresh', + user: AuthUser(uid: '9', nickname: 'Viewer'), + ), + ); + final auth = AuthController( + AuthApi( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ), + store, + ); + await auth.load(); + return auth; } class _FakeCommerceRepository implements CommerceRepository { @@ -281,33 +362,46 @@ class _FakeDownloadRepository implements DownloadRepository { Future clear() async => 0; } -Drama _detailDrama({int count = 8, bool descending = false}) { +Drama _detailDrama({ + int count = 8, + bool descending = false, + bool withCounts = true, + bool longSynopsis = false, +}) { final numbers = List.generate( count, (index) => descending ? count - index : index + 1, ); + final synopsis = longSynopsis + ? 'She agreed to marry a mysterious stranger to save her family, ' + 'until she discovered his secret identity and the city he controls. ' + 'Every choice pulls her deeper into a dangerous game, and the ' + 'final secret changes everything she thought she knew about him.' + : 'She agreed to marry a mysterious stranger to save her family, ' + 'until she discovered his secret identity and the city he controls.'; return Drama( - id: 'detail-k25', - title: 'Detail K25', - synopsis: - 'She agreed to marry a mysterious stranger to save her family, ' - 'until she discovered his secret identity and the city he controls.', - posterUrl: '/files/detail-k25.png', + id: 'detail-k38', + title: 'Detail K38', + synopsis: synopsis, + posterUrl: '/files/detail-k38.png', genre: 'Urban Romance', category: 'Romance', + language: 'id', totalEpisodes: count, viewCount: 4800000, + favoriteCount: withCounts ? 3200 : null, + likeCount: withCounts ? 12400 : null, detailLoaded: true, episodes: [ for (final number in numbers) Episode( - id: 'detail-k25-$number', + id: 'detail-k38-$number', number: number, title: 'Episode $number', isFree: number == 1, durationSec: 135, locked: number > 1, - videoUrl: number == 1 ? '/files/detail-k25-1.mp4' : '', + videoUrl: number == 1 ? '/files/detail-k38-1.mp4' : '', ), ], ); From 1c6c92170d105fefe01962f2a274a856b63123a4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:55:12 +0800 Subject: [PATCH 082/124] =?UTF-8?q?feat(flutter):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E9=A1=B5=E5=A4=B4=E5=B9=B6=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=96=B0=E5=89=A7=E6=9A=96=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/plain_page_scaffold.dart | 45 +----- lib/core/widgets/subpage_header.dart | 130 +++++++++++++++++ .../account/account_deactivate_page.dart | 28 ++-- lib/pages/account/account_devices_page.dart | 28 ++-- lib/pages/account/account_export_page.dart | 28 ++-- lib/pages/account/account_profile_page.dart | 28 ++-- lib/pages/create/create_view.dart | 11 +- lib/pages/discover/category_dramas_page.dart | 45 +++--- lib/pages/downloads/downloads_page.dart | 22 ++- lib/pages/drama_detail/drama_detail_view.dart | 46 +++--- .../widgets/drama_detail_header.dart | 10 +- lib/pages/favorites/favorites_page.dart | 21 ++- lib/pages/filter/filter_page.dart | 44 +----- lib/pages/mine/mine_view.dart | 11 +- lib/pages/new_releases/new_releases_page.dart | 134 ++++++++---------- lib/pages/notices/notices_page.dart | 22 ++- lib/pages/reserve/reserve_page.dart | 49 +------ lib/pages/settings/settings_page.dart | 36 ++--- lib/pages/store/store_page.dart | 22 ++- lib/pages/wallet/wallet_page.dart | 15 +- .../watch_records/watch_records_page.dart | 18 +-- test/core/subpage_header_test.dart | 86 +++++++++++ test/discover/filter_page_test.dart | 8 ++ test/discover/theater_pages_test.dart | 18 +++ test/mine/settings_page_test.dart | 7 + test/notice/notices_entry_test.dart | 7 + 26 files changed, 492 insertions(+), 427 deletions(-) create mode 100644 lib/core/widgets/subpage_header.dart create mode 100644 test/core/subpage_header_test.dart diff --git a/lib/core/widgets/plain_page_scaffold.dart b/lib/core/widgets/plain_page_scaffold.dart index 640baf0..8e5c07f 100644 --- a/lib/core/widgets/plain_page_scaffold.dart +++ b/lib/core/widgets/plain_page_scaffold.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; +import 'subpage_header.dart'; /// 二级页面通用骨架:返回栏 + 标题/副标题 + 可滚动内容,无底部导航。 class PlainPageScaffold extends StatelessWidget { @@ -30,47 +31,11 @@ class PlainPageScaffold extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(6, 4, 14, 0), - child: Row( - children: [ - IconButton( - key: backKey, - tooltip: MaterialLocalizations.of( - context, - ).backButtonTooltip, - onPressed: () => Navigator.of(context).maybePop(), - icon: const Icon( - Icons.chevron_left_rounded, - color: AppColors.onSurface, - size: 26, - ), - ), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 20, - fontWeight: FontWeight.w800, - ), - ), - ), - ], - ), + AppSubpageHeader( + title: title, + subtitle: subtitle, + backKey: backKey, ), - if (subtitle != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - subtitle!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.muted, fontSize: 12), - ), - ), Expanded(child: child), ], ), diff --git a/lib/core/widgets/subpage_header.dart b/lib/core/widgets/subpage_header.dart new file mode 100644 index 0000000..e98130d --- /dev/null +++ b/lib/core/widgets/subpage_header.dart @@ -0,0 +1,130 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + +const double _height = 52; +const double _heightWithSubtitle = 64; +const double _horizontal = 14; +const double _backSize = 36; +const double _backGap = 12; +const double _titleFontSize = 18; +const double _subtitleFontSize = 12; +const double _actionGap = 8; +const double _iconSize = 20; + +/// 二级页面统一返回按钮,浅色页使用 card 圆底,深色页使用透明底。 +class AppSubpageBackButton extends StatelessWidget { + const AppSubpageBackButton({ + super.key, + required this.backKey, + this.dark = false, + this.onBack, + }); + + final Key backKey; + final bool dark; + final VoidCallback? onBack; + + @override + Widget build(BuildContext context) { + return SizedBox( + key: backKey, + width: _backSize, + height: _backSize, + child: Tooltip( + message: MaterialLocalizations.of(context).backButtonTooltip, + child: Material( + color: dark ? Colors.transparent : AppColors.card, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onBack ?? () => Navigator.of(context).maybePop(), + child: Icon( + Icons.chevron_left_rounded, + color: dark ? Colors.white : AppColors.onSurface, + size: _iconSize, + ), + ), + ), + ), + ); + } +} + +/// 二级页面统一页头:返回按钮、左对齐标题、可选副标题与右侧操作。 +class AppSubpageHeader extends StatelessWidget { + const AppSubpageHeader({ + super.key, + required this.title, + required this.backKey, + this.subtitle, + this.actions, + this.onBack, + this.dark = false, + }); + + final String title; + final Key backKey; + final String? subtitle; + final Widget? actions; + final VoidCallback? onBack; + final bool dark; + + @override + Widget build(BuildContext context) { + final foreground = dark ? Colors.white : AppColors.onSurface; + final secondary = dark ? Colors.white70 : AppColors.muted; + return SizedBox( + key: const Key('subpage-header'), + height: subtitle == null ? _height : _heightWithSubtitle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _horizontal), + child: Row( + children: [ + AppSubpageBackButton(backKey: backKey, dark: dark, onBack: onBack), + const SizedBox(width: _backGap), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + key: const Key('subpage-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontSize: _titleFontSize, + fontWeight: FontWeight.w800, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + key: const Key('subpage-subtitle'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: secondary, + fontSize: _subtitleFontSize, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + if (actions != null) ...[ + const SizedBox(width: _actionGap), + actions!, + ], + ], + ), + ), + ); + } +} diff --git a/lib/pages/account/account_deactivate_page.dart b/lib/pages/account/account_deactivate_page.dart index 4b04d21..f5a123b 100644 --- a/lib/pages/account/account_deactivate_page.dart +++ b/lib/pages/account/account_deactivate_page.dart @@ -12,6 +12,7 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import 'account_bindings.dart'; import 'account_deactivate_logic.dart'; @@ -51,23 +52,16 @@ class _AccountDeactivatePageState extends State { final canShow = signedIn || _logic.scheduledAt != null; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('account-deactivate-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(l10n.accountDeactivate), - ), - body: SafeArea( - top: false, - child: canShow - ? ListenableBuilder( - listenable: _logic, - builder: (context, _) => _body(l10n), - ) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('account-deactivate-page'), + backKey: const Key('account-deactivate-back'), + title: l10n.accountDeactivate, + child: canShow + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/account/account_devices_page.dart b/lib/pages/account/account_devices_page.dart index 804a192..99f3101 100644 --- a/lib/pages/account/account_devices_page.dart +++ b/lib/pages/account/account_devices_page.dart @@ -15,6 +15,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import 'account_bindings.dart'; import 'account_devices_logic.dart'; @@ -51,23 +52,16 @@ class _AccountDevicesPageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('account-devices-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(l10n.accountDevices), - ), - body: SafeArea( - top: false, - child: signedIn - ? ListenableBuilder( - listenable: _logic, - builder: (context, _) => _body(l10n), - ) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('account-devices-page'), + backKey: const Key('account-devices-back'), + title: l10n.accountDevices, + child: signedIn + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/account/account_export_page.dart b/lib/pages/account/account_export_page.dart index 1bc0a9d..f451b27 100644 --- a/lib/pages/account/account_export_page.dart +++ b/lib/pages/account/account_export_page.dart @@ -15,6 +15,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import 'account_bindings.dart'; import 'account_export_logic.dart'; @@ -52,23 +53,16 @@ class _AccountExportPageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('account-export-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(l10n.accountDataExport), - ), - body: SafeArea( - top: false, - child: signedIn - ? ListenableBuilder( - listenable: _logic, - builder: (context, _) => _body(l10n), - ) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('account-export-page'), + backKey: const Key('account-export-back'), + title: l10n.accountDataExport, + child: signedIn + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/account/account_profile_page.dart b/lib/pages/account/account_profile_page.dart index 6b9ef9e..1b79c5e 100644 --- a/lib/pages/account/account_profile_page.dart +++ b/lib/pages/account/account_profile_page.dart @@ -15,6 +15,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'account_bindings.dart'; @@ -72,23 +73,16 @@ class _AccountProfilePageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('account-profile-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(l10n.accountProfile), - ), - body: SafeArea( - top: false, - child: signedIn - ? ListenableBuilder( - listenable: _logic, - builder: (context, _) => _body(l10n), - ) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('account-profile-page'), + backKey: const Key('account-profile-back'), + title: l10n.accountProfile, + child: signedIn + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/create/create_view.dart b/lib/pages/create/create_view.dart index d5cf3a4..bf6e729 100644 --- a/lib/pages/create/create_view.dart +++ b/lib/pages/create/create_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; const double _cardHeight = 196; @@ -343,11 +344,11 @@ class _CreatePlaceholderPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return Scaffold( - key: const Key('create-placeholder-page'), - backgroundColor: AppColors.ink, - appBar: AppBar(title: Text(title)), - body: Center( + return PlainPageScaffold( + pageKey: const Key('create-placeholder-page'), + backKey: const Key('create-placeholder-back'), + title: title, + child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/pages/discover/category_dramas_page.dart b/lib/pages/discover/category_dramas_page.dart index 0687f49..9b35069 100644 --- a/lib/pages/discover/category_dramas_page.dart +++ b/lib/pages/discover/category_dramas_page.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; -import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; import 'widgets/drama_grid.dart'; @@ -28,32 +28,25 @@ class CategoryDramasPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('category-dramas-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onSurface, - title: Text(title), - ), - body: SafeArea( - top: false, - child: dramas.isEmpty - ? EmptyState( - key: const Key('category-dramas-empty'), - icon: Icons.movie_filter_outlined, - title: l10n.noCategoryDramas, - subtitle: l10n.emptyHint, - ) - : SingleChildScrollView( - padding: const EdgeInsets.only(top: 12, bottom: 28), - child: DramaGrid( - l10n: l10n, - dramas: dramas, - onOpen: (drama) => _open(context, drama), - ), + child: PlainPageScaffold( + pageKey: const Key('category-dramas-page'), + backKey: const Key('category-dramas-back'), + title: title, + child: dramas.isEmpty + ? EmptyState( + key: const Key('category-dramas-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.noCategoryDramas, + subtitle: l10n.emptyHint, + ) + : SingleChildScrollView( + padding: const EdgeInsets.only(top: 8, bottom: 28), + child: DramaGrid( + l10n: l10n, + dramas: dramas, + onOpen: (drama) => _open(context, drama), ), - ), + ), ), ); } diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index 46e87f4..84e839a 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -18,6 +18,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../player/player_view.dart'; import 'downloads_binding.dart'; @@ -55,20 +56,13 @@ class _DownloadsPageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('downloads-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - title: Text(l10n.downloads), - ), - body: SafeArea( - top: false, - child: signedIn - ? _DownloadsBody(downloads: context.watch()) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('downloads-page'), + backKey: const Key('downloads-back'), + title: l10n.downloads, + child: signedIn + ? _DownloadsBody(downloads: context.watch()) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 208ab48..f9a9a06 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -20,6 +20,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../interaction/widgets/report_dialog.dart'; import 'drama_detail_binding.dart'; @@ -145,24 +146,14 @@ class _DramaDetailPageState extends State { ); } - Widget _loading(AppLocalizations l10n) => Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.surfaceTop, - foregroundColor: AppColors.onSurface, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: const Center(child: CircularProgressIndicator()), + Widget _loading(AppLocalizations l10n) => _statePage( + localizedDramaTitle(l10n, widget.drama), + const Center(child: CircularProgressIndicator()), ); - Widget _failed(AppLocalizations l10n) => Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.surfaceTop, - foregroundColor: AppColors.onSurface, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: Center( + Widget _failed(AppLocalizations l10n) => _statePage( + localizedDramaTitle(l10n, widget.drama), + Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Column( @@ -181,14 +172,9 @@ class _DramaDetailPageState extends State { ), ); - Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.surfaceTop, - foregroundColor: AppColors.onSurface, - title: Text(localizedDramaTitle(l10n, drama)), - ), - body: EmptyState( + Widget _empty(AppLocalizations l10n, Drama drama) => _statePage( + localizedDramaTitle(l10n, drama), + EmptyState( key: const Key('drama-detail-empty'), icon: Icons.video_library_outlined, title: l10n.noEpisodesYet, @@ -198,6 +184,18 @@ class _DramaDetailPageState extends State { ), ); + Widget _statePage(String title, Widget child) => Scaffold( + backgroundColor: AppColors.ink, + body: SafeArea( + child: Column( + children: [ + AppSubpageHeader(title: title, backKey: const Key('detail-back')), + Expanded(child: child), + ], + ), + ), + ); + Widget? _resume(AppLocalizations l10n) { final fraction = _continueFraction; final progressError = logic.progressError; diff --git a/lib/pages/drama_detail/widgets/drama_detail_header.dart b/lib/pages/drama_detail/widgets/drama_detail_header.dart index e414d8a..833893b 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_header.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_header.dart @@ -6,6 +6,7 @@ import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; +import '../../../core/widgets/subpage_header.dart'; import '../../../l10n/app_localizations.dart'; const double _horizontal = 14; @@ -80,11 +81,10 @@ class DramaDetailHeader extends StatelessWidget { Widget _actions(BuildContext context) { return Row( children: [ - _CircleAction( - actionKey: const Key('detail-back'), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Icons.chevron_left_rounded, - onPressed: actions.onBack, + AppSubpageBackButton( + backKey: const Key('detail-back'), + dark: true, + onBack: actions.onBack, ), const Spacer(), _CircleAction( diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 2e5fa8a..0294fa0 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -15,6 +15,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/library_drama_tile.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; import 'favorites_binding.dart'; @@ -52,19 +53,13 @@ class _FavoritesPageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - title: Text(l10n.myFavorites), - ), - body: SafeArea( - top: false, - child: signedIn - ? _FavoritesBody(favorites: context.watch()) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('favorites-page'), + backKey: const Key('favorites-back'), + title: l10n.myFavorites, + child: signedIn + ? _FavoritesBody(favorites: context.watch()) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/filter/filter_page.dart b/lib/pages/filter/filter_page.dart index 3828c02..2def7eb 100644 --- a/lib/pages/filter/filter_page.dart +++ b/lib/pages/filter/filter_page.dart @@ -9,6 +9,7 @@ import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import 'widgets/filter_result_card.dart'; @@ -64,7 +65,10 @@ class _FilterPageState extends State { body: SafeArea( child: Column( children: [ - _topBar(l10n), + AppSubpageHeader( + title: l10n.filter, + backKey: const Key('filter-back'), + ), Expanded( child: CustomScrollView( slivers: [ @@ -123,44 +127,6 @@ class _FilterPageState extends State { ); } - Widget _topBar(AppLocalizations l10n) { - return SizedBox( - height: 52, - child: Row( - children: [ - SizedBox( - key: const Key('filter-back'), - width: 48, - height: 48, - child: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () => Navigator.of(context).pop(), - padding: EdgeInsets.zero, - icon: const Icon( - Icons.chevron_left_rounded, - color: AppColors.onSurface, - size: 26, - ), - ), - ), - Expanded( - child: Center( - child: Text( - l10n.filter, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 18, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - const SizedBox(width: 48), - ], - ), - ); - } - Widget _groups( AppLocalizations l10n, List categories, diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 7b2c44d..3b777a5 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -14,6 +14,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; @@ -364,11 +365,11 @@ class _MinePlaceholderPage extends StatelessWidget { MinePlaceholderKind.creator => l10n.creatorCenter, MinePlaceholderKind.help => l10n.helpFeedback, }; - return Scaffold( - key: const Key('mine-placeholder-page'), - backgroundColor: AppColors.ink, - appBar: AppBar(title: Text(title)), - body: Center( + return PlainPageScaffold( + pageKey: const Key('mine-placeholder-page'), + backKey: const Key('mine-placeholder-back'), + title: title, + child: Center( child: Text( l10n.comingSoon, style: const TextStyle( diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index b540b8a..a02d874 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -11,16 +11,18 @@ import '../../core/data/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/remote_image.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; const int _moreLimit = 12; const int _filmHoles = 8; -const Color _premiereTop = Color(0xFF1F1B16); -const Color _premiereBottom = Color(0xFF2E2119); -const Color _filmHole = Color(0x33FFFFFF); +const double _cardPadding = 8; +const double _cardWidth = 124; +const double _rowHeight = 204; +const double _cardRadius = 12; -/// 新剧页:暗色首映厅,今日首映大卡 + 横向更多新剧。 +/// 新剧页:暖色首映厅,今日首映大卡 + 横向更多新剧。 class NewReleasesPage extends StatelessWidget { const NewReleasesPage({super.key}); @@ -32,29 +34,35 @@ class NewReleasesPage extends StatelessWidget { final hero = dramas.isEmpty ? null : dramas.first; final more = dramas.skip(1).take(_moreLimit).toList(); return AnnotatedRegion( - value: SystemUiOverlayStyle.light, + value: SystemUiOverlayStyle.dark, child: Scaffold( key: const Key('new-releases-page'), + backgroundColor: AppColors.ink, body: DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [_premiereTop, _premiereBottom], + colors: [AppColors.surfaceTop, AppColors.ink], ), ), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _header(context, l10n, dramas.length), + AppSubpageHeader( + title: l10n.newReleasesTitle, + subtitle: l10n.newReleasesSubtitle, + backKey: const Key('new-releases-back'), + actions: _countChip(l10n, dramas.length), + ), Expanded( child: hero == null ? Center( child: Text( l10n.noStories, key: const Key('new-releases-empty'), - style: const TextStyle(color: Colors.white70), + style: const TextStyle(color: AppColors.muted), ), ) : ListView( @@ -73,7 +81,7 @@ class NewReleasesPage extends StatelessWidget { _moreHeader(l10n), const SizedBox(height: 10), SizedBox( - height: 196, + height: _rowHeight, child: ListView.separated( key: const Key('new-releases-row'), scrollDirection: Axis.horizontal, @@ -103,63 +111,21 @@ class NewReleasesPage extends StatelessWidget { ); } - Widget _header(BuildContext context, AppLocalizations l10n, int count) { - return Padding( - padding: const EdgeInsets.fromLTRB(6, 4, 16, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - key: const Key('new-releases-back'), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () => Navigator.of(context).maybePop(), - icon: const Icon( - Icons.chevron_left_rounded, - color: Colors.white, - size: 26, - ), - ), - Expanded( - child: Text( - l10n.newReleasesTitle, - style: const TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.w900, - ), - ), - ), - Container( - key: const Key('new-releases-count'), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - l10n.newReleasesCount(count), - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.only(left: 8), - child: Text( - l10n.newReleasesSubtitle, - style: const TextStyle(color: Colors.white70, fontSize: 12), - ), - ), - ], + Widget _countChip(AppLocalizations l10n, int count) { + return Container( + key: const Key('new-releases-count'), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.badgeBeige, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + l10n.newReleasesCount(count), + style: const TextStyle( + color: AppColors.blue, + fontSize: 11, + fontWeight: FontWeight.w700, + ), ), ); } @@ -286,7 +252,7 @@ class NewReleasesPage extends StatelessWidget { child: Text( l10n.rankingNew, style: const TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: 16, fontWeight: FontWeight.w800, ), @@ -294,7 +260,7 @@ class NewReleasesPage extends StatelessWidget { ), Text( l10n.seeAll, - style: const TextStyle(color: Colors.white54, fontSize: 12), + style: const TextStyle(color: AppColors.subtle, fontSize: 12), ), ], ), @@ -310,13 +276,25 @@ class NewReleasesPage extends StatelessWidget { return GestureDetector( key: Key('new-release-card-${drama.id}'), onTap: () => unawaited(playDramaFromCatalog(context, drama)), - child: SizedBox( - width: 108, + child: Container( + width: _cardWidth, + padding: const EdgeInsets.all(_cardPadding), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_cardRadius), + boxShadow: const [ + BoxShadow( + color: AppColors.cardShadow, + blurRadius: 12, + offset: Offset(0, 4), + ), + ], + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), child: SizedBox( width: 108, height: 144, @@ -348,7 +326,7 @@ class NewReleasesPage extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: 13, fontWeight: FontWeight.w700, ), @@ -356,7 +334,7 @@ class NewReleasesPage extends StatelessWidget { const SizedBox(height: 2), Text( _releaseDate(index), - style: const TextStyle(color: Colors.white54, fontSize: 12), + style: const TextStyle(color: AppColors.subtle, fontSize: 12), ), ], ), @@ -407,7 +385,13 @@ class _FilmStrip extends StatelessWidget { return Container( key: const Key('new-releases-film'), height: 22, - color: const Color(0x1A000000), + decoration: const BoxDecoration( + color: AppColors.raised, + border: Border( + top: BorderSide(color: AppColors.border), + bottom: BorderSide(color: AppColors.border), + ), + ), child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [_holes(), _holes()], @@ -424,7 +408,7 @@ class _FilmStrip extends StatelessWidget { width: 6, height: 8, decoration: BoxDecoration( - color: _filmHole, + color: AppColors.border, borderRadius: BorderRadius.circular(2), ), ), diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index a8e2f27..605c1a4 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -14,6 +14,7 @@ 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/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; /// Anonymous system notice list with pagination and detail dialog. @@ -45,20 +46,13 @@ class _NoticesPageState extends State { final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('notices-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(l10n.notices), - ), - body: SafeArea( - top: false, - child: ListenableBuilder( - listenable: _controller, - builder: (context, _) => _body(l10n), - ), + child: PlainPageScaffold( + pageKey: const Key('notices-page'), + backKey: const Key('notices-back'), + title: l10n.notices, + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) => _body(l10n), ), ), ); diff --git a/lib/pages/reserve/reserve_page.dart b/lib/pages/reserve/reserve_page.dart index 1d8189b..26f5bd3 100644 --- a/lib/pages/reserve/reserve_page.dart +++ b/lib/pages/reserve/reserve_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../new_releases/new_releases_page.dart'; @@ -43,7 +44,11 @@ class _ReservePageState extends State { child: ListView( padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), children: [ - _header(context, l10n), + AppSubpageHeader( + title: l10n.reserveTitle, + subtitle: l10n.reserveSubtitle, + backKey: const Key('reserve-back'), + ), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: _weekStrip(l10n), @@ -62,48 +67,6 @@ class _ReservePageState extends State { ); } - Widget _header(BuildContext context, AppLocalizations l10n) { - return Padding( - padding: const EdgeInsets.fromLTRB(6, 0, 16, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - key: const Key('reserve-back'), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () => Navigator.of(context).maybePop(), - icon: const Icon( - Icons.chevron_left_rounded, - color: AppColors.onSurface, - size: 26, - ), - ), - Expanded( - child: Text( - l10n.reserveTitle, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 24, - fontWeight: FontWeight.w800, - ), - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.only(left: 8), - child: Text( - l10n.reserveSubtitle, - style: const TextStyle(color: AppColors.muted, fontSize: 12), - ), - ), - ], - ), - ); - } - Widget _weekStrip(AppLocalizations l10n) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index 8d06c0a..bd8cc4e 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -9,6 +9,7 @@ import '../../core/data/services/auth_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../account/account_entry_card.dart'; import '../notices/notices_page.dart'; @@ -23,28 +24,21 @@ class SettingsPage extends StatelessWidget { final auth = context.watch(); return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('settings-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onSurface, - title: Text(l10n.settings), - ), - body: SafeArea( - top: false, - child: ListView( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 28), - children: [ - if (auth.isSignedIn) ...[ - const AccountEntryCard(), - const SizedBox(height: 12), - ], - _noticesTile(context, l10n), - const SizedBox(height: 24), - _authAction(context, l10n, auth), + child: PlainPageScaffold( + pageKey: const Key('settings-page'), + backKey: const Key('settings-back'), + title: l10n.settings, + child: ListView( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 28), + children: [ + if (auth.isSignedIn) ...[ + const AccountEntryCard(), + const SizedBox(height: 12), ], - ), + _noticesTile(context, l10n), + const SizedBox(height: 24), + _authAction(context, l10n, auth), + ], ), ), ); diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart index 9fe52d6..beed81a 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -13,6 +13,7 @@ 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/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; /// 商品货架:金币包与会员档位共用一套真实接口渲染。 @@ -57,20 +58,13 @@ class _StorePageState extends State { final isVip = widget.type == StoreController.vipType; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('store-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onSurface, - title: Text(isVip ? l10n.vipTitle : l10n.rechargeTitle), - ), - body: SafeArea( - top: false, - child: ListenableBuilder( - listenable: controller, - builder: (context, _) => _body(l10n, isVip), - ), + child: PlainPageScaffold( + pageKey: const Key('store-page'), + backKey: const Key('store-back'), + title: isVip ? l10n.vipTitle : l10n.rechargeTitle, + child: ListenableBuilder( + listenable: controller, + builder: (context, _) => _body(l10n, isVip), ), ), ); diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart index 7748314..6f80f6e 100644 --- a/lib/pages/wallet/wallet_page.dart +++ b/lib/pages/wallet/wallet_page.dart @@ -13,6 +13,7 @@ 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/plain_page_scaffold.dart'; import '../store/recharge_page.dart'; import '../store/vip_page.dart'; import '../../l10n/app_localizations.dart'; @@ -45,15 +46,11 @@ class _WalletPageState extends State { final wallet = context.watch(); return Theme( data: AppTheme.light(), - 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)), + child: PlainPageScaffold( + pageKey: const Key('wallet-page'), + backKey: const Key('wallet-back'), + title: l10n.wallet, + child: _body(l10n, wallet), ), ); } diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index 48b46b5..fb3e5b9 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -19,6 +19,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; @@ -34,18 +35,11 @@ class WatchRecordsPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('watch-records-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - title: Text(l10n.watchHistory), - ), - body: SafeArea( - top: false, - child: signedIn ? const _WatchRecordsBody() : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('watch-records-page'), + backKey: const Key('watch-records-back'), + title: l10n.watchHistory, + child: signedIn ? const _WatchRecordsBody() : const AuthGatePanel(), ), ); } diff --git a/test/core/subpage_header_test.dart b/test/core/subpage_header_test.dart new file mode 100644 index 0000000..7548468 --- /dev/null +++ b/test/core/subpage_header_test.dart @@ -0,0 +1,86 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/core/widgets/subpage_header.dart'; + +void main() { + testWidgets('should_render_unified_light_header_dimensions', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AppSubpageHeader( + title: 'Title', + backKey: const Key('sample-back'), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(const Key('subpage-header'))).height, 52); + expect( + tester.getSize(find.byKey(const Key('sample-back'))), + const Size(36, 36), + ); + final title = tester.widget(find.byKey(const Key('subpage-title'))); + expect(title.style?.fontSize, 18); + expect(title.style?.fontWeight, FontWeight.w800); + expect(title.style?.color, AppColors.onSurface); + final back = tester.getRect(find.byKey(const Key('sample-back'))); + final titleRect = tester.getRect(find.byKey(const Key('subpage-title'))); + expect(titleRect.left - back.right, closeTo(12, 0.5)); + }); + + testWidgets('should_use_64_height_when_subtitle_exists', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AppSubpageHeader( + title: 'Title', + subtitle: 'Subtitle', + backKey: const Key('sample-back'), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(const Key('subpage-header'))).height, 64); + expect(find.byKey(const Key('subpage-subtitle')), findsOneWidget); + }); + + testWidgets('should_use_transparent_dark_back_button', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + backgroundColor: Colors.black, + body: AppSubpageHeader( + title: 'Title', + backKey: Key('sample-back'), + dark: true, + ), + ), + ), + ); + + final material = tester.widget( + find.descendant( + of: find.byKey(const Key('sample-back')), + matching: find.byType(Material), + ), + ); + final icon = tester.widget( + find.descendant( + of: find.byKey(const Key('sample-back')), + matching: find.byIcon(Icons.chevron_left_rounded), + ), + ); + expect(material.color, Colors.transparent); + expect(icon.color, Colors.white); + expect(icon.size, 20); + }); +} diff --git a/test/discover/filter_page_test.dart b/test/discover/filter_page_test.dart index cbfea3b..97fd6ed 100644 --- a/test/discover/filter_page_test.dart +++ b/test/discover/filter_page_test.dart @@ -29,6 +29,7 @@ void main() { await _pumpFilterPage(tester); expect(find.byKey(const Key('filter-page')), findsOneWidget); + _expectUnifiedHeader(tester, const Key('filter-back')); expect( find.byKey(const Key('filter-group-label-category')), findsOneWidget, @@ -184,6 +185,13 @@ final List _filterDramas = [ ), ]; +void _expectUnifiedHeader(WidgetTester tester, Key backKey) { + expect(tester.getSize(find.byKey(backKey)), const Size(36, 36)); + final title = tester.widget(find.byKey(const Key('subpage-title'))); + expect(title.style?.fontSize, 18); + expect(title.style?.fontWeight, FontWeight.w800); +} + Drama _drama({ required String id, required String title, diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 8294393..29442e9 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -29,6 +29,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('ranking-page')), findsOneWidget); + _expectUnifiedHeader(tester, const Key('ranking-back')); expect(find.text('TOP 1'), findsOneWidget); expect( tester.widget(find.byKey(const Key('ranking-title-1'))).data, @@ -46,6 +47,15 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('new-releases-page')), findsOneWidget); + _expectUnifiedHeader(tester, const Key('new-releases-back')); + final newPage = tester.widget( + find.byKey(const Key('new-releases-page')), + ); + expect(newPage.backgroundColor, AppColors.ink); + final badge = tester.widget( + find.byKey(const Key('new-releases-hero-badge')), + ); + expect((badge.decoration! as BoxDecoration).color, AppColors.blue); expect(find.byKey(const Key('new-releases-hero')), findsOneWidget); expect(find.text('Vengeance Rising'), findsWidgets); expect( @@ -68,6 +78,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('reserve-page')), findsOneWidget); + _expectUnifiedHeader(tester, const Key('reserve-back')); expect(find.byKey(const Key('reserve-week-strip')), findsOneWidget); for (var index = 0; index < 7; index++) { expect(find.byKey(Key('reserve-day-$index')), findsOneWidget); @@ -207,3 +218,10 @@ List _rankDramas(int count) => [ ], ), ]; + +void _expectUnifiedHeader(WidgetTester tester, Key backKey) { + expect(tester.getSize(find.byKey(backKey)), const Size(36, 36)); + final title = tester.widget(find.byKey(const Key('subpage-title'))); + expect(title.style?.fontSize, 18); + expect(title.style?.fontWeight, FontWeight.w800); +} diff --git a/test/mine/settings_page_test.dart b/test/mine/settings_page_test.dart index 7280809..d748fde 100644 --- a/test/mine/settings_page_test.dart +++ b/test/mine/settings_page_test.dart @@ -52,6 +52,13 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('settings-page')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('settings-back'))), + const Size(36, 36), + ); + final title = tester.widget(find.byKey(const Key('subpage-title'))); + expect(title.style?.fontSize, 18); + expect(title.style?.fontWeight, FontWeight.w800); expect(find.byKey(const Key('settings-notices')), findsOneWidget); expect(find.byKey(const Key('server-endpoint-setting')), findsNothing); expect(find.text('Server address'), findsNothing); diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index 9fdbe56..140352a 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -52,6 +52,13 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('notices-page')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('notices-back'))), + const Size(36, 36), + ); + final title = tester.widget(find.byKey(const Key('subpage-title'))); + expect(title.style?.fontSize, 18); + expect(title.style?.fontWeight, FontWeight.w800); expect(find.byKey(const Key('auth-gate-panel')), findsNothing); }); } From fa5fd124a45aee14d9dc630f69d3f33c14623839 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:59:23 +0800 Subject: [PATCH 083/124] =?UTF-8?q?fix(flutter):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=8A=A0=E8=BD=BD=E5=8D=A0=E4=BD=8D=E4=B8=8E?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 48 +++++++++++++++++++++---- test/player/player_fullscreen_test.dart | 48 +++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 6b77976..c08ab0a 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -20,6 +20,7 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; @@ -298,11 +299,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { return Stack( fit: StackFit.expand, children: [ - if (!ready) - const ColoredBox( - key: Key('player-fallback-background'), - color: Colors.black, - ), + if (!ready) _PlayerFallbackBackground(posterUrl: drama.posterUrl), ?video, if (widget.homeMode && _chromeVisible && !logic.chromeHidden) Positioned.fill( @@ -316,7 +313,10 @@ class _PlayerPageState extends State with WidgetsBindingObserver { // 票据三态之一:起播前正在向后端要签名地址 if (selected && logic.ticketLoading) const Center( - child: CircularProgressIndicator(color: AppColors.blueSoft), + child: CircularProgressIndicator( + key: Key('player-ticket-loading'), + color: AppColors.blue, + ), ), // 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载 if (selected && !logic.ticketLoading && logic.ticketError != null) @@ -348,7 +348,10 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), if (selected && logic.session.loading) const Center( - child: CircularProgressIndicator(color: AppColors.blueSoft), + child: CircularProgressIndicator( + key: Key('player-session-loading'), + color: AppColors.blue, + ), ), if (selected && !ready && logic.session.error != null) Center( @@ -550,3 +553,34 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ); } } + +class _PlayerFallbackBackground extends StatelessWidget { + const _PlayerFallbackBackground({required this.posterUrl}); + + final String posterUrl; + + @override + Widget build(BuildContext context) { + if (posterUrl.trim().isEmpty) { + return const ColoredBox( + key: Key('player-fallback-background'), + color: Colors.black, + ); + } + return Stack( + key: const Key('player-fallback-background'), + fit: StackFit.expand, + children: [ + RemoteImage( + posterUrl, + key: const Key('player-fallback-cover'), + fit: BoxFit.cover, + ), + const ColoredBox( + key: Key('player-fallback-scrim'), + color: Color(0x66000000), + ), + ], + ); + } +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 345d15a..92896d9 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -49,6 +49,26 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('should_fall_back_to_black_when_poster_is_empty', (tester) async { + await _pumpApp( + tester, + catalog: FakeCatalogRepository(dramas: const [_noCoverDrama]), + ); + await _openSeriesPlayer( + tester, + cardKey: const Key('home-drama-card-no-cover-demo'), + ); + + expect( + tester + .widget( + find.byKey(const Key('player-fallback-background')), + ) + .color, + Colors.black, + ); + }); + testWidgets('should_show_center_toggle_only_when_paused', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); @@ -283,9 +303,14 @@ void main() { ); expect(find.byKey(const Key('player-background')), findsNothing); final fallback = find.byKey(const Key('player-fallback-background')); - if (fallback.evaluate().isNotEmpty) { - expect(tester.widget(fallback).color, Colors.black); - } + expect(fallback, findsOneWidget); + expect(find.byKey(const Key('player-fallback-cover')), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('player-fallback-scrim'))) + .color, + const Color(0x66000000), + ); expect( tester .widget(find.byKey(const Key('player-scaffold'))) @@ -1024,3 +1049,20 @@ const _lockedDrama = Drama( ), ], ); + +const _noCoverDrama = Drama( + id: 'no-cover-demo', + title: 'No Cover Demo', + synopsis: 'No cover synopsis', + posterUrl: '', + detailLoaded: true, + episodes: [ + Episode( + id: 'no-cover-demo-1', + number: 1, + title: 'No Cover Episode', + isFree: true, + videoUrl: 'https://example.test/no-cover-demo-1.mp4', + ), + ], +); From d0c1a5f49944ab0d75e1d44297fd9ede7208aaa9 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:05:47 +0800 Subject: [PATCH 084/124] =?UTF-8?q?fix(flutter):=20=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=A2=9E=E5=8A=A0=E7=99=BB=E5=BD=95=E9=97=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_id.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_zh.arb | 1 + .../player/widgets/drama_comments_sheet.dart | 88 ++++++++------ test/comment/drama_comments_sheet_test.dart | 112 +++++++++++++++++- 9 files changed, 177 insertions(+), 41 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bdfda89..1dab89c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -202,6 +202,7 @@ "share": "Share", "noComments": "No comments yet.", "writeComment": "Write a comment", + "commentLoginHint": "Sign in to comment", "postComment": "Post", "loadMore": "Load more", "commentsLoadFailed": "Comments could not be loaded.", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index eba5ffb..4b28acb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -176,6 +176,7 @@ "share": "Bagikan", "noComments": "Belum ada komentar.", "writeComment": "Tulis komentar", + "commentLoginHint": "Masuk untuk berkomentar", "postComment": "Kirim", "loadMore": "Muat lebih banyak", "commentsLoadFailed": "Komentar tidak dapat dimuat.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a646edf..a66503a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -922,6 +922,12 @@ abstract class AppLocalizations { /// **'Write a comment'** String get writeComment; + /// No description provided for @commentLoginHint. + /// + /// In en, this message translates to: + /// **'Sign in to comment'** + String get commentLoginHint; + /// No description provided for @postComment. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f7bca93..cc597d8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -448,6 +448,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get writeComment => 'Write a comment'; + @override + String get commentLoginHint => 'Sign in to comment'; + @override String get postComment => 'Post'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 08a7bbb..fb002d5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -449,6 +449,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get writeComment => 'Tulis komentar'; + @override + String get commentLoginHint => 'Masuk untuk berkomentar'; + @override String get postComment => 'Kirim'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f29c0bd..6856d4c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -443,6 +443,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get writeComment => '写评论'; + @override + String get commentLoginHint => '登录后参与评论'; + @override String get postComment => '发布'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 00541ed..10e0a0d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -176,6 +176,7 @@ "share": "分享", "noComments": "还没有评论。", "writeComment": "写评论", + "commentLoginHint": "登录后参与评论", "postComment": "发布", "loadMore": "加载更多", "commentsLoadFailed": "评论加载失败,请重试。", diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 6576a35..c3b4c34 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -10,6 +10,7 @@ import '../../../core/data/models/drama.dart'; import '../../../core/data/repositories/comment_repository.dart'; import '../../../core/data/repositories/interaction_repository.dart'; import '../../../core/data/services/comment_controller.dart'; +import '../../../core/data/services/auth_controller.dart'; import '../../../core/formatting/error_text.dart'; import '../../../core/platform/haptics.dart'; import '../../../core/theme/app_colors.dart'; @@ -64,8 +65,8 @@ class _DramaCommentsSheetState extends State { Future _post() async { if (_controller.submitting) return; - if (_input.text.trim().isEmpty) return; if (!await ensureSignedIn(context) || !mounted) return; + if (_input.text.trim().isEmpty) return; unawaited(context.read().lightImpact()); final result = await _controller.publish(_input.text, replyTo: _replyTo); if (!mounted) return; @@ -90,6 +91,13 @@ class _DramaCommentsSheetState extends State { setState(() => _replyTo = null); } + Future _authenticateForComment() async { + _inputFocus.unfocus(); + await ensureSignedIn(context); + if (!mounted || !context.read().isSignedIn) return; + _inputFocus.requestFocus(); + } + Future _like(DramaComment comment) async { if (!await ensureSignedIn(context) || !mounted) return; unawaited(context.read().lightImpact()); @@ -351,44 +359,52 @@ class _DramaCommentsSheetState extends State { ); } - Widget _composer(AppLocalizations l10n) => Row( - children: [ - Expanded( - child: SizedBox( - height: _composerHeight, - child: TextField( - key: const Key('comment-input'), - controller: _input, - focusNode: _inputFocus, - maxLength: _maxCommentLength, - maxLines: 1, - textAlignVertical: TextAlignVertical.center, - style: const TextStyle(color: AppColors.cream, fontSize: 14), - cursorColor: AppColors.blue, - decoration: _composerDecoration(l10n.writeComment), - onSubmitted: (_) => _post(), + Widget _composer(AppLocalizations l10n) { + final signedIn = context.watch().isSignedIn; + return Row( + children: [ + Expanded( + child: SizedBox( + height: _composerHeight, + child: TextField( + key: const Key('comment-input'), + controller: _input, + focusNode: _inputFocus, + readOnly: !signedIn, + canRequestFocus: signedIn, + maxLength: _maxCommentLength, + maxLines: 1, + textAlignVertical: TextAlignVertical.center, + style: const TextStyle(color: AppColors.cream, fontSize: 14), + cursorColor: AppColors.blue, + decoration: _composerDecoration( + signedIn ? l10n.writeComment : l10n.commentLoginHint, + ), + onTap: signedIn ? null : _authenticateForComment, + onSubmitted: (_) => _post(), + ), ), ), - ), - const SizedBox(width: 8), - SizedBox.square( - dimension: _sendButtonSize, - child: IconButton.filled( - key: const Key('comment-post'), - tooltip: l10n.postComment, - padding: EdgeInsets.zero, - iconSize: 18, - onPressed: _controller.submitting ? null : _post, - icon: _controller.submitting - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.send_rounded), + const SizedBox(width: 8), + SizedBox.square( + dimension: _sendButtonSize, + child: IconButton.filled( + key: const Key('comment-post'), + tooltip: l10n.postComment, + padding: EdgeInsets.zero, + iconSize: 18, + onPressed: _controller.submitting ? null : _post, + icon: _controller.submitting + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send_rounded), + ), ), - ), - ], - ); + ], + ); + } } class _CommentThread extends StatelessWidget { diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index f23ad4a..9e8cff8 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/comment.dart'; @@ -41,6 +43,71 @@ void main() { }); }); + testWidgets('should_gate_guest_comment_input_without_focus', (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final repository = FakeCommentRepository(pages: [const CommentPage()]); + + await _pumpSheet(tester, repository: repository); + final input = find.byKey(const Key('comment-input')); + final field = tester.widget(input); + + expect(field.decoration?.hintText, 'Sign in to comment'); + expect(field.focusNode?.hasFocus, isFalse); + await tester.tap(input); + await tester.pumpAndSettle(); + + expect(find.text('Sign in to continue'), findsOneWidget); + expect(tester.widget(input).focusNode?.hasFocus, isFalse); + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + expect(tester.widget(input).focusNode?.hasFocus, isFalse); + }); + + testWidgets('should_focus_comment_input_after_successful_login', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final repository = FakeCommentRepository(pages: [const CommentPage()]); + final auth = await _pumpSheet(tester, repository: repository); + + await tester.tap(find.byKey(const Key('comment-input'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Sign In')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + (auth as _TestAuthController).setSignedIn(true); + await tester.pump(); + Navigator.of(tester.element(find.byKey(const Key('auth-landing')))).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect( + tester + .widget(find.byKey(const Key('comment-input'))) + .focusNode + ?.hasFocus, + isTrue, + ); + }); + + testWidgets('should_gate_guest_post_without_creating_comment', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final repository = FakeCommentRepository(pages: [const CommentPage()]); + + await _pumpSheet(tester, repository: repository); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); + + expect(find.text('Sign in to continue'), findsOneWidget); + expect(repository.publishCalls, isEmpty); + }); + testWidgets('should_show_server_comments_and_append_next_cursor_page', ( tester, ) async { @@ -392,7 +459,7 @@ void main() { }); } -Future _pumpSheet( +Future _pumpSheet( WidgetTester tester, { required FakeCommentRepository repository, FakeInteractionRepository? interactions, @@ -421,16 +488,51 @@ Future _pumpSheet( ), ); await tester.pumpAndSettle(); + return auth; } Future _auth() async { final client = ApiClient( baseUrl: 'http://127.0.0.1:8081', - adapter: FakeHttpAdapter( - (_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'), - ), + adapter: FakeHttpAdapter((options) async { + if (options.uri.path == '/app/auth/login') { + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'accessToken': 'access', + 'refreshToken': 'refresh', + 'expiresIn': 7200, + 'user': {'uid': '9', 'nickname': 'Viewer', 'avatarUrl': ''}, + }, + }), + ); + } + return FakeHttpResponse('{"code":0,"message":"ok","data":null}'); + }), + ); + final store = AuthSessionStore(); + final session = await store.read(); + final auth = _TestAuthController( + AuthApi(client), + store, + signedIn: session != null, ); - final auth = AuthController(AuthApi(client), AuthSessionStore()); await auth.load(); return auth; } + +class _TestAuthController extends AuthController { + _TestAuthController(super.api, super.store, {required this._signedIn}); + + bool _signedIn; + + @override + bool get isSignedIn => _signedIn; + + void setSignedIn(bool value) { + _signedIn = value; + notifyListeners(); + } +} From 8493c4ef3b7ca5a479a4af21e34f2e91942b1572 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:09:10 +0800 Subject: [PATCH 085/124] =?UTF-8?q?fix(flutter):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E9=A1=B5=E9=9D=A2=E8=BF=94=E5=9B=9E=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/subpage_header.dart | 57 ++++++++++++++----- test/core/subpage_header_test.dart | 36 ++++++++++-- test/discover/filter_page_test.dart | 2 +- test/discover/theater_pages_test.dart | 2 +- test/drama_detail/drama_detail_view_test.dart | 15 +++++ test/mine/settings_page_test.dart | 2 +- test/notice/notices_entry_test.dart | 2 +- 7 files changed, 95 insertions(+), 21 deletions(-) diff --git a/lib/core/widgets/subpage_header.dart b/lib/core/widgets/subpage_header.dart index e98130d..ac44985 100644 --- a/lib/core/widgets/subpage_header.dart +++ b/lib/core/widgets/subpage_header.dart @@ -7,14 +7,21 @@ import '../theme/app_colors.dart'; const double _height = 52; const double _heightWithSubtitle = 64; const double _horizontal = 14; -const double _backSize = 36; +const double _backSize = 40; const double _backGap = 12; const double _titleFontSize = 18; const double _subtitleFontSize = 12; const double _actionGap = 8; -const double _iconSize = 20; +const double _iconSize = 22; +const double _iconWeight = 600; +const double _borderWidth = 1; +const double _shadowBlur = 8; +const Color _lightShadowColor = Color(0x14000000); +const Color _darkBackground = Color(0x59000000); +const Color _darkShadowColor = Color(0x33000000); +const Offset _lightShadowOffset = Offset(0, 2); -/// 二级页面统一返回按钮,浅色页使用 card 圆底,深色页使用透明底。 +/// 二级页面统一返回按钮,浅色页使用白底描边,深色页使用半透明黑底。 class AppSubpageBackButton extends StatelessWidget { const AppSubpageBackButton({ super.key, @@ -35,16 +42,40 @@ class AppSubpageBackButton extends StatelessWidget { height: _backSize, child: Tooltip( message: MaterialLocalizations.of(context).backButtonTooltip, - child: Material( - color: dark ? Colors.transparent : AppColors.card, - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onBack ?? () => Navigator.of(context).maybePop(), - child: Icon( - Icons.chevron_left_rounded, - color: dark ? Colors.white : AppColors.onSurface, - size: _iconSize, + child: DecoratedBox( + key: const Key('subpage-back-shadow'), + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: dark ? _darkShadowColor : _lightShadowColor, + blurRadius: _shadowBlur, + offset: dark ? Offset.zero : _lightShadowOffset, + ), + ], + ), + child: Material( + color: dark ? _darkBackground : AppColors.card, + shape: CircleBorder( + side: dark + ? BorderSide.none + : const BorderSide( + color: AppColors.border, + width: _borderWidth, + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onBack ?? () => Navigator.of(context).maybePop(), + child: Center( + child: Icon( + Icons.chevron_left_rounded, + color: dark ? Colors.white : AppColors.onSurface, + size: _iconSize, + weight: _iconWeight, + ), + ), ), ), ), diff --git a/test/core/subpage_header_test.dart b/test/core/subpage_header_test.dart index 7548468..353c4e6 100644 --- a/test/core/subpage_header_test.dart +++ b/test/core/subpage_header_test.dart @@ -23,7 +23,7 @@ void main() { expect(tester.getSize(find.byKey(const Key('subpage-header'))).height, 52); expect( tester.getSize(find.byKey(const Key('sample-back'))), - const Size(36, 36), + const Size(40, 40), ); final title = tester.widget(find.byKey(const Key('subpage-title'))); expect(title.style?.fontSize, 18); @@ -32,6 +32,24 @@ void main() { final back = tester.getRect(find.byKey(const Key('sample-back'))); final titleRect = tester.getRect(find.byKey(const Key('subpage-title'))); expect(titleRect.left - back.right, closeTo(12, 0.5)); + final surface = tester.widget( + find.descendant( + of: find.byKey(const Key('sample-back')), + matching: find.byType(Material), + ), + ); + expect(surface.color, AppColors.card); + expect((surface.shape! as CircleBorder).side.color, AppColors.border); + final shadow = + tester + .widget( + find.byKey(const Key('subpage-back-shadow')), + ) + .decoration + as BoxDecoration; + expect(shadow.boxShadow!.single.color, const Color(0x14000000)); + expect(shadow.boxShadow!.single.blurRadius, 8); + expect(shadow.boxShadow!.single.offset, const Offset(0, 2)); }); testWidgets('should_use_64_height_when_subtitle_exists', (tester) async { @@ -52,7 +70,7 @@ void main() { expect(find.byKey(const Key('subpage-subtitle')), findsOneWidget); }); - testWidgets('should_use_transparent_dark_back_button', (tester) async { + testWidgets('should_use_translucent_dark_back_button', (tester) async { await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), @@ -79,8 +97,18 @@ void main() { matching: find.byIcon(Icons.chevron_left_rounded), ), ); - expect(material.color, Colors.transparent); + expect(material.color, const Color(0x59000000)); expect(icon.color, Colors.white); - expect(icon.size, 20); + expect(icon.size, 22); + expect(icon.weight, 600); + final shadow = + tester + .widget( + find.byKey(const Key('subpage-back-shadow')), + ) + .decoration + as BoxDecoration; + expect(shadow.boxShadow!.single.color, const Color(0x33000000)); + expect(shadow.boxShadow!.single.blurRadius, 8); }); } diff --git a/test/discover/filter_page_test.dart b/test/discover/filter_page_test.dart index 97fd6ed..10fe4c2 100644 --- a/test/discover/filter_page_test.dart +++ b/test/discover/filter_page_test.dart @@ -186,7 +186,7 @@ final List _filterDramas = [ ]; void _expectUnifiedHeader(WidgetTester tester, Key backKey) { - expect(tester.getSize(find.byKey(backKey)), const Size(36, 36)); + expect(tester.getSize(find.byKey(backKey)), const Size(40, 40)); final title = tester.widget(find.byKey(const Key('subpage-title'))); expect(title.style?.fontSize, 18); expect(title.style?.fontWeight, FontWeight.w800); diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 29442e9..bd12f50 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -220,7 +220,7 @@ List _rankDramas(int count) => [ ]; void _expectUnifiedHeader(WidgetTester tester, Key backKey) { - expect(tester.getSize(find.byKey(backKey)), const Size(36, 36)); + expect(tester.getSize(find.byKey(backKey)), const Size(40, 40)); final title = tester.widget(find.byKey(const Key('subpage-title'))); expect(title.style?.fontSize, 18); expect(title.style?.fontWeight, FontWeight.w800); diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index 370b205..9300e08 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -58,6 +58,21 @@ void main() { tester.getSize(find.byKey(const Key('detail-poster'))), const Size(96, 128), ); + expect( + tester.getSize(find.byKey(const Key('detail-back'))), + const Size(40, 40), + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('detail-back')), + matching: find.byType(Material), + ), + ) + .color, + const Color(0x59000000), + ); expect( tester .widget(find.byKey(const Key('detail-title'))) diff --git a/test/mine/settings_page_test.dart b/test/mine/settings_page_test.dart index d748fde..3e1a8f0 100644 --- a/test/mine/settings_page_test.dart +++ b/test/mine/settings_page_test.dart @@ -54,7 +54,7 @@ void main() { expect(find.byKey(const Key('settings-page')), findsOneWidget); expect( tester.getSize(find.byKey(const Key('settings-back'))), - const Size(36, 36), + const Size(40, 40), ); final title = tester.widget(find.byKey(const Key('subpage-title'))); expect(title.style?.fontSize, 18); diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index 140352a..5515a63 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -54,7 +54,7 @@ void main() { expect(find.byKey(const Key('notices-page')), findsOneWidget); expect( tester.getSize(find.byKey(const Key('notices-back'))), - const Size(36, 36), + const Size(40, 40), ); final title = tester.widget(find.byKey(const Key('subpage-title'))); expect(title.style?.fontSize, 18); From 870a170fadec97fe8340af5cc6caf6b803d47eb8 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:12:27 +0800 Subject: [PATCH 086/124] =?UTF-8?q?fix(flutter):=20=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=A2=9E=E5=8A=A0=E7=99=BB=E5=BD=95=E9=97=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/comments_auth.dart | 24 +++++++++++++++++++++ lib/pages/player/player_view.dart | 9 ++++++-- test/player/player_fullscreen_test.dart | 28 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 lib/pages/player/comments_auth.dart diff --git a/lib/pages/player/comments_auth.dart b/lib/pages/player/comments_auth.dart new file mode 100644 index 0000000..c1178bc --- /dev/null +++ b/lib/pages/player/comments_auth.dart @@ -0,0 +1,24 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/widgets/auth_gate.dart'; + +/// 评论入口统一登录闸:登录成功且页面仍存在时再打开评论面板。 +Future openCommentsWithAuth( + BuildContext context, + Drama drama, { + required Future Function(BuildContext context, Drama drama) show, +}) async { + final auth = context.read(); + if (auth.isSignedIn) { + await show(context, drama); + return; + } + await ensureSignedIn(context); + if (!context.mounted || !auth.isSignedIn) return; + await show(context, drama); +} diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index c08ab0a..b03546d 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -22,6 +22,7 @@ import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; +import 'comments_auth.dart'; import 'player_binding.dart'; import 'player_logic.dart'; import 'widgets/home_feed_header.dart'; @@ -454,7 +455,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ); } }, - onComments: () => logic.showComments(context, drama), + onComments: () => unawaited( + openCommentsWithAuth(context, drama, show: logic.showComments), + ), onShare: () => logic.shareDrama(context, drama), onDetail: () => unawaited(logic.openDetail(context)), onOpenFullPlayer: () async { @@ -498,7 +501,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onDetail: () => unawaited(logic.openDetail(context)), onSpeed: () => unawaited(_openSpeedSheet(context)), onEpisodes: () => logic.showEpisodes(context), - onComments: () => logic.showComments(context, drama), + onComments: () => unawaited( + openCommentsWithAuth(context, drama, show: logic.showComments), + ), onLike: () => unawaited(_toggleLike(context, interactions, drama)), onSave: () => unawaited(_toggleSave(context, drama)), diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 92896d9..2e17534 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -69,6 +69,34 @@ void main() { ); }); + testWidgets('should_gate_comments_before_opening_sheet_when_signed_out', ( + tester, + ) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + + await tester.tap(find.byKey(const Key('player-rail-comments'))); + await tester.pumpAndSettle(); + + expect(find.text('Sign in to continue'), findsOneWidget); + expect(find.byKey(const Key('player-comments-sheet')), findsNothing); + }); + + testWidgets('should_open_feed_comments_when_signed_in', (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'access', + 'auth.refreshToken': 'refresh', + 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', + }); + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('rail-comments'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); + }); + testWidgets('should_show_center_toggle_only_when_paused', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); From c1dd0335812a24d1a16ae62dbbd2d79998a414ce Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:15:53 +0800 Subject: [PATCH 087/124] =?UTF-8?q?fix(flutter):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E9=A1=B6=E9=83=A8=E5=9C=86=E5=BD=A2?= =?UTF-8?q?=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/subpage_header.dart | 51 ++++++++++++++---- lib/pages/drama_detail/drama_detail_view.dart | 54 ++++++++++++++----- .../widgets/drama_detail_header.dart | 40 ++------------ test/drama_detail/drama_detail_view_test.dart | 14 +++++ 4 files changed, 97 insertions(+), 62 deletions(-) diff --git a/lib/core/widgets/subpage_header.dart b/lib/core/widgets/subpage_header.dart index ac44985..6924433 100644 --- a/lib/core/widgets/subpage_header.dart +++ b/lib/core/widgets/subpage_header.dart @@ -21,27 +21,31 @@ const Color _darkBackground = Color(0x59000000); const Color _darkShadowColor = Color(0x33000000); const Offset _lightShadowOffset = Offset(0, 2); -/// 二级页面统一返回按钮,浅色页使用白底描边,深色页使用半透明黑底。 -class AppSubpageBackButton extends StatelessWidget { - const AppSubpageBackButton({ +/// 二级页面统一圆形操作按钮,返回、分享和更多共用同一尺寸与表面样式。 +class AppSubpageCircleButton extends StatelessWidget { + const AppSubpageCircleButton({ super.key, - required this.backKey, + required this.buttonKey, + required this.tooltip, + required this.icon, + required this.onPressed, this.dark = false, - this.onBack, }); - final Key backKey; + final Key buttonKey; + final String tooltip; + final IconData icon; + final VoidCallback onPressed; final bool dark; - final VoidCallback? onBack; @override Widget build(BuildContext context) { return SizedBox( - key: backKey, + key: buttonKey, width: _backSize, height: _backSize, child: Tooltip( - message: MaterialLocalizations.of(context).backButtonTooltip, + message: tooltip, child: DecoratedBox( key: const Key('subpage-back-shadow'), decoration: BoxDecoration( @@ -67,10 +71,10 @@ class AppSubpageBackButton extends StatelessWidget { clipBehavior: Clip.antiAlias, child: InkWell( customBorder: const CircleBorder(), - onTap: onBack ?? () => Navigator.of(context).maybePop(), + onTap: onPressed, child: Center( child: Icon( - Icons.chevron_left_rounded, + icon, color: dark ? Colors.white : AppColors.onSurface, size: _iconSize, weight: _iconWeight, @@ -84,6 +88,31 @@ class AppSubpageBackButton extends StatelessWidget { } } +/// 二级页面统一返回按钮,浅色页使用白底描边,深色页使用半透明黑底。 +class AppSubpageBackButton extends StatelessWidget { + const AppSubpageBackButton({ + super.key, + required this.backKey, + this.dark = false, + this.onBack, + }); + + final Key backKey; + final bool dark; + final VoidCallback? onBack; + + @override + Widget build(BuildContext context) { + return AppSubpageCircleButton( + buttonKey: backKey, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Icons.chevron_left_rounded, + dark: dark, + onPressed: onBack ?? () => Navigator.of(context).maybePop(), + ); + } +} + /// 二级页面统一页头:返回按钮、左对齐标题、可选副标题与右侧操作。 class AppSubpageHeader extends StatelessWidget { const AppSubpageHeader({ diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index f9a9a06..4248772 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -34,8 +34,10 @@ const String _menuDownload = 'download'; const String _menuShare = 'share'; const String _menuReport = 'report'; const int _maxTags = 4; -const double _moreSize = 36; -const double _moreIconSize = 18; +const double _stateHeaderHeight = 52; +const double _stateHeaderHorizontal = 14; +const double _stateTitleGap = 12; +const double _stateTitleSize = 18; final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -189,7 +191,35 @@ class _DramaDetailPageState extends State { body: SafeArea( child: Column( children: [ - AppSubpageHeader(title: title, backKey: const Key('detail-back')), + SizedBox( + height: _stateHeaderHeight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _stateHeaderHorizontal, + ), + child: Row( + children: [ + const AppSubpageBackButton( + backKey: Key('detail-back'), + dark: true, + ), + const SizedBox(width: _stateTitleGap), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: _stateTitleSize, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ), + ), Expanded(child: child), ], ), @@ -285,17 +315,13 @@ class _DramaDetailPageState extends State { ), ), ], - child: Container( - width: _moreSize, - height: _moreSize, - decoration: const BoxDecoration( - color: AppColors.card, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.more_horiz_rounded, - size: _moreIconSize, - color: AppColors.onSurface, + child: IgnorePointer( + child: AppSubpageCircleButton( + buttonKey: const Key('detail-more-visual'), + tooltip: l10n.more, + icon: Icons.more_horiz_rounded, + dark: true, + onPressed: () {}, ), ), ); diff --git a/lib/pages/drama_detail/widgets/drama_detail_header.dart b/lib/pages/drama_detail/widgets/drama_detail_header.dart index 833893b..d1da929 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_header.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_header.dart @@ -18,8 +18,6 @@ const double _infoGap = 14; const double _favoriteHeight = 34; const double _favoriteRadius = 17; const double _favoriteHorizontal = 12; -const double _circleSize = 36; -const double _iconSize = 18; /// 详情页顶部操作与短剧主信息所需的交互回调。 class DramaDetailHeaderActions { @@ -87,10 +85,11 @@ class DramaDetailHeader extends StatelessWidget { onBack: actions.onBack, ), const Spacer(), - _CircleAction( - actionKey: const Key('detail-share'), + AppSubpageCircleButton( + buttonKey: const Key('detail-share'), tooltip: l10n.share, icon: Icons.share_outlined, + dark: true, onPressed: actions.onShare, ), const SizedBox(width: 8), @@ -201,36 +200,3 @@ class DramaDetailHeader extends StatelessWidget { ); } } - -class _CircleAction extends StatelessWidget { - const _CircleAction({ - required this.actionKey, - required this.tooltip, - required this.icon, - required this.onPressed, - }); - - final Key actionKey; - final String tooltip; - final IconData icon; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return Container( - key: actionKey, - width: _circleSize, - height: _circleSize, - decoration: const BoxDecoration( - color: AppColors.card, - shape: BoxShape.circle, - ), - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - icon: Icon(icon, size: _iconSize, color: AppColors.onSurface), - ), - ); - } -} diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index 9300e08..5fe78a8 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -94,6 +94,20 @@ void main() { expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 46); expect(find.byKey(const Key('detail-share')), findsOneWidget); expect(find.byKey(const Key('detail-more')), findsOneWidget); + for (final key in const [Key('detail-share'), Key('detail-more')]) { + expect(tester.getSize(find.byKey(key)), const Size(40, 40)); + expect( + tester + .widget( + find.descendant( + of: find.byKey(key), + matching: find.byType(Material), + ), + ) + .color, + const Color(0x59000000), + ); + } await tester.tap(find.byKey(const Key('detail-more'))); await tester.pumpAndSettle(); From 81bb1543bbbdcb9af7eb3715f6cc8cd1769f5cb4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:21:06 +0800 Subject: [PATCH 088/124] =?UTF-8?q?feat(flutter):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E8=BE=93=E5=85=A5=E5=B9=B6=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/auth/auth_form_widgets.dart | 71 +++++------ lib/pages/auth/auth_view.dart | 174 +++++++++++++++----------- lib/pages/auth/auth_widgets.dart | 16 +-- test/auth/auth_k21_test.dart | 2 +- test/auth/auth_visual_test.dart | 111 +++++++++++++--- 5 files changed, 233 insertions(+), 141 deletions(-) diff --git a/lib/pages/auth/auth_form_widgets.dart b/lib/pages/auth/auth_form_widgets.dart index 576da35..81a58a7 100644 --- a/lib/pages/auth/auth_form_widgets.dart +++ b/lib/pages/auth/auth_form_widgets.dart @@ -22,11 +22,11 @@ class _AuthFields extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _fieldLabel(state.l10n.authEmailLabel), - const SizedBox(height: 8), + const SizedBox(height: 6), _emailField(context, hasError: emailError), - const SizedBox(height: 16), + const SizedBox(height: 14), _fieldLabel(state.l10n.authPasswordLabel), - const SizedBox(height: 8), + const SizedBox(height: 6), _passwordField(context, hasError: passwordError), if (state.status.signUpMode) ...[ const SizedBox(height: 8), @@ -60,7 +60,8 @@ class _AuthFields extends StatelessWidget { autofillHints: const [AutofillHints.email], onChanged: callbacks.onEmailChanged, onSubmitted: (_) => state.fields.passwordFocus.requestFocus(), - style: const TextStyle(color: AppColors.onPrimary, fontSize: 15), + style: const TextStyle(color: AppColors.onSurface, fontSize: 15), + cursorColor: AppColors.blue, decoration: _decoration( context: context, hint: 'name@example.com', @@ -91,7 +92,8 @@ class _AuthFields extends StatelessWidget { autofillHints: const [AutofillHints.password], onChanged: callbacks.onPasswordChanged, onSubmitted: (_) => callbacks.onSubmit(), - style: const TextStyle(color: AppColors.onPrimary, fontSize: 15), + style: const TextStyle(color: AppColors.onSurface, fontSize: 15), + cursorColor: AppColors.blue, decoration: _decoration( context: context, hint: l10n.authPasswordHint, @@ -125,8 +127,8 @@ class _AuthFields extends StatelessWidget { label, style: const TextStyle( color: AppColors.muted, - fontSize: 13, - fontWeight: FontWeight.w600, + fontSize: 12, + fontWeight: FontWeight.w700, ), ); @@ -140,7 +142,10 @@ class _AuthFields extends StatelessWidget { }) { final border = OutlineInputBorder( borderRadius: BorderRadius.circular(_fieldRadius), - borderSide: BorderSide.none, + borderSide: const BorderSide( + color: AppColors.border, + width: _fieldBorderWidth, + ), ); final errorColor = Theme.of(context).colorScheme.error; final errorBorder = OutlineInputBorder( @@ -149,11 +154,11 @@ class _AuthFields extends StatelessWidget { ); return InputDecoration( hintText: hint, - hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), + hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 14), prefixIcon: Icon( icon, size: 21, - color: focused ? AppColors.blueSoft : AppColors.muted, + color: focused ? AppColors.blue : AppColors.muted, ), prefixIconConstraints: const BoxConstraints( minWidth: _minTapTarget, @@ -161,7 +166,7 @@ class _AuthFields extends StatelessWidget { ), suffixIcon: suffix, filled: true, - fillColor: AppColors.bar, + fillColor: AppColors.card, isDense: true, contentPadding: const EdgeInsets.only(right: 16), border: border, @@ -229,8 +234,8 @@ class _AuthSubmitButton extends StatelessWidget { : Text( status.signUpMode ? l10n.signUp : l10n.signIn, style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, + fontSize: 15, + fontWeight: FontWeight.w800, ), ), ), @@ -273,7 +278,7 @@ class _ModeSegment extends StatelessWidget { child: Text( label, style: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.muted, + color: selected ? AppColors.onPrimary : AppColors.onSurface, fontSize: 14, fontWeight: FontWeight.w700, ), @@ -327,29 +332,11 @@ class _AuthBrand extends StatelessWidget { ); } - Widget _brandMark() => Container( + Widget _brandMark() => _brandMarkWidget( key: const Key('auth-brand-mark'), - width: _brandMarkSize, - height: _brandMarkSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: AppColors.blue.withValues(alpha: 0.32), - blurRadius: 24, - offset: const Offset(0, 10), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Image.asset( - 'assets/icon/splash_mark.png', - width: _brandMarkSize, - height: _brandMarkSize, - fit: BoxFit.cover, - ), - ), + size: compact ? _compactBrandMarkSize : _brandMarkSize, + radius: compact ? _compactBrandMarkRadius : _brandMarkRadius, + iconSize: compact ? _compactBrandIconSize : _brandIconSize, ); Widget _brandCopy(BuildContext context, {required bool centered}) => Column( @@ -361,9 +348,9 @@ class _AuthBrand extends StatelessWidget { AppLocalizations.of(context)!.appName, textAlign: centered ? TextAlign.center : TextAlign.start, style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 26, - fontWeight: FontWeight.w800, + color: AppColors.onSurface, + fontSize: 24, + fontWeight: FontWeight.w900, height: 1.1, ), ), @@ -372,8 +359,8 @@ class _AuthBrand extends StatelessWidget { tagline, textAlign: centered ? TextAlign.center : TextAlign.start, style: const TextStyle( - color: AppColors.blueSoft, - fontSize: 14, + color: AppColors.muted, + fontSize: 13, height: 1.35, ), ), @@ -406,7 +393,7 @@ class _AuthError extends StatelessWidget { child: Text( message, key: const Key('auth-error'), - style: TextStyle(color: color, fontSize: 13, height: 1.35), + style: TextStyle(color: color, fontSize: 12, height: 1.35), ), ), ], diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 18345a7..3fe70d9 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -11,6 +11,7 @@ import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import 'auth_logic.dart'; import 'phone_auth_sheet.dart'; @@ -19,18 +20,21 @@ part 'auth_widgets.dart'; part 'auth_form_widgets.dart'; const double _minTapTarget = 44; -const double _brandMarkSize = 48; +const double _brandMarkSize = 72; +const double _compactBrandMarkSize = 48; +const double _brandMarkRadius = 20; +const double _compactBrandMarkRadius = 14; +const double _brandIconSize = 36; +const double _compactBrandIconSize = 26; const double _contentMaxWidth = 440; // Auth keeps its approved 20px card silhouette. const double _cardRadius = AppDimens.authCardRadius; -const double _cardPadding = AppDimens.cardPadding; -const double _cardBorderWidth = 1; +const double _cardPadding = 20; const double _fieldHeight = 52; const double _fieldRadius = 14; -const double _fieldBorderWidth = 1.5; -const double _buttonHeight = 52; -const double _buttonRadius = 26; -const double _glowOpacity = 0.18; +const double _fieldBorderWidth = 1; +const double _buttonHeight = 50; +const double _buttonRadius = 25; const double _errorOpacity = 0.10; const double _errorBorderOpacity = 0.28; const double _enterSlideDistance = 16; @@ -39,11 +43,48 @@ const double _bottomContentPadding = 28; const double _compactTopContentPadding = 48; const double _compactHeightBreakpoint = 720; const double _compactBrandGap = 16; -const double _warmGlowSize = 260; -const double _warmGlowOpacity = 0.06; +const double _glowSize = 280; +const double _warmGlowOpacity = 0.18; +const double _entryButtonHeight = 50; +const double _entryButtonRadius = 25; +const double _entryButtonFontSize = 15; +const double _entryIconSize = 20; const Duration _enterDuration = Duration(milliseconds: 220); const Duration _segmentDuration = Duration(milliseconds: 160); +Widget _brandMarkWidget({ + Key? key, + required double size, + required double radius, + required double iconSize, +}) { + return Container( + key: key, + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue, AppColors.secondary], + ), + boxShadow: [ + BoxShadow( + color: AppColors.blue.withValues(alpha: 0.24), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: iconSize, + ), + ); +} + /// 登录 / 注册页:邮箱 + 密码。 class AuthPage extends StatefulWidget { const AuthPage({super.key, this.signUpMode = false}); @@ -147,34 +188,39 @@ class _AuthPageState extends State { backgroundColor: AppColors.ink, body: Stack( children: [ + const Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + key: Key('auth-page-background'), + decoration: BoxDecoration(gradient: AppTheme.pageGradient), + ), + ), + ), Positioned( - top: 0, - left: 0, - right: 0, - height: 420, + top: -90, + right: -70, + width: _glowSize, + height: _glowSize, child: IgnorePointer( child: Container( key: const Key('auth-background-glow'), decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + shape: BoxShape.circle, + gradient: RadialGradient( colors: [ - AppColors.blue.withValues(alpha: _glowOpacity), - AppColors.blue.withValues(alpha: 0.05), - AppColors.ink.withValues(alpha: 0), + AppColors.surfaceTop, + AppColors.surfaceTop.withValues(alpha: 0), ], - stops: const [0, 0.55, 1], ), ), ), ), ), Positioned( - top: 72, - right: -80, - width: _warmGlowSize, - height: _warmGlowSize, + left: -110, + bottom: -90, + width: _glowSize, + height: _glowSize, child: IgnorePointer( child: Container( key: const Key('auth-background-warm-glow'), @@ -239,23 +285,16 @@ class _AuthPageState extends State { child: Align( alignment: Alignment.topLeft, child: Padding( - padding: const EdgeInsets.only(left: 4, top: 4), - child: IconButton( - key: const Key('auth-back'), - tooltip: MaterialLocalizations.of( - context, - ).backButtonTooltip, - onPressed: () { + padding: const EdgeInsets.only(left: 14, top: 8), + child: AppSubpageBackButton( + backKey: const Key('auth-back'), + onBack: () { if (_emailEntryOpen) { setState(() => _emailEntryOpen = false); } else { Navigator.of(context).pop(); } }, - icon: const Icon( - Icons.arrow_back_rounded, - color: AppColors.onPrimary, - ), ), ), ), @@ -298,58 +337,44 @@ class _AuthPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( - child: Container( + child: _brandMarkWidget( key: const Key('auth-brand-logo'), - width: 64, - height: 64, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.blue, AppColors.secondary], - ), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 32, - ), + size: _brandMarkSize, + radius: _brandMarkRadius, + iconSize: _brandIconSize, ), ), - const SizedBox(height: 14), + const SizedBox(height: 16), Text( l10n.fruitShortsBrand, textAlign: TextAlign.center, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: 20, - fontWeight: FontWeight.w800, + fontSize: 24, + fontWeight: FontWeight.w900, ), ), const SizedBox(height: 6), Text( l10n.authLandingSubtitle, textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.subtle, fontSize: 11), + style: const TextStyle(color: AppColors.muted, fontSize: 13), ), - const SizedBox(height: 22), + const SizedBox(height: 28), _AuthEntryButton( key: const Key('auth-entry-phone'), label: l10n.authPhoneSignIn, icon: Icons.phone_android_rounded, - background: AppColors.input, - foreground: AppColors.onSurface, + primary: true, onTap: _openPhoneSheet, ), - const SizedBox(height: 11), + const SizedBox(height: 12), _AuthEntryButton( key: const Key('auth-entry-email'), label: l10n.authEmailSignIn, icon: Icons.mail_outline_rounded, - background: AppColors.input, - foreground: AppColors.onSurface, + primary: false, onTap: () => setState(() => _emailEntryOpen = true), ), const SizedBox(height: 18), @@ -417,33 +442,40 @@ class _AuthEntryButton extends StatelessWidget { const _AuthEntryButton({ super.key, required this.label, - required this.background, - required this.foreground, + required this.primary, required this.onTap, this.icon, }); final String label; - final Color background; - final Color foreground; + final bool primary; final VoidCallback onTap; final IconData? icon; @override Widget build(BuildContext context) { + final background = primary ? AppColors.blue : AppColors.card; + final foreground = primary ? AppColors.onPrimary : AppColors.onSurface; return Material( color: background, - borderRadius: BorderRadius.circular(9), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_entryButtonRadius), + side: primary + ? BorderSide.none + : const BorderSide(color: AppColors.border), + ), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, + borderRadius: BorderRadius.circular(_entryButtonRadius), child: SizedBox( - height: 44, + height: _entryButtonHeight, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (icon != null) Icon(icon, size: 16, color: foreground), - if (icon != null) const SizedBox(width: 7), + if (icon != null) + Icon(icon, size: _entryIconSize, color: foreground), + if (icon != null) const SizedBox(width: 8), Flexible( child: Text( label, @@ -451,8 +483,8 @@ class _AuthEntryButton extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( color: foreground, - fontSize: 13, - fontWeight: FontWeight.w600, + fontSize: _entryButtonFontSize, + fontWeight: FontWeight.w800, ), ), ), diff --git a/lib/pages/auth/auth_widgets.dart b/lib/pages/auth/auth_widgets.dart index 3934899..9355158 100644 --- a/lib/pages/auth/auth_widgets.dart +++ b/lib/pages/auth/auth_widgets.dart @@ -76,10 +76,7 @@ class _AuthCard extends StatelessWidget { decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_cardRadius), - border: Border.all( - color: AppColors.outlineVariantDark, - width: _cardBorderWidth, - ), + boxShadow: const [AppTheme.cardShadow], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -94,7 +91,7 @@ class _AuthCard extends StatelessWidget { Text( status.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle, style: const TextStyle( - color: AppColors.onPrimary, + color: AppColors.onSurface, fontSize: 25, fontWeight: FontWeight.w800, height: 1.2, @@ -149,12 +146,11 @@ class _AuthModeSwitch extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), ), child: Container( - height: _minTapTarget, - padding: const EdgeInsets.all(3), + height: 36, + padding: const EdgeInsets.all(2), decoration: BoxDecoration( - color: AppColors.bar, - borderRadius: BorderRadius.circular(22), - border: Border.all(color: AppColors.outlineVariantDark), + color: AppColors.raised, + borderRadius: BorderRadius.circular(18), ), child: Row( children: [ diff --git a/test/auth/auth_k21_test.dart b/test/auth/auth_k21_test.dart index 6312819..52612e9 100644 --- a/test/auth/auth_k21_test.dart +++ b/test/auth/auth_k21_test.dart @@ -53,7 +53,7 @@ void main() { final errorBorder = email.decoration!.errorBorder! as OutlineInputBorder; final scheme = Theme.of(tester.element(emailFinder)).colorScheme; expect(errorBorder.borderSide.color, scheme.error); - expect(errorBorder.borderSide.width, 1.5); + expect(errorBorder.borderSide.width, 1); }); } diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index ae17115..192ed41 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -13,6 +13,7 @@ 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/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; @@ -30,6 +31,7 @@ void main() { AuthApi? api, FakeHaptics? haptics, bool openEmail = true, + bool asRoute = false, }) async { final auth = AuthController( api ?? @@ -53,11 +55,15 @@ void main() { locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: const AuthPage(), + home: asRoute ? const _AuthRouteHost() : const AuthPage(), ), ), ); await tester.pump(const Duration(milliseconds: 220)); + if (asRoute) { + await tester.tap(find.byKey(const Key('auth-open'))); + await tester.pumpAndSettle(); + } if (openEmail) { await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); await tester.tap(find.byKey(const Key('auth-entry-email'))); @@ -78,21 +84,46 @@ void main() { ); expect( tester.getSize(find.byKey(const Key('auth-brand-logo'))), - const Size(64, 64), + const Size(72, 72), ); 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); + expect( + tester.getSize(find.byKey(const Key('auth-entry-phone'))).height, + 50, + ); + expect( + tester.getSize(find.byKey(const Key('auth-entry-email'))).height, + 50, + ); + final phoneButton = tester.widget( + find.descendant( + of: find.byKey(const Key('auth-entry-phone')), + matching: find.byType(Material), + ), + ); + expect(phoneButton.color, AppColors.blue); + final emailButton = tester.widget( + find.descendant( + of: find.byKey(const Key('auth-entry-email')), + matching: find.byType(Material), + ), + ); + expect(emailButton.color, AppColors.card); + expect( + (emailButton.shape! as RoundedRectangleBorder).side.color, + AppColors.border, + ); final glow = tester.widget( find.byKey(const Key('auth-background-glow')), ); final glowDecoration = glow.decoration! as BoxDecoration; - final gradient = glowDecoration.gradient! as LinearGradient; - for (final color in gradient.colors) { - expect(color.a, lessThanOrEqualTo(0.18)); - } + final gradient = glowDecoration.gradient! as RadialGradient; + expect(gradient.colors.first, AppColors.surfaceTop); + expect(gradient.colors.last.a, 0); await tester.tap(find.byKey(const Key('auth-entry-email'))); await tester.pumpAndSettle(); @@ -101,19 +132,28 @@ void main() { final cardDecoration = card.decoration! as BoxDecoration; expect(cardDecoration.color, AppColors.card); expect(cardDecoration.borderRadius, BorderRadius.circular(20)); - expect( - (cardDecoration.border! as Border).top.color, - AppColors.outlineVariantDark, - ); - expect((cardDecoration.border! as Border).top.width, 1); + expect(cardDecoration.boxShadow, contains(AppTheme.cardShadow)); + expect(card.padding, const EdgeInsets.all(20)); final email = tester.widget( find.byKey(const Key('auth-email')), ); - expect(email.decoration?.fillColor, AppColors.bar); + expect(email.decoration?.fillColor, AppColors.card); + expect(email.style?.color, AppColors.onSurface); + expect(email.style?.fontSize, 15); + expect(email.cursorColor, AppColors.blue); expect(tester.getSize(find.byKey(const Key('auth-email'))).height, 52); - final emailBorder = email.decoration!.border! as OutlineInputBorder; + final emailBorder = + email.decoration!.enabledBorder! as OutlineInputBorder; expect(emailBorder.borderRadius, BorderRadius.circular(14)); + expect(emailBorder.borderSide.color, AppColors.border); + expect(emailBorder.borderSide.width, 1); + final password = tester.widget( + find.byKey(const Key('auth-password')), + ); + expect(password.style?.color, AppColors.onSurface); + expect(password.style?.fontSize, 15); + expect(password.cursorColor, AppColors.blue); final submit = tester.widget( find.byKey(const Key('auth-submit')), @@ -123,11 +163,11 @@ void main() { submit.style!.backgroundColor!.resolve(const {WidgetState.pressed}), AppColors.bluePressed, ); - expect(tester.getSize(find.byKey(const Key('auth-submit'))).height, 52); + expect(tester.getSize(find.byKey(const Key('auth-submit'))).height, 50); expect( (submit.style!.shape!.resolve(const {})! as RoundedRectangleBorder) .borderRadius, - BorderRadius.circular(26), + BorderRadius.circular(25), ); expect(find.text('Sign In'), findsWidgets); @@ -139,6 +179,23 @@ void main() { }, ); + testWidgets('should_render_40px_back_button_when_page_is_pushed', ( + tester, + ) async { + await pumpAuthPage(tester, openEmail: false, asRoute: true); + + final back = find.byKey(const Key('auth-back')); + expect(tester.getSize(back), const Size(40, 40)); + expect( + tester + .widget( + find.descendant(of: back, matching: find.byType(Material)), + ) + .color, + AppColors.card, + ); + }); + testWidgets('should_fade_and_slide_content_in_within_220ms', (tester) async { await pumpAuthPage(tester); @@ -198,10 +255,10 @@ void main() { final focusedBorder = email.decoration!.focusedBorder! as OutlineInputBorder; expect(focusedBorder.borderSide.color, AppColors.blue); - expect(focusedBorder.borderSide.width, 1.5); + expect(focusedBorder.borderSide.width, 1); expect( tester.widget(find.byIcon(Icons.mail_outline_rounded)).color, - AppColors.blueSoft, + AppColors.blue, ); await tester.tap(find.byKey(const Key('auth-password-visibility'))); @@ -280,6 +337,7 @@ void main() { final error = tester.widget(errorFinder); final scheme = Theme.of(tester.element(errorFinder)).colorScheme; expect(error.style?.color, scheme.error); + expect(error.style?.fontSize, 12); expect( find.descendant( of: capsuleFinder, @@ -325,3 +383,22 @@ class _PendingAuthApi extends AuthApi { throw const ApiException(500, 'pending failure'); } } + +class _AuthRouteHost extends StatelessWidget { + const _AuthRouteHost(); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: TextButton( + key: const Key('auth-open'), + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const AuthPage())), + child: const Text('Open auth'), + ), + ), + ); + } +} From 794dc87083fade3c98aa1009f1234b106530b8a0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:31:59 +0800 Subject: [PATCH 089/124] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E5=81=9A?= =?UTF-8?q?=E6=94=B6=E8=97=8F=E4=B8=8E=E8=A7=82=E7=9C=8B=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/watch_records_controller.dart | 10 +- lib/l10n/app_en.arb | 13 + lib/l10n/app_id.arb | 3 + lib/l10n/app_localizations.dart | 18 + lib/l10n/app_localizations_en.dart | 13 + lib/l10n/app_localizations_id.dart | 13 + lib/l10n/app_localizations_zh.dart | 13 + lib/l10n/app_zh.arb | 3 + lib/pages/favorites/favorites_page.dart | 361 +++++++++++++++--- .../watch_records/watch_records_page.dart | 332 +++++----------- .../widgets/watch_record_card.dart | 248 ++++++++++++ test/library/favorites_page_test.dart | 27 ++ test/watch/watch_records_controller_test.dart | 37 ++ test/watch/watch_records_page_test.dart | 36 ++ 14 files changed, 855 insertions(+), 272 deletions(-) create mode 100644 lib/pages/watch_records/widgets/watch_record_card.dart diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index f3b819b..9862db3 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -128,11 +128,19 @@ class WatchRecordsController extends ChangeNotifier { final seen = {}; return [ for (final progress in recent) - if (seen.add(progress.dramaId)) WatchRecord(progress: progress), + if (seen.add(progress.dramaId)) + WatchRecord( + progress: progress, + drama: _detailCache[progress.dramaId], + detailLoading: !_detailCache.containsKey(progress.dramaId), + ), ]; } Future _hydrate(List dramaIds) async { + for (final id in dramaIds) { + if (_detailCache.containsKey(id)) await _loadDetail(id); + } final queue = [ for (final id in dramaIds) if (!_detailCache.containsKey(id)) id, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1dab89c..a926e71 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -500,6 +500,19 @@ "recordDeleteFailed": "Could not delete this record. Please retry.", "recordClearFailed": "Could not clear watch history. Please retry.", "clearAll": "Clear all", + "watchRecordsCount": "{count} records", + "@watchRecordsCount": { + "placeholders": { + "count": {"type": "int"} + } + }, + "favoritesCount": "{count} favorites", + "@favoritesCount": { + "placeholders": { + "count": {"type": "int"} + } + }, + "favoriteLongPressHint": "Long press a card to remove", "clearWatchHistoryTitle": "Clear watch history?", "clearWatchHistoryMessage": "This removes the watch history from your account.", "confirmClear": "Clear", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4b28acb..d79a440 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -474,6 +474,9 @@ "recordDeleteFailed": "Gagal menghapus riwayat ini. Coba lagi.", "recordClearFailed": "Gagal menghapus semua riwayat. Coba lagi.", "clearAll": "Hapus semua", + "watchRecordsCount": "{count} riwayat", + "favoritesCount": "{count} favorit", + "favoriteLongPressHint": "Tekan lama kartu untuk menghapus", "clearWatchHistoryTitle": "Hapus riwayat tontonan?", "clearWatchHistoryMessage": "Semua riwayat akan dihapus dari akun kamu.", "confirmClear": "Hapus", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a66503a..bda0476 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2074,6 +2074,24 @@ abstract class AppLocalizations { /// **'Clear all'** String get clearAll; + /// No description provided for @watchRecordsCount. + /// + /// In en, this message translates to: + /// **'{count} records'** + String watchRecordsCount(int count); + + /// No description provided for @favoritesCount. + /// + /// In en, this message translates to: + /// **'{count} favorites'** + String favoritesCount(int count); + + /// No description provided for @favoriteLongPressHint. + /// + /// In en, this message translates to: + /// **'Long press a card to remove'** + String get favoriteLongPressHint; + /// No description provided for @clearWatchHistoryTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index cc597d8..4e072fd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1068,6 +1068,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get clearAll => 'Clear all'; + @override + String watchRecordsCount(int count) { + return '$count records'; + } + + @override + String favoritesCount(int count) { + return '$count favorites'; + } + + @override + String get favoriteLongPressHint => 'Long press a card to remove'; + @override String get clearWatchHistoryTitle => 'Clear watch history?'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index fb002d5..86b40bb 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1068,6 +1068,19 @@ class AppLocalizationsId extends AppLocalizations { @override String get clearAll => 'Hapus semua'; + @override + String watchRecordsCount(int count) { + return '$count riwayat'; + } + + @override + String favoritesCount(int count) { + return '$count favorit'; + } + + @override + String get favoriteLongPressHint => 'Tekan lama kartu untuk menghapus'; + @override String get clearWatchHistoryTitle => 'Hapus riwayat tontonan?'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6856d4c..334e3d2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1049,6 +1049,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get clearAll => '清空'; + @override + String watchRecordsCount(int count) { + return '共 $count 条'; + } + + @override + String favoritesCount(int count) { + return '共 $count 部'; + } + + @override + String get favoriteLongPressHint => '长按卡片可取消收藏'; + @override String get clearWatchHistoryTitle => '清空观看记录?'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 10e0a0d..b22074d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -436,6 +436,9 @@ "recordDeleteFailed": "删除记录失败,请重试。", "recordClearFailed": "清空观看记录失败,请重试。", "clearAll": "清空", + "watchRecordsCount": "共 {count} 条", + "favoritesCount": "共 {count} 部", + "favoriteLongPressHint": "长按卡片可取消收藏", "clearWatchHistoryTitle": "清空观看记录?", "clearWatchHistoryMessage": "这会从当前账号移除全部观看记录。", "confirmClear": "清空", diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 0294fa0..8392e75 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -8,18 +8,35 @@ import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/formatting/count_format.dart'; import '../../core/formatting/error_text.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/library_drama_tile.dart'; -import '../../core/widgets/plain_page_scaffold.dart'; +import '../../core/widgets/remote_image.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; import 'favorites_binding.dart'; +const double _pageHorizontal = 14; +const double _cardRadius = 14; +const double _cardPadding = 10; +const double _coverWidth = 80; +const double _coverHeight = 106; +const double _coverRadius = 10; +const double _removeSize = 28; +const double _removeIconSize = 15; +const double _statsFontSize = 12; +const double _metaFontSize = 11; +const double _titleFontSize = 15; +const int _maxTags = 2; +const double _emptyListHeight = 360; + +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + /// 独立我的收藏页。 class FavoritesPage extends StatefulWidget { const FavoritesPage({super.key}); @@ -53,13 +70,26 @@ class _FavoritesPageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: PlainPageScaffold( - pageKey: const Key('favorites-page'), - backKey: const Key('favorites-back'), - title: l10n.myFavorites, - child: signedIn - ? _FavoritesBody(favorites: context.watch()) - : const AuthGatePanel(), + child: Scaffold( + key: const Key('favorites-page'), + backgroundColor: AppColors.ink, + body: SafeArea( + child: Column( + children: [ + AppSubpageHeader( + title: l10n.myFavorites, + backKey: const Key('favorites-back'), + ), + Expanded( + child: signedIn + ? _FavoritesBody( + favorites: context.watch(), + ) + : const AuthGatePanel(), + ), + ], + ), + ), ), ); } @@ -74,53 +104,110 @@ class _FavoritesBody extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; if (favorites.loading && favorites.cards.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const Center( + child: CircularProgressIndicator(key: Key('favorites-loading')), + ); } if (favorites.error != null && favorites.cards.isEmpty) { - return EmptyState( - key: const Key('favorites-error'), - icon: Icons.cloud_off_rounded, - title: errorText(l10n, favorites.error, l10n.favoritesError), - actionLabel: l10n.retry, - actionKey: const Key('favorites-retry'), - onAction: () => unawaited(favorites.load()), + return _refreshable( + favorites, + children: [ + SizedBox( + height: _emptyListHeight, + child: EmptyState( + key: const Key('favorites-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, favorites.error, l10n.favoritesError), + actionLabel: l10n.retry, + actionKey: const Key('favorites-retry'), + onAction: () => unawaited(favorites.load()), + ), + ), + ], ); } if (favorites.cards.isEmpty) { - return EmptyState( - key: const Key('favorites-empty'), - icon: Icons.favorite_border_rounded, - title: l10n.noFavorites, - subtitle: l10n.emptyHint, + return _refreshable( + favorites, + children: [ + SizedBox( + height: _emptyListHeight, + child: EmptyState( + key: const Key('favorites-empty'), + icon: Icons.favorite_border_rounded, + title: l10n.noFavorites, + subtitle: l10n.emptyHint, + ), + ), + ], ); } - return RefreshIndicator( - onRefresh: favorites.load, - child: ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), - itemCount: favorites.cards.length, - separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final drama = favorites.cards[index]; - return LibraryDramaTile( - key: ValueKey('favorite-card-${drama.id}'), + return _refreshable( + favorites, + children: [ + _summary(l10n), + for (final (index, drama) in favorites.cards.indexed) ...[ + if (index > 0) const SizedBox(height: 10), + _FavoriteCard( drama: drama, - onTap: () => _open(context, drama), - trailing: IconButton( - key: ValueKey('favorite-remove-${drama.id}'), - tooltip: l10n.remove, - onPressed: () => _remove(context, drama.id), - icon: const Icon( - Icons.favorite_rounded, - color: AppColors.favorite, - ), - ), - ); - }, + onOpen: () => _open(context, drama), + onRemove: () => _remove(context, drama.id), + ), + ], + ], + ); + } + + Widget _refreshable( + FavoriteController favorites, { + required List children, + }) { + return RefreshIndicator( + color: AppColors.blue, + backgroundColor: AppColors.card, + strokeWidth: 2, + onRefresh: favorites.load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 4, + _pageHorizontal, + 28, + ), + children: children, ), ); } + Widget _summary(AppLocalizations l10n) { + return Row( + children: [ + Text( + l10n.favoritesCount(favorites.cards.length), + key: const Key('favorites-count'), + style: const TextStyle( + color: AppColors.muted, + fontSize: _statsFontSize, + ), + ), + const Spacer(), + Flexible( + child: Text( + l10n.favoriteLongPressHint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: const TextStyle( + color: AppColors.subtle, + fontSize: _metaFontSize, + ), + ), + ), + ], + ); + } + Future _open(BuildContext context, Drama drama) async { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), @@ -137,3 +224,189 @@ class _FavoritesBody extends StatelessWidget { ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); } } + +class _FavoriteCard extends StatelessWidget { + const _FavoriteCard({ + required this.drama, + required this.onOpen, + required this.onRemove, + }); + + final Drama drama; + final VoidCallback onOpen; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final tags = _tags(); + return Container( + key: ValueKey('favorite-card-${drama.id}'), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(_cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onOpen, + onLongPress: onRemove, + child: Padding( + padding: const EdgeInsets.all(_cardPadding), + child: Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(_coverRadius), + child: RemoteImage( + drama.posterUrl, + key: ValueKey('favorite-cover-${drama.id}'), + width: _coverWidth, + height: _coverHeight, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + right: _removeSize, + top: 2, + ), + child: SizedBox( + height: _coverHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + key: ValueKey('favorite-title-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: _titleFontSize, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + if (tags.isNotEmpty) + SizedBox( + height: 20, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: + const NeverScrollableScrollPhysics(), + child: Row( + children: [ + for (final tag in tags) + Padding( + padding: const EdgeInsets.only( + right: 5, + ), + child: Container( + padding: + const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.raised, + borderRadius: + BorderRadius.circular(6), + ), + child: Text( + tag, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: _metaFontSize, + ), + ), + ), + ), + ], + ), + ), + ), + const Spacer(), + Row( + children: [ + const Icon( + Icons.local_fire_department_rounded, + color: AppColors.blue, + size: 13, + ), + const SizedBox(width: 3), + Text( + formatInteractionCount(drama.viewCount), + style: const TextStyle( + color: AppColors.muted, + fontSize: _metaFontSize, + ), + ), + const Spacer(), + Text( + l10n.episodeCountLabel(drama.episodeCount), + style: const TextStyle( + color: AppColors.muted, + fontSize: _metaFontSize, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + Positioned( + top: 0, + right: 0, + child: SizedBox.square( + dimension: _removeSize, + child: Material( + color: AppColors.badgePink, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: IconButton( + key: ValueKey('favorite-remove-${drama.id}'), + tooltip: l10n.remove, + padding: EdgeInsets.zero, + onPressed: onRemove, + icon: const Icon( + Icons.favorite_rounded, + color: AppColors.favorite, + size: _removeIconSize, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + List _tags() { + final values = []; + for (final source in [drama.genre, drama.category]) { + for (final part in source.split(_tagSplitter)) { + final value = part.trim(); + if (value.isNotEmpty && !values.contains(value)) values.add(value); + if (values.length == _maxTags) return values; + } + } + return values; + } +} diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index fb3e5b9..b1987f1 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -6,24 +6,25 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/models/watch_record.dart'; import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/watch_records_controller.dart'; import '../../core/formatting/error_text.dart'; -import '../../core/formatting/localized_date_time.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/plain_page_scaffold.dart'; -import '../../core/widgets/remote_image.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; import 'watch_records_binding.dart'; +import 'widgets/watch_record_card.dart'; + +const double _pageHorizontal = 14; +const double _statsFontSize = 12; +const double _emptyListHeight = 360; /// 独立观看记录页。 class WatchRecordsPage extends StatelessWidget { @@ -35,11 +36,24 @@ class WatchRecordsPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.light(), - child: PlainPageScaffold( - pageKey: const Key('watch-records-page'), - backKey: const Key('watch-records-back'), - title: l10n.watchHistory, - child: signedIn ? const _WatchRecordsBody() : const AuthGatePanel(), + child: Scaffold( + key: const Key('watch-records-page'), + backgroundColor: AppColors.ink, + body: SafeArea( + child: Column( + children: [ + AppSubpageHeader( + title: l10n.watchHistory, + backKey: const Key('watch-records-back'), + ), + Expanded( + child: signedIn + ? const _WatchRecordsBody() + : const AuthGatePanel(), + ), + ], + ), + ), ), ); } @@ -82,52 +96,97 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; if (controller.loading && controller.records.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const Center( + child: CircularProgressIndicator(key: Key('watch-records-loading')), + ); } if (controller.error != null && controller.records.isEmpty) { - return _FailureState( - message: errorText(l10n, controller.error, l10n.progressError), - onRetry: controller.load, + return _refreshable( + children: [ + SizedBox( + height: _emptyListHeight, + child: _FailureState( + message: errorText(l10n, controller.error, l10n.progressError), + onRetry: controller.load, + ), + ), + ], ); } if (controller.records.isEmpty) { - return EmptyState( - key: const Key('watch-records-empty'), - icon: Icons.history_rounded, - title: l10n.noWatchRecords, - subtitle: l10n.emptyHint, + return _refreshable( + children: [ + SizedBox( + height: _emptyListHeight, + child: EmptyState( + key: const Key('watch-records-empty'), + icon: Icons.history_rounded, + title: l10n.noWatchRecords, + subtitle: l10n.emptyHint, + ), + ), + ], ); } - return Column( + return _refreshable( children: [ - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - key: const Key('watch-records-clear'), - onPressed: controller.clearing ? null : _confirmClear, - icon: const Icon(Icons.delete_sweep_outlined), - label: Text(l10n.clearAll), + _summary(l10n), + for (final (index, record) in controller.records.indexed) ...[ + if (index > 0) const SizedBox(height: 10), + WatchRecordCard( + record: record, + onRetry: () => controller.retryDetail(record.dramaId), + onRemove: () => _remove(record.dramaId), + onOpen: record.drama == null ? null : () => _open(record.drama!), + ), + ], + ], + ); + } + + Widget _refreshable({required List children}) { + return RefreshIndicator( + color: AppColors.blue, + backgroundColor: AppColors.card, + strokeWidth: 2, + onRefresh: controller.load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 4, + _pageHorizontal, + 28, + ), + children: children, + ), + ); + } + + Widget _summary(AppLocalizations l10n) { + return Row( + children: [ + Text( + l10n.watchRecordsCount(controller.records.length), + key: const Key('watch-records-count'), + style: const TextStyle( + color: AppColors.muted, + fontSize: _statsFontSize, ), ), - Expanded( - child: RefreshIndicator( - onRefresh: controller.load, - child: ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 28), - itemCount: controller.records.length, - separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final record = controller.records[index]; - return _RecordTile( - record: record, - onRetry: () => controller.retryDetail(record.dramaId), - onRemove: () => _remove(record.dramaId), - onOpen: record.drama == null - ? null - : () => _open(record.drama!), - ); - }, - ), + const Spacer(), + TextButton.icon( + key: const Key('watch-records-clear'), + onPressed: controller.clearing ? null : _confirmClear, + style: TextButton.styleFrom( + foregroundColor: AppColors.blue, + minimumSize: const Size(44, 36), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + icon: const Icon(Icons.delete_sweep_outlined, size: 16), + label: Text( + l10n.clearAll, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), ), ), ], @@ -182,181 +241,6 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { } } -class _RecordTile extends StatelessWidget { - const _RecordTile({ - required this.record, - required this.onRetry, - required this.onRemove, - this.onOpen, - }); - - final WatchRecord record; - final VoidCallback onRetry; - final VoidCallback onRemove; - final VoidCallback? onOpen; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - if (record.detailFailed) { - return _FailedRecordTile(record: record, onRetry: onRetry); - } - final drama = record.drama; - if (drama == null || record.detailLoading) { - return Container( - key: ValueKey('watch-record-${record.dramaId}'), - height: AppDimens.watchPlaceholderHeight, - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: const Padding( - padding: EdgeInsets.all(AppDimens.innerPadding), - child: Center( - child: SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ), - ); - } - final episodeLabel = record.episodeNumber > 0 - ? l10n.episodeLabel(record.episodeNumber) - : l10n.episodeUnavailable; - return Material( - key: ValueKey('watch-record-${record.dramaId}'), - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onOpen, - child: Padding( - padding: const EdgeInsets.all(AppDimens.innerPadding), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: RemoteImage(drama.posterUrl, width: 72, height: 96), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - drama.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 5), - Text( - episodeLabel, - key: ValueKey('watch-record-episode-${record.dramaId}'), - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - ), - ), - const SizedBox(height: 7), - ClipRRect( - borderRadius: BorderRadius.circular( - AppDimens.progressRadius, - ), - child: LinearProgressIndicator( - minHeight: AppDimens.progressHeight, - value: record.progress.fraction, - backgroundColor: Colors.white24, - valueColor: const AlwaysStoppedAnimation( - AppColors.blueSoft, - ), - ), - ), - const SizedBox(height: 6), - Text( - l10n.resumeProgress( - _clock(record.progress.position), - _clock(record.progress.duration), - ), - style: const TextStyle( - color: AppColors.blueSoft, - fontSize: AppDimens.caption, - ), - ), - if (record.progress.updatedAt != null) - Text( - l10n.updatedAtLabel( - formatLocalizedDateTime( - context, - record.progress.updatedAt!, - ), - ), - key: ValueKey('watch-record-updated-${record.dramaId}'), - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - ), - ), - ], - ), - ), - IconButton( - key: ValueKey('watch-record-remove-${record.dramaId}'), - tooltip: l10n.remove, - onPressed: onRemove, - icon: const Icon(Icons.close_rounded), - ), - ], - ), - ), - ), - ); - } -} - -class _FailedRecordTile extends StatelessWidget { - const _FailedRecordTile({required this.record, required this.onRetry}); - - final WatchRecord record; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Container( - key: ValueKey('watch-record-failed-${record.dramaId}'), - padding: const EdgeInsets.all(AppDimens.innerPadding), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: Row( - children: [ - const Icon(Icons.broken_image_outlined, color: Colors.white38), - const SizedBox(width: 12), - Expanded( - child: Text( - l10n.detailLoadFailed, - style: const TextStyle(color: Colors.white70), - ), - ), - TextButton( - key: ValueKey('watch-record-retry-${record.dramaId}'), - onPressed: onRetry, - child: Text(l10n.retryDetail), - ), - ], - ), - ); - } -} - class _FailureState extends StatelessWidget { const _FailureState({required this.message, required this.onRetry}); @@ -375,9 +259,3 @@ class _FailureState extends StatelessWidget { ); } } - -String _clock(Duration value) { - final minutes = value.inMinutes; - final seconds = value.inSeconds.remainder(60); - return '$minutes:${seconds.toString().padLeft(2, '0')}'; -} diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart new file mode 100644 index 0000000..fdedd38 --- /dev/null +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -0,0 +1,248 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/watch_record.dart'; +import '../../../core/formatting/localized_date_time.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _cardRadius = 14; +const double _cardPadding = 10; +const double _coverWidth = 72; +const double _coverHeight = 96; +const double _coverRadius = 10; +const double _removeSize = 28; +const double _removeIconSize = 16; +const double _progressHeight = 4; +const double _progressRadius = 2; +const double _statsFontSize = 12; +const double _metaFontSize = 11; +const double _titleFontSize = 15; + +/// 观看记录卡片:覆盖加载、失败与完整记录三种状态。 +class WatchRecordCard extends StatelessWidget { + const WatchRecordCard({ + super.key, + required this.record, + required this.onRetry, + required this.onRemove, + this.onOpen, + }); + + final WatchRecord record; + final VoidCallback onRetry; + final VoidCallback onRemove; + final VoidCallback? onOpen; + + @override + Widget build(BuildContext context) { + if (record.detailFailed) { + return _FailedRecordTile(record: record, onRetry: onRetry); + } + final drama = record.drama; + if (drama == null || record.detailLoading) { + return _loadingCard(); + } + final l10n = AppLocalizations.of(context)!; + final episodeLabel = record.episodeNumber > 0 + ? l10n.episodeLabel(record.episodeNumber) + : l10n.episodeUnavailable; + return _card( + onTap: onOpen, + child: Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(_coverRadius), + child: RemoteImage( + drama.posterUrl, + key: ValueKey('watch-record-cover-${record.dramaId}'), + width: _coverWidth, + height: _coverHeight, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: _removeSize), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + key: ValueKey('watch-record-title-${record.dramaId}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: _titleFontSize, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + episodeLabel, + key: ValueKey('watch-record-episode-${record.dramaId}'), + style: const TextStyle( + color: AppColors.muted, + fontSize: _statsFontSize, + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(_progressRadius), + child: LinearProgressIndicator( + minHeight: _progressHeight, + value: record.progress.fraction, + backgroundColor: AppColors.border, + valueColor: const AlwaysStoppedAnimation( + AppColors.blue, + ), + ), + ), + const SizedBox(height: 4), + Text( + '${_clock(record.progress.position)} / ' + '${_clock(record.progress.duration)}', + style: const TextStyle( + color: AppColors.muted, + fontSize: _metaFontSize, + ), + ), + if (record.progress.updatedAt != null) + Text( + l10n.updatedAtLabel( + formatLocalizedDateTime( + context, + record.progress.updatedAt!, + ), + ), + key: ValueKey( + 'watch-record-updated-${record.dramaId}', + ), + style: const TextStyle( + color: AppColors.subtle, + fontSize: _metaFontSize, + ), + ), + ], + ), + ), + ), + ], + ), + Positioned(top: 0, right: 0, child: _removeButton(l10n.remove)), + ], + ), + ); + } + + Widget _loadingCard() => Container( + key: ValueKey('watch-record-${record.dramaId}'), + padding: const EdgeInsets.all(_cardPadding), + decoration: _cardDecoration(), + child: const SizedBox( + height: _coverHeight, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + ); + + Widget _card({required VoidCallback? onTap, required Widget child}) { + return Container( + key: ValueKey('watch-record-${record.dramaId}'), + decoration: _cardDecoration(), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(_cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(_cardPadding), + child: child, + ), + ), + ), + ); + } + + BoxDecoration _cardDecoration() => BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_cardRadius), + boxShadow: const [AppTheme.cardShadow], + ); + + Widget _removeButton(String tooltip) { + return SizedBox.square( + dimension: _removeSize, + child: Material( + color: AppColors.raised, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: IconButton( + key: ValueKey('watch-record-remove-${record.dramaId}'), + tooltip: tooltip, + padding: EdgeInsets.zero, + onPressed: onRemove, + icon: const Icon( + Icons.close_rounded, + size: _removeIconSize, + color: AppColors.muted, + ), + ), + ), + ); + } +} + +class _FailedRecordTile extends StatelessWidget { + const _FailedRecordTile({required this.record, required this.onRetry}); + + final WatchRecord record; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: ValueKey('watch-record-${record.dramaId}'), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Container( + key: ValueKey('watch-record-failed-${record.dramaId}'), + padding: const EdgeInsets.all(_cardPadding), + child: Row( + children: [ + const Icon(Icons.broken_image_outlined, color: AppColors.muted), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.detailLoadFailed, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ), + TextButton( + key: ValueKey('watch-record-retry-${record.dramaId}'), + onPressed: onRetry, + child: Text(l10n.retryDetail), + ), + ], + ), + ), + ); + } +} + +String _clock(Duration value) { + final minutes = value.inMinutes; + final seconds = value.inSeconds.remainder(60); + return '$minutes:${seconds.toString().padLeft(2, '0')}'; +} diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 3d12478..677cc33 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -10,6 +10,7 @@ import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/empty_state.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/favorites/favorites_page.dart'; @@ -47,9 +48,35 @@ void main() { await _pumpPage(tester, auth: auth, favorites: favorites); + expect(find.byKey(const Key('favorites-page')), findsOneWidget); + expect(find.byKey(const Key('favorites-back')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('favorites-count'))).data, + '2 favorites', + ); + expect(find.text('Long press a card to remove'), findsOneWidget); expect(find.text('Drama signal'), findsOneWidget); expect(find.text('Drama city'), findsOneWidget); + final title = tester.widget( + find.byKey(const Key('favorite-title-signal')), + ); + expect(title.style?.color, AppColors.onSurface); + expect(title.style?.fontSize, 15); + expect( + tester.getSize(find.byKey(const Key('favorite-cover-signal'))), + const Size(80, 106), + ); expect(find.byKey(const Key('favorite-remove-signal')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('favorite-remove-signal'))), + const Size(28, 28), + ); + final refresh = tester.widget( + find.byType(RefreshIndicator), + ); + expect(refresh.color, AppColors.blue); + expect(refresh.backgroundColor, AppColors.card); + expect(refresh.strokeWidth, 2); }); testWidgets('should_keep_card_and_show_failure_when_remove_fails', ( diff --git a/test/watch/watch_records_controller_test.dart b/test/watch/watch_records_controller_test.dart index 2fd55b2..f6a795b 100644 --- a/test/watch/watch_records_controller_test.dart +++ b/test/watch/watch_records_controller_test.dart @@ -72,6 +72,43 @@ void main() { expect(controller.error, isNull); }); + test('should_reuse_cached_details_when_records_are_loaded_again', () async { + final history = ControlledWatchHistory([_progress('1'), _progress('2')]); + final catalog = ScriptedWatchCatalog(); + final controller = WatchRecordsController( + history: history, + catalog: catalog, + ); + + await controller.load(); + await controller.load(); + + expect(catalog.calls, ['1', '2']); + expect( + controller.records.every( + (record) => !record.detailLoading && record.drama != null, + ), + isTrue, + ); + }); + + test('should_keep_existing_records_when_refresh_fails', () async { + final history = ControlledWatchHistory([_progress('1')]); + final controller = WatchRecordsController( + history: history, + catalog: ScriptedWatchCatalog(), + ); + await controller.load(); + + history.failRecent = true; + await controller.load(); + + expect(controller.loading, isFalse); + expect(controller.error, 'progressError'); + expect(controller.records.single.drama?.id, '1'); + expect(controller.records.single.detailLoading, isFalse); + }); + test('should_keep_other_records_when_one_detail_request_fails', () async { final history = ControlledWatchHistory([_progress('1'), _progress('2')]); final catalog = ScriptedWatchCatalog(failedIds: {'1'}); diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index d8c99e3..22eb59a 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -12,6 +12,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.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/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/empty_state.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/watch_records/watch_records_page.dart'; @@ -47,11 +48,46 @@ void main() { await _pumpPage(tester, auth: auth, history: history, catalog: catalog); expect(find.byKey(const Key('watch-records-page')), findsOneWidget); + expect(find.byKey(const Key('watch-records-back')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('watch-records-count'))).data, + '1 records', + ); expect(find.text('The Signal'), findsOneWidget); + final title = tester.widget( + find.byKey(const Key('watch-record-title-the-signal-demo')), + ); + expect(title.style?.color, AppColors.onSurface); + expect(title.style?.fontSize, 15); + expect( + tester.getSize( + find.byKey(const Key('watch-record-cover-the-signal-demo')), + ), + const Size(72, 96), + ); + expect( + tester.getSize( + find.byKey(const Key('watch-record-remove-the-signal-demo')), + ), + const Size(28, 28), + ); expect( find.byKey(const Key('watch-record-episode-the-signal-demo')), findsOneWidget, ); + final refresh = tester.widget( + find.byType(RefreshIndicator), + ); + expect(refresh.color, AppColors.blue); + expect(refresh.backgroundColor, AppColors.card); + expect(refresh.strokeWidth, 2); + final list = tester.widget( + find.descendant( + of: find.byType(RefreshIndicator), + matching: find.byType(ListView), + ), + ); + expect(list.physics, isA()); }); testWidgets( From f62324084efd6dfcdc7e6cbc1f6543cfa18320cb Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:34:31 +0800 Subject: [PATCH 090/124] =?UTF-8?q?fix(flutter):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=A7=82=E7=9C=8B=E5=8E=86=E5=8F=B2=E5=88=A0=E9=99=A4=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/watch_records/widgets/watch_record_card.dart | 6 +++--- test/watch/watch_records_page_test.dart | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart index fdedd38..5bb67cc 100644 --- a/lib/pages/watch_records/widgets/watch_record_card.dart +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -14,8 +14,8 @@ const double _cardPadding = 10; const double _coverWidth = 72; const double _coverHeight = 96; const double _coverRadius = 10; -const double _removeSize = 28; -const double _removeIconSize = 16; +const double _removeSize = 32; +const double _removeIconSize = 18; const double _progressHeight = 4; const double _progressRadius = 2; const double _statsFontSize = 12; @@ -190,7 +190,7 @@ class WatchRecordCard extends StatelessWidget { padding: EdgeInsets.zero, onPressed: onRemove, icon: const Icon( - Icons.close_rounded, + Icons.delete_outline_rounded, size: _removeIconSize, color: AppColors.muted, ), diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index 22eb59a..6818cd6 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -69,8 +69,10 @@ void main() { tester.getSize( find.byKey(const Key('watch-record-remove-the-signal-demo')), ), - const Size(28, 28), + const Size(32, 32), ); + expect(find.byIcon(Icons.delete_outline_rounded), findsOneWidget); + expect(find.byIcon(Icons.close_rounded), findsNothing); expect( find.byKey(const Key('watch-record-episode-the-signal-demo')), findsOneWidget, From 1d8582dfaa4d09344037ffe3991b01b23aaa60ed Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:36:56 +0800 Subject: [PATCH 091/124] =?UTF-8?q?fix(flutter):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=94=B6=E8=97=8F=E9=A1=B5=E6=98=9F=E6=A0=87=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 1 - lib/l10n/app_id.arb | 1 - lib/l10n/app_localizations.dart | 6 --- lib/l10n/app_localizations_en.dart | 3 -- lib/l10n/app_localizations_id.dart | 3 -- lib/l10n/app_localizations_zh.dart | 3 -- lib/l10n/app_zh.arb | 1 - lib/pages/favorites/favorites_page.dart | 50 ++++++++++--------------- test/library/favorites_page_test.dart | 9 ++++- 9 files changed, 28 insertions(+), 49 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a926e71..b07b940 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -512,7 +512,6 @@ "count": {"type": "int"} } }, - "favoriteLongPressHint": "Long press a card to remove", "clearWatchHistoryTitle": "Clear watch history?", "clearWatchHistoryMessage": "This removes the watch history from your account.", "confirmClear": "Clear", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d79a440..42a50b1 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -476,7 +476,6 @@ "clearAll": "Hapus semua", "watchRecordsCount": "{count} riwayat", "favoritesCount": "{count} favorit", - "favoriteLongPressHint": "Tekan lama kartu untuk menghapus", "clearWatchHistoryTitle": "Hapus riwayat tontonan?", "clearWatchHistoryMessage": "Semua riwayat akan dihapus dari akun kamu.", "confirmClear": "Hapus", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bda0476..d217e85 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2086,12 +2086,6 @@ abstract class AppLocalizations { /// **'{count} favorites'** String favoritesCount(int count); - /// No description provided for @favoriteLongPressHint. - /// - /// In en, this message translates to: - /// **'Long press a card to remove'** - String get favoriteLongPressHint; - /// No description provided for @clearWatchHistoryTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4e072fd..97f5a2a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1078,9 +1078,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$count favorites'; } - @override - String get favoriteLongPressHint => 'Long press a card to remove'; - @override String get clearWatchHistoryTitle => 'Clear watch history?'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 86b40bb..2991e04 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1078,9 +1078,6 @@ class AppLocalizationsId extends AppLocalizations { return '$count favorit'; } - @override - String get favoriteLongPressHint => 'Tekan lama kartu untuk menghapus'; - @override String get clearWatchHistoryTitle => 'Hapus riwayat tontonan?'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 334e3d2..ec1af40 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1059,9 +1059,6 @@ class AppLocalizationsZh extends AppLocalizations { return '共 $count 部'; } - @override - String get favoriteLongPressHint => '长按卡片可取消收藏'; - @override String get clearWatchHistoryTitle => '清空观看记录?'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b22074d..92f688c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -438,7 +438,6 @@ "clearAll": "清空", "watchRecordsCount": "共 {count} 条", "favoritesCount": "共 {count} 部", - "favoriteLongPressHint": "长按卡片可取消收藏", "clearWatchHistoryTitle": "清空观看记录?", "clearWatchHistoryMessage": "这会从当前账号移除全部观看记录。", "confirmClear": "清空", diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 8392e75..b5be3b2 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -28,7 +28,7 @@ const double _coverWidth = 80; const double _coverHeight = 106; const double _coverRadius = 10; const double _removeSize = 28; -const double _removeIconSize = 15; +const double _removeIconSize = 20; const double _statsFontSize = 12; const double _metaFontSize = 11; const double _titleFontSize = 15; @@ -134,7 +134,7 @@ class _FavoritesBody extends StatelessWidget { height: _emptyListHeight, child: EmptyState( key: const Key('favorites-empty'), - icon: Icons.favorite_border_rounded, + icon: Icons.star_border_rounded, title: l10n.noFavorites, subtitle: l10n.emptyHint, ), @@ -150,6 +150,7 @@ class _FavoritesBody extends StatelessWidget { if (index > 0) const SizedBox(height: 10), _FavoriteCard( drama: drama, + isFavorite: favorites.isSaved(drama.id), onOpen: () => _open(context, drama), onRemove: () => _remove(context, drama.id), ), @@ -181,30 +182,16 @@ class _FavoritesBody extends StatelessWidget { } Widget _summary(AppLocalizations l10n) { - return Row( - children: [ - Text( - l10n.favoritesCount(favorites.cards.length), - key: const Key('favorites-count'), - style: const TextStyle( - color: AppColors.muted, - fontSize: _statsFontSize, - ), + return Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.favoritesCount(favorites.cards.length), + key: const Key('favorites-count'), + style: const TextStyle( + color: AppColors.muted, + fontSize: _statsFontSize, ), - const Spacer(), - Flexible( - child: Text( - l10n.favoriteLongPressHint, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - style: const TextStyle( - color: AppColors.subtle, - fontSize: _metaFontSize, - ), - ), - ), - ], + ), ); } @@ -228,11 +215,13 @@ class _FavoritesBody extends StatelessWidget { class _FavoriteCard extends StatelessWidget { const _FavoriteCard({ required this.drama, + required this.isFavorite, required this.onOpen, required this.onRemove, }); final Drama drama; + final bool isFavorite; final VoidCallback onOpen; final VoidCallback onRemove; @@ -253,7 +242,6 @@ class _FavoriteCard extends StatelessWidget { clipBehavior: Clip.antiAlias, child: InkWell( onTap: onOpen, - onLongPress: onRemove, child: Padding( padding: const EdgeInsets.all(_cardPadding), child: Stack( @@ -373,7 +361,7 @@ class _FavoriteCard extends StatelessWidget { child: SizedBox.square( dimension: _removeSize, child: Material( - color: AppColors.badgePink, + color: AppColors.raised, shape: const CircleBorder(), clipBehavior: Clip.antiAlias, child: IconButton( @@ -381,9 +369,11 @@ class _FavoriteCard extends StatelessWidget { tooltip: l10n.remove, padding: EdgeInsets.zero, onPressed: onRemove, - icon: const Icon( - Icons.favorite_rounded, - color: AppColors.favorite, + icon: Icon( + isFavorite + ? Icons.star_rounded + : Icons.star_border_rounded, + color: isFavorite ? AppColors.blue : AppColors.muted, size: _removeIconSize, ), ), diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 677cc33..6af2784 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -54,7 +54,7 @@ void main() { tester.widget(find.byKey(const Key('favorites-count'))).data, '2 favorites', ); - expect(find.text('Long press a card to remove'), findsOneWidget); + expect(find.text('Long press a card to remove'), findsNothing); expect(find.text('Drama signal'), findsOneWidget); expect(find.text('Drama city'), findsOneWidget); final title = tester.widget( @@ -71,12 +71,19 @@ void main() { tester.getSize(find.byKey(const Key('favorite-remove-signal'))), const Size(28, 28), ); + expect(find.byIcon(Icons.star_rounded), findsNWidgets(2)); + expect(find.byIcon(Icons.favorite_rounded), findsNothing); final refresh = tester.widget( find.byType(RefreshIndicator), ); expect(refresh.color, AppColors.blue); expect(refresh.backgroundColor, AppColors.card); expect(refresh.strokeWidth, 2); + + await tester.tap(find.byKey(const Key('favorite-remove-signal'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('favorite-card-signal')), findsNothing); }); testWidgets('should_keep_card_and_show_failure_when_remove_fails', ( From 0d2680e6c170eaaa5baf42eebfecda357af38ae0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:39:48 +0800 Subject: [PATCH 092/124] =?UTF-8?q?fix(flutter):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=86=B7=E5=90=AF=E5=8A=A8=E7=81=B0=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/remote_image.dart | 4 +++- lib/pages/player/player_view.dart | 8 +++++++- test/home/k28_a_navigation_test.dart | 1 + test/player/player_fullscreen_test.dart | 9 ++++++++- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index fd3a490..60d10be 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -16,6 +16,7 @@ class RemoteImage extends StatelessWidget { this.showFallbackIcon = false, this.fallbackIconKey, this.fallbackAspectRatio, + this.fallbackColor = AppColors.bar, }); final String path; @@ -25,6 +26,7 @@ class RemoteImage extends StatelessWidget { final bool showFallbackIcon; final Key? fallbackIconKey; final double? fallbackAspectRatio; + final Color fallbackColor; @override Widget build(BuildContext context) { @@ -47,7 +49,7 @@ class RemoteImage extends StatelessWidget { final fallback = Container( width: width, height: height, - color: AppColors.bar, + color: fallbackColor, alignment: Alignment.center, child: showFallbackIcon ? Icon( diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index b03546d..186c370 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -300,6 +300,11 @@ class _PlayerPageState extends State with WidgetsBindingObserver { return Stack( fit: StackFit.expand, children: [ + if (widget.homeMode) + const ColoredBox( + key: Key('home-feed-black-background'), + color: Colors.black, + ), if (!ready) _PlayerFallbackBackground(posterUrl: drama.posterUrl), ?video, if (widget.homeMode && _chromeVisible && !logic.chromeHidden) @@ -580,10 +585,11 @@ class _PlayerFallbackBackground extends StatelessWidget { posterUrl, key: const Key('player-fallback-cover'), fit: BoxFit.cover, + fallbackColor: Colors.black, ), const ColoredBox( key: Key('player-fallback-scrim'), - color: Color(0x66000000), + color: Color(0x4D000000), ), ], ); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 89324f9..31a6fe0 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -59,6 +59,7 @@ void main() { expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-black-background')), findsOneWidget); expect(find.byType(PageView), findsOneWidget); expect(find.byKey(const Key('home-browse-page')), findsNothing); expect( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 2e17534..c01d548 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -13,6 +13,7 @@ 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/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/remote_image.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; @@ -333,11 +334,17 @@ void main() { final fallback = find.byKey(const Key('player-fallback-background')); expect(fallback, findsOneWidget); expect(find.byKey(const Key('player-fallback-cover')), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('player-fallback-cover'))) + .fallbackColor, + Colors.black, + ); expect( tester .widget(find.byKey(const Key('player-fallback-scrim'))) .color, - const Color(0x66000000), + const Color(0x4D000000), ); expect( tester From b2a8bfa59fc96536c3b66702fd723c36bdc1a33d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:42:27 +0800 Subject: [PATCH 093/124] =?UTF-8?q?fix(flutter):=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E8=B5=84=E6=96=99=E5=B9=B6=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_localizations_zh.dart | 2 +- lib/l10n/app_zh.arb | 2 +- lib/pages/account/account_profile_page.dart | 47 +-------------------- test/account/account_pages_test.dart | 4 ++ 4 files changed, 7 insertions(+), 48 deletions(-) diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ec1af40..6097883 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -429,7 +429,7 @@ class AppLocalizationsZh extends AppLocalizations { String get like => '喜欢'; @override - String get save => '收藏'; + String get save => '保存'; @override String get comments => '评论'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 92f688c..8da3fa4 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -171,7 +171,7 @@ "mute": "静音", "unmute": "取消静音", "like": "喜欢", - "save": "收藏", + "save": "保存", "comments": "评论", "share": "分享", "noComments": "还没有评论。", diff --git a/lib/pages/account/account_profile_page.dart b/lib/pages/account/account_profile_page.dart index 1b79c5e..46ebb99 100644 --- a/lib/pages/account/account_profile_page.dart +++ b/lib/pages/account/account_profile_page.dart @@ -23,7 +23,6 @@ import 'account_profile_logic.dart'; const _avatarSize = 64.0; const _fieldGap = 14.0; -const _datePadWidth = 2; /// 个人资料编辑页。 class AccountProfilePage extends StatefulWidget { @@ -37,7 +36,6 @@ class _AccountProfilePageState extends State { late final AccountProfileLogic _logic; final _nickname = TextEditingController(); var _gender = 'unknown'; - String? _birthday; bool _formReady = false; @override @@ -163,8 +161,6 @@ class _AccountProfilePageState extends State { _nicknameField(l10n), const SizedBox(height: _fieldGap), _genderField(l10n), - const SizedBox(height: _fieldGap), - _birthdayField(l10n), if (_logic.error != null) _saveError(l10n), const SizedBox(height: 16), _saveButton(l10n), @@ -200,26 +196,6 @@ class _AccountProfilePageState extends State { }, ); - Widget _birthdayField(AppLocalizations l10n) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - OutlinedButton.icon( - key: const Key('account-birthday'), - onPressed: _pickBirthday, - icon: const Icon(Icons.cake_outlined), - label: Text(_birthday ?? l10n.accountSelectBirthday), - ), - const SizedBox(height: 6), - Text( - l10n.accountBirthdayHint, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - ), - ), - ], - ); - Widget _saveError(AppLocalizations l10n) => Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -307,24 +283,8 @@ class _AccountProfilePageState extends State { ), ); - Future _pickBirthday() async { - final value = await showDatePicker( - context: context, - initialDate: DateTime(2000), - firstDate: DateTime(1900), - lastDate: DateTime.now(), - ); - if (value != null && mounted) { - setState(() => _birthday = _formatDate(value)); - } - } - Future _save() async { - final ok = await _logic.save( - nickname: _nickname.text, - gender: _gender, - birthday: _birthday, - ); + final ok = await _logic.save(nickname: _nickname.text, gender: _gender); if (!ok || !mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.accountSaved)), @@ -335,11 +295,6 @@ class _AccountProfilePageState extends State { const {'male', 'female', 'unknown'}.contains(value) ? value : 'unknown'; } -String _formatDate(DateTime value) => - '${value.year.toString().padLeft(4, '0')}-' - '${value.month.toString().padLeft(_datePadWidth, '0')}-' - '${value.day.toString().padLeft(_datePadWidth, '0')}'; - class _AccountCard extends StatelessWidget { const _AccountCard({required this.child}); diff --git a/test/account/account_pages_test.dart b/test/account/account_pages_test.dart index 90cc7d0..c73f6fc 100644 --- a/test/account/account_pages_test.dart +++ b/test/account/account_pages_test.dart @@ -28,15 +28,19 @@ void main() { AccountProfilePage(), repository: repository, auth: auth, + locale: const Locale('zh'), ); final nickname = find.byKey(const Key('account-nickname')); + expect(find.byKey(const Key('account-birthday')), findsNothing); + expect(find.text('保存'), findsOneWidget); expect(tester.widget(nickname).controller?.text, 'Nadia'); await tester.enterText(nickname, 'Nadia Baru'); await tester.tap(find.byKey(const Key('account-profile-save'))); await tester.pumpAndSettle(); expect(repository.lastUpdate?.nickname, 'Nadia Baru'); + expect(repository.lastUpdate?.birthday, isNull); }); testWidgets('should_show_device_empty_state_when_list_is_empty', ( From 5bdcb989bba840f69307e64b412f7ff0b0ecf50d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:47:13 +0800 Subject: [PATCH 094/124] =?UTF-8?q?feat(flutter):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=96=87=E5=AD=97=E4=B8=8B=E6=8B=89=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/app_pull_to_refresh.dart | 163 ++++++++++++++++++ lib/l10n/app_en.arb | 3 + lib/l10n/app_id.arb | 3 + lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_en.dart | 9 + lib/l10n/app_localizations_id.dart | 9 + lib/l10n/app_localizations_zh.dart | 9 + lib/l10n/app_zh.arb | 3 + lib/pages/discover/discover_view.dart | 6 +- lib/pages/downloads/downloads_page.dart | 4 +- lib/pages/favorites/favorites_page.dart | 6 +- lib/pages/home/home_browse_view.dart | 5 +- lib/pages/notices/notices_page.dart | 7 +- lib/pages/wallet/wallet_page.dart | 4 +- .../watch_records/watch_records_page.dart | 6 +- test/core/app_pull_to_refresh_test.dart | 68 ++++++++ test/discover/home_all_dramas_grid_test.dart | 13 +- test/library/favorites_page_test.dart | 12 +- test/watch/watch_records_page_test.dart | 10 +- 19 files changed, 320 insertions(+), 38 deletions(-) create mode 100644 lib/core/widgets/app_pull_to_refresh.dart create mode 100644 test/core/app_pull_to_refresh_test.dart diff --git a/lib/core/widgets/app_pull_to_refresh.dart b/lib/core/widgets/app_pull_to_refresh.dart new file mode 100644 index 0000000..3cf3d06 --- /dev/null +++ b/lib/core/widgets/app_pull_to_refresh.dart @@ -0,0 +1,163 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../theme/app_colors.dart'; + +const double _maxPullDistance = 96; +const double _refreshThreshold = 56; +const Duration _arrowTurnDuration = Duration(milliseconds: 160); +const Duration _retractDuration = Duration(milliseconds: 200); + +enum _PullState { idle, pull, armed, refreshing } + +/// 带文字状态提示的下拉刷新容器,指示区悬浮在内容上方。 +class AppPullToRefresh extends StatefulWidget { + const AppPullToRefresh({ + super.key, + required this.child, + required this.onRefresh, + this.edgeOffset = 0, + }); + + final Widget child; + final Future Function() onRefresh; + final double edgeOffset; + + @override + State createState() => _AppPullToRefreshState(); +} + +class _AppPullToRefreshState extends State { + _PullState _state = _PullState.idle; + double _pullDistance = 0; + bool _refreshing = false; + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0 || _refreshing) return false; + if (notification is ScrollStartNotification) { + if (notification.dragDetails != null) _setPullDistance(0); + } else if (notification is OverscrollNotification) { + if (notification.dragDetails != null) { + _setPullDistance(_pullDistance - notification.overscroll); + } + } else if (notification is ScrollEndNotification) { + _release(); + } + return false; + } + + void _setPullDistance(double value) { + final distance = value.clamp(0.0, _maxPullDistance); + final state = distance >= _refreshThreshold + ? _PullState.armed + : distance > 0 + ? _PullState.pull + : _PullState.idle; + if (distance == _pullDistance && state == _state) return; + setState(() { + _pullDistance = distance; + _state = state; + }); + } + + void _release() { + if (_state == _PullState.armed) { + unawaited(_refresh()); + } else if (_state == _PullState.pull) { + setState(() { + _pullDistance = 0; + _state = _PullState.idle; + }); + } + } + + Future _refresh() async { + if (_refreshing) return; + _refreshing = true; + setState(() => _state = _PullState.refreshing); + try { + await widget.onRefresh(); + } finally { + await Future.delayed(_retractDuration); + if (mounted) { + setState(() { + _pullDistance = 0; + _state = _PullState.idle; + _refreshing = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: _onScroll, + child: Stack( + fit: StackFit.expand, + children: [ + widget.child, + if (_state != _PullState.idle) + Positioned( + top: widget.edgeOffset, + left: 0, + right: 0, + child: IgnorePointer(child: _indicator(context)), + ), + ], + ), + ); + } + + Widget _indicator(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (_state == _PullState.refreshing) { + return _label( + const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.blue, + ), + ), + l10n.refreshingLabel, + ); + } + final armed = _state == _PullState.armed; + return _label( + AnimatedRotation( + turns: armed ? 0.5 : 0, + duration: _arrowTurnDuration, + child: Icon( + Icons.arrow_downward_rounded, + size: 18, + color: armed ? AppColors.blue : AppColors.muted, + ), + ), + armed ? l10n.releaseToRefresh : l10n.pullToRefresh, + emphasized: armed, + ); + } + + Widget _label(Widget icon, String text, {bool emphasized = false}) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + icon, + const SizedBox(width: 6), + Text( + text, + style: TextStyle( + color: emphasized ? AppColors.blue : AppColors.muted, + fontSize: 12, + fontWeight: emphasized ? FontWeight.w700 : FontWeight.w500, + ), + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b07b940..43dca2a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -338,6 +338,9 @@ } }, "retry": "Retry", + "pullToRefresh": "Pull to refresh", + "releaseToRefresh": "Release to refresh", + "refreshingLabel": "Refreshing…", "backToSeries": "Back to series", "nextEpisode": "Next episode", "pause": "Pause", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 42a50b1..38c5f27 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -312,6 +312,9 @@ } }, "retry": "Coba lagi", + "pullToRefresh": "Tarik untuk menyegarkan", + "releaseToRefresh": "Lepaskan untuk menyegarkan", + "refreshingLabel": "Menyegarkan…", "backToSeries": "Kembali ke serial", "nextEpisode": "Episode berikutnya", "pause": "Jeda", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d217e85..1247720 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1336,6 +1336,24 @@ abstract class AppLocalizations { /// **'Retry'** String get retry; + /// No description provided for @pullToRefresh. + /// + /// In en, this message translates to: + /// **'Pull to refresh'** + String get pullToRefresh; + + /// No description provided for @releaseToRefresh. + /// + /// In en, this message translates to: + /// **'Release to refresh'** + String get releaseToRefresh; + + /// No description provided for @refreshingLabel. + /// + /// In en, this message translates to: + /// **'Refreshing…'** + String get refreshingLabel; + /// No description provided for @backToSeries. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 97f5a2a..af56d36 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -676,6 +676,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get retry => 'Retry'; + @override + String get pullToRefresh => 'Pull to refresh'; + + @override + String get releaseToRefresh => 'Release to refresh'; + + @override + String get refreshingLabel => 'Refreshing…'; + @override String get backToSeries => 'Back to series'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 2991e04..a8627ff 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -679,6 +679,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get retry => 'Coba lagi'; + @override + String get pullToRefresh => 'Tarik untuk menyegarkan'; + + @override + String get releaseToRefresh => 'Lepaskan untuk menyegarkan'; + + @override + String get refreshingLabel => 'Menyegarkan…'; + @override String get backToSeries => 'Kembali ke serial'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6097883..923626c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -668,6 +668,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get retry => '重试'; + @override + String get pullToRefresh => '下拉刷新'; + + @override + String get releaseToRefresh => '松手刷新'; + + @override + String get refreshingLabel => '正在刷新…'; + @override String get backToSeries => '返回剧集详情'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8da3fa4..efd2b33 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -274,6 +274,9 @@ } }, "retry": "重试", + "pullToRefresh": "下拉刷新", + "releaseToRefresh": "松手刷新", + "refreshingLabel": "正在刷新…", "backToSeries": "返回剧集详情", "nextEpisode": "下一集", "pause": "暂停", diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index cb68f9d..606622a 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -14,6 +14,7 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../filter/filter_page.dart'; @@ -152,11 +153,8 @@ class _DiscoverPageState extends State { child: Stack( children: [ Positioned.fill( - child: RefreshIndicator( + child: AppPullToRefresh( key: const Key('discover-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, - strokeWidth: 2, edgeOffset: _stickyHeaderHeight + 8, onRefresh: categories.load, child: ListView( diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index 84e839a..f3e6997 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -16,6 +16,7 @@ import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; @@ -90,9 +91,10 @@ class _DownloadsBody extends StatelessWidget { ); } final policy = downloads.policy!; - return RefreshIndicator( + return AppPullToRefresh( onRefresh: downloads.load, child: ListView( + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 10, 16, 28), children: [ _PolicyPanel(downloads: downloads, policy: policy), diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index b5be3b2..ee45a08 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -13,6 +13,7 @@ import '../../core/formatting/error_text.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/remote_image.dart'; @@ -163,10 +164,7 @@ class _FavoritesBody extends StatelessWidget { FavoriteController favorites, { required List children, }) { - return RefreshIndicator( - color: AppColors.blue, - backgroundColor: AppColors.card, - strokeWidth: 2, + return AppPullToRefresh( onRefresh: favorites.load, child: ListView( physics: const AlwaysScrollableScrollPhysics(), diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index b6ffc64..6924045 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -12,6 +12,7 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/widgets/feed_banner_carousel.dart'; @@ -137,10 +138,8 @@ class _HomeBrowseViewState extends State { return Stack( children: [ Positioned.fill( - child: RefreshIndicator( + child: AppPullToRefresh( key: const Key('home-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, onRefresh: () async { await Future.wait([catalog.load(), logic.refreshRecent()]); }, diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index 605c1a4..4c42abf 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -13,6 +13,7 @@ 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/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; @@ -65,7 +66,7 @@ class _NoticesPageState extends State { ); } if (_controller.error != null && _controller.notices.isEmpty) { - return RefreshIndicator( + return AppPullToRefresh( onRefresh: _controller.refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -90,7 +91,7 @@ class _NoticesPageState extends State { ); } if (_controller.notices.isEmpty) { - return RefreshIndicator( + return AppPullToRefresh( onRefresh: _controller.refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -108,7 +109,7 @@ class _NoticesPageState extends State { ), ); } - return RefreshIndicator( + return AppPullToRefresh( onRefresh: _controller.refresh, child: ListView.separated( key: const Key('notices-list'), diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart index 6f80f6e..651244f 100644 --- a/lib/pages/wallet/wallet_page.dart +++ b/lib/pages/wallet/wallet_page.dart @@ -12,6 +12,7 @@ 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/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../store/recharge_page.dart'; @@ -116,9 +117,10 @@ class _WalletPageState extends State { onAction: () => unawaited(wallet.load()), ); } - return RefreshIndicator( + return AppPullToRefresh( onRefresh: wallet.load, child: ListView( + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 8, 16, 28), children: [ _BalanceHero(wallet: snapshot.wallet), diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index b1987f1..00fd4a2 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -14,6 +14,7 @@ import '../../core/formatting/error_text.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/subpage_header.dart'; @@ -145,10 +146,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { } Widget _refreshable({required List children}) { - return RefreshIndicator( - color: AppColors.blue, - backgroundColor: AppColors.card, - strokeWidth: 2, + return AppPullToRefresh( onRefresh: controller.load, child: ListView( physics: const AlwaysScrollableScrollPhysics(), diff --git a/test/core/app_pull_to_refresh_test.dart b/test/core/app_pull_to_refresh_test.dart new file mode 100644 index 0000000..b86aa7e --- /dev/null +++ b/test/core/app_pull_to_refresh_test.dart @@ -0,0 +1,68 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; + +void main() { + testWidgets( + 'should_show_pull_release_and_refreshing_states_and_refresh_once', + (tester) async { + final refresh = Completer(); + var calls = 0; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: AppPullToRefresh( + onRefresh: () { + calls++; + return refresh.future; + }, + child: ListView( + key: const Key('pull-refresh-list'), + physics: const AlwaysScrollableScrollPhysics(), + children: const [SizedBox(height: 1200)], + ), + ), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('pull-refresh-list'))), + ); + await gesture.moveBy(const Offset(0, 30)); + await tester.pump(); + expect(find.text('Pull to refresh'), findsOneWidget); + + await gesture.moveBy(const Offset(0, 40)); + await tester.pump(); + expect(find.text('Release to refresh'), findsOneWidget); + + await gesture.up(); + await tester.pump(); + expect(find.text('Refreshing…'), findsOneWidget); + expect(calls, 1); + + final duplicate = await tester.startGesture( + tester.getCenter(find.byKey(const Key('pull-refresh-list'))), + ); + await duplicate.moveBy(const Offset(0, 70)); + await duplicate.up(); + await tester.pump(); + expect(calls, 1); + + refresh.complete(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.text('Refreshing…'), findsNothing); + expect(find.text('Pull to refresh'), findsNothing); + }, + ); +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 77c34fd..d90e923 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -11,6 +11,7 @@ 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/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/app_pull_to_refresh.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'; @@ -292,15 +293,17 @@ void main() { expect(underline('7'), AppColors.blue); }); - testWidgets('should_use_compact_refresh_indicator', (tester) async { + testWidgets('should_use_text_pull_to_refresh', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - final indicator = tester.widget( + final refresh = tester.widget( find.byKey(const Key('discover-refresh')), ); - expect(indicator.color, AppColors.blue); - expect(indicator.strokeWidth, 2); - expect(indicator.edgeOffset, 52); + expect(refresh.edgeOffset, 52); + await tester.drag(_verticalFeed, const Offset(0, 120)); + await tester.pump(); + expect(find.text('Refreshing…'), findsOneWidget); + await tester.pumpAndSettle(); }); } diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 6af2784..72a951c 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -11,6 +11,7 @@ import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/core/widgets/empty_state.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/favorites/favorites_page.dart'; @@ -73,12 +74,11 @@ void main() { ); expect(find.byIcon(Icons.star_rounded), findsNWidgets(2)); expect(find.byIcon(Icons.favorite_rounded), findsNothing); - final refresh = tester.widget( - find.byType(RefreshIndicator), - ); - expect(refresh.color, AppColors.blue); - expect(refresh.backgroundColor, AppColors.card); - expect(refresh.strokeWidth, 2); + expect(find.byType(AppPullToRefresh), findsOneWidget); + await tester.drag(find.byType(ListView), const Offset(0, 120)); + await tester.pump(); + expect(find.text('Refreshing…'), findsOneWidget); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('favorite-remove-signal'))); await tester.pumpAndSettle(); diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index 6818cd6..e88cef1 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -13,6 +13,7 @@ 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/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/core/widgets/empty_state.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/watch_records/watch_records_page.dart'; @@ -77,15 +78,10 @@ void main() { find.byKey(const Key('watch-record-episode-the-signal-demo')), findsOneWidget, ); - final refresh = tester.widget( - find.byType(RefreshIndicator), - ); - expect(refresh.color, AppColors.blue); - expect(refresh.backgroundColor, AppColors.card); - expect(refresh.strokeWidth, 2); + expect(find.byType(AppPullToRefresh), findsOneWidget); final list = tester.widget( find.descendant( - of: find.byType(RefreshIndicator), + of: find.byType(AppPullToRefresh), matching: find.byType(ListView), ), ); From c4dcb714ab95185caacb55547c293216a2626ab3 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:53:07 +0800 Subject: [PATCH 095/124] =?UTF-8?q?feat(flutter):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=A6=9C=E5=8D=95=E6=96=B0=E5=89=A7=E5=88=86=E9=A1=B5=E4=B8=8E?= =?UTF-8?q?=E9=A2=84=E7=BA=A6=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/paged_local_list.dart | 116 ++++++++++ lib/l10n/app_en.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_id.dart | 6 + lib/l10n/app_localizations_zh.dart | 6 + lib/l10n/app_zh.arb | 2 + lib/pages/new_releases/new_releases_page.dart | 214 ++++++++++++++---- lib/pages/ranking/ranking_page.dart | 148 +++++++++--- lib/pages/reserve/reserve_page.dart | 55 +++-- test/discover/theater_pages_test.dart | 138 ++++++++++- 12 files changed, 611 insertions(+), 96 deletions(-) create mode 100644 lib/core/widgets/paged_local_list.dart diff --git a/lib/core/widgets/paged_local_list.dart b/lib/core/widgets/paged_local_list.dart new file mode 100644 index 0000000..ce5a80d --- /dev/null +++ b/lib/core/widgets/paged_local_list.dart @@ -0,0 +1,116 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + +const int _defaultPageSize = 20; +const Duration _defaultLoadDelay = Duration(milliseconds: 300); + +/// 客户端本地列表分页状态。 +class PagedLocalListController extends ChangeNotifier { + PagedLocalListController({ + List items = const [], + this.pageSize = _defaultPageSize, + this.minimumLoadDelay = _defaultLoadDelay, + }) : _items = List.unmodifiable(items); + + final int pageSize; + final Duration minimumLoadDelay; + + List _items; + int _pageCount = 1; + int _generation = 0; + bool _loading = false; + bool _disposed = false; + + List get items => _items; + bool get loading => _loading; + bool get hasMore => visibleItems.length < _items.length; + + List get visibleItems => + _items.take(_pageCount * pageSize).toList(growable: false); + + /// 用最新目录替换本地数据,并回到第一页。 + void replaceAll(List items) { + _generation++; + _items = List.unmodifiable(items); + _pageCount = 1; + _loading = false; + _notify(); + } + + /// 追加下一页;已加载完或正在加载时不重复触发。 + Future loadMore() async { + if (_loading || !hasMore) return; + final generation = _generation; + _loading = true; + _notify(); + await Future.delayed(minimumLoadDelay); + if (_disposed || generation != _generation) return; + _pageCount++; + _loading = false; + _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +/// 分页列表底部加载态与到底提示。 +class PagedLoadFooter extends StatelessWidget { + const PagedLoadFooter({ + super.key, + required this.loading, + required this.hasMore, + required this.hasItems, + required this.loadMoreKey, + required this.noMoreKey, + required this.noMoreLabel, + }); + + final bool loading; + final bool hasMore; + final bool hasItems; + final Key loadMoreKey; + final Key noMoreKey; + final String noMoreLabel; + + @override + Widget build(BuildContext context) { + if (loading) { + return SizedBox( + key: loadMoreKey, + height: 56, + child: const Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.blue, + ), + ), + ), + ); + } + if (hasItems && !hasMore) { + return Padding( + key: noMoreKey, + padding: const EdgeInsets.symmetric(vertical: 14), + child: Text( + noMoreLabel, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ); + } + return const SizedBox(height: 16); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 43dca2a..6affe0c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -341,6 +341,7 @@ "pullToRefresh": "Pull to refresh", "releaseToRefresh": "Release to refresh", "refreshingLabel": "Refreshing…", + "noMore": "No more", "backToSeries": "Back to series", "nextEpisode": "Next episode", "pause": "Pause", @@ -633,6 +634,7 @@ "rankingSubtitle": "Ranked from today's live catalog feed.", "rankingHot": "Trending", "rankingNew": "New releases", + "newReleasesAll": "All new releases", "newReleasesSubtitle": "Newest arrivals", "reserveSubtitle": "Reserve before launch", "reserveEmptyTitle": "No reservations yet", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 38c5f27..68f8893 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -315,6 +315,7 @@ "pullToRefresh": "Tarik untuk menyegarkan", "releaseToRefresh": "Lepaskan untuk menyegarkan", "refreshingLabel": "Menyegarkan…", + "noMore": "Tidak ada lagi", "backToSeries": "Kembali ke serial", "nextEpisode": "Episode berikutnya", "pause": "Jeda", @@ -595,6 +596,7 @@ "rankingSubtitle": "Diurutkan dari feed katalog terkini.", "rankingHot": "Populer", "rankingNew": "Rilis baru", + "newReleasesAll": "Semua rilis baru", "newReleasesSubtitle": "Rilis terbaru", "reserveSubtitle": "Reservasi sebelum rilis", "reserveEmptyTitle": "Belum ada reservasi", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1247720..650b5bb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1354,6 +1354,12 @@ abstract class AppLocalizations { /// **'Refreshing…'** String get refreshingLabel; + /// No description provided for @noMore. + /// + /// In en, this message translates to: + /// **'No more'** + String get noMore; + /// No description provided for @backToSeries. /// /// In en, this message translates to: @@ -2704,6 +2710,12 @@ abstract class AppLocalizations { /// **'New releases'** String get rankingNew; + /// No description provided for @newReleasesAll. + /// + /// In en, this message translates to: + /// **'All new releases'** + String get newReleasesAll; + /// No description provided for @newReleasesSubtitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index af56d36..6437937 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -685,6 +685,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get refreshingLabel => 'Refreshing…'; + @override + String get noMore => 'No more'; + @override String get backToSeries => 'Back to series'; @@ -1401,6 +1404,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rankingNew => 'New releases'; + @override + String get newReleasesAll => 'All new releases'; + @override String get newReleasesSubtitle => 'Newest arrivals'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a8627ff..5b3e823 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -688,6 +688,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get refreshingLabel => 'Menyegarkan…'; + @override + String get noMore => 'Tidak ada lagi'; + @override String get backToSeries => 'Kembali ke serial'; @@ -1401,6 +1404,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get rankingNew => 'Rilis baru'; + @override + String get newReleasesAll => 'Semua rilis baru'; + @override String get newReleasesSubtitle => 'Rilis terbaru'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 923626c..30037c8 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -677,6 +677,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get refreshingLabel => '正在刷新…'; + @override + String get noMore => '没有更多了'; + @override String get backToSeries => '返回剧集详情'; @@ -1379,6 +1382,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get rankingNew => '新剧'; + @override + String get newReleasesAll => '全部新剧'; + @override String get newReleasesSubtitle => '最新上架短剧'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index efd2b33..8ceaa1d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -277,6 +277,7 @@ "pullToRefresh": "下拉刷新", "releaseToRefresh": "松手刷新", "refreshingLabel": "正在刷新…", + "noMore": "没有更多了", "backToSeries": "返回剧集详情", "nextEpisode": "下一集", "pause": "暂停", @@ -557,6 +558,7 @@ "rankingSubtitle": "依据目录实时分区排序。", "rankingHot": "热播", "rankingNew": "新剧", + "newReleasesAll": "全部新剧", "newReleasesSubtitle": "最新上架短剧", "reserveSubtitle": "上线前预约,开播即通知", "reserveEmptyTitle": "暂无预约剧集", diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index a02d874..e499302 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -10,9 +10,12 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; +import '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/remote_image.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; +import '../discover/widgets/drama_grid.dart'; import '../player/open_player.dart'; const int _moreLimit = 12; @@ -21,18 +24,83 @@ const double _cardPadding = 8; const double _cardWidth = 124; const double _rowHeight = 204; const double _cardRadius = 12; +const double _loadMoreThreshold = 200; /// 新剧页:暖色首映厅,今日首映大卡 + 横向更多新剧。 -class NewReleasesPage extends StatelessWidget { +class NewReleasesPage extends StatefulWidget { const NewReleasesPage({super.key}); + @override + State createState() => _NewReleasesPageState(); +} + +class _NewReleasesPageState extends State { + final ScrollController _scroll = ScrollController(); + PagedLocalListController? _pager; + bool _refreshing = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_pager != null) return; + _pager = PagedLocalListController( + items: _newest(context.read().dramas), + )..addListener(_rebuild); + } + + @override + void dispose() { + _pager + ?..removeListener(_rebuild) + ..dispose(); + _scroll.dispose(); + super.dispose(); + } + + void _rebuild() { + if (mounted) setState(() {}); + } + + List _newest(List source) { + return [...source]..sort((a, b) => b.id.compareTo(a.id)); + } + + Future _refresh() async { + if (_refreshing) return; + _refreshing = true; + try { + final catalog = context.read(); + await catalog.refresh(); + if (!mounted) return; + _pager!.replaceAll(_newest(catalog.dramas)); + if (_scroll.hasClients) { + _scroll.jumpTo(_scroll.position.minScrollExtent); + } + } finally { + _refreshing = false; + } + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0 || _refreshing) return false; + final reachedBottom = + notification.metrics.extentAfter <= _loadMoreThreshold; + if (reachedBottom && + (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification)) { + unawaited(_pager!.loadMore()); + } + return false; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final dramas = [...context.read().dramas] - ..sort((a, b) => b.id.compareTo(a.id)); - final hero = dramas.isEmpty ? null : dramas.first; - final more = dramas.skip(1).take(_moreLimit).toList(); + final pager = _pager!; + final visible = pager.visibleItems; + final hero = visible.isEmpty ? null : visible.first; + final more = pager.items.skip(1).take(_moreLimit).toList(); return AnnotatedRegion( value: SystemUiOverlayStyle.dark, child: Scaffold( @@ -54,54 +122,93 @@ class NewReleasesPage extends StatelessWidget { title: l10n.newReleasesTitle, subtitle: l10n.newReleasesSubtitle, backKey: const Key('new-releases-back'), - actions: _countChip(l10n, dramas.length), + actions: _countChip(l10n, pager.items.length), ), Expanded( - child: hero == null - ? Center( - child: Text( - l10n.noStories, - key: const Key('new-releases-empty'), - style: const TextStyle(color: AppColors.muted), - ), - ) - : ListView( - padding: const EdgeInsets.only(bottom: 24), - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: _heroCard(context, l10n, hero), - ), - const SizedBox(height: 14), - const _FilmStrip(), - const SizedBox(height: 14), - if (more.isNotEmpty) ...[ - _moreHeader(l10n), - const SizedBox(height: 10), - SizedBox( - height: _rowHeight, - child: ListView.separated( - key: const Key('new-releases-row'), - scrollDirection: Axis.horizontal, + child: NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('new-releases-refresh'), + onRefresh: _refresh, + child: ListView( + key: const Key('new-releases-list'), + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 24), + children: hero == null + ? [ + SizedBox( + height: 420, + child: Center( + child: Text( + l10n.noStories, + key: const Key('new-releases-empty'), + style: const TextStyle( + color: AppColors.muted, + ), + ), + ), + ), + ] + : [ + Padding( padding: const EdgeInsets.symmetric( horizontal: 16, ), - itemCount: more.length, - separatorBuilder: (_, _) => - const SizedBox(width: 10), - itemBuilder: (context, index) => _moreCard( - context, - l10n, - more[index], - index, + child: _heroCard(context, l10n, hero), + ), + const SizedBox(height: 14), + const _FilmStrip(), + const SizedBox(height: 14), + if (more.isNotEmpty) ...[ + _moreHeader(l10n), + const SizedBox(height: 10), + SizedBox( + height: _rowHeight, + child: ListView.separated( + key: const Key('new-releases-row'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + itemCount: more.length, + separatorBuilder: (_, _) => + const SizedBox(width: 10), + itemBuilder: (context, index) => + _moreCard( + context, + l10n, + more[index], + index, + ), + ), + ), + ], + const SizedBox(height: 20), + _allHeader(l10n), + const SizedBox(height: 10), + DramaGrid( + key: const Key('new-releases-all-grid'), + l10n: l10n, + dramas: visible, + onOpen: (drama) => unawaited( + playDramaFromCatalog(context, drama), ), ), - ), - ], - ], - ), + PagedLoadFooter( + loading: pager.loading, + hasMore: pager.hasMore, + hasItems: pager.items.isNotEmpty, + loadMoreKey: const Key( + 'new-releases-load-more', + ), + noMoreKey: const Key('new-releases-no-more'), + noMoreLabel: l10n.noMore, + ), + ], + ), + ), + ), ), ], ), @@ -267,6 +374,21 @@ class NewReleasesPage extends StatelessWidget { ); } + Widget _allHeader(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + l10n.newReleasesAll, + key: const Key('new-releases-all-header'), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ); + } + Widget _moreCard( BuildContext context, AppLocalizations l10n, diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 0e34da9..bb2b3bb 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -10,7 +10,9 @@ import '../../core/data/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; @@ -24,21 +26,87 @@ const double _rowGap = 12; const double _rankColumnWidth = 56; const double _listCoverWidth = 72; const double _listCoverHeight = 96; +const double _loadMoreThreshold = 200; const Color _goldStart = Color(0xFFF3B34C); const Color _goldEnd = Color(0xFFD9A23A); const Color _bronzeStart = Color(0xFFCD7F32); const Color _bronzeEnd = Color(0xFFA9713A); /// 剧场榜单页:前三名横排领奖台,第 4 名起纵向列表。 -class RankingPage extends StatelessWidget { +class RankingPage extends StatefulWidget { const RankingPage({super.key}); + @override + State createState() => _RankingPageState(); +} + +class _RankingPageState extends State { + final ScrollController _scroll = ScrollController(); + PagedLocalListController? _pager; + bool _refreshing = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_pager != null) return; + _pager = PagedLocalListController( + items: _ranked(context.read().dramas), + )..addListener(_rebuild); + } + + @override + void dispose() { + _pager + ?..removeListener(_rebuild) + ..dispose(); + _scroll.dispose(); + super.dispose(); + } + + void _rebuild() { + if (mounted) setState(() {}); + } + + List _ranked(List source) { + final dramas = [...source] + ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + return dramas.take(_topCount).toList(growable: false); + } + + Future _refresh() async { + if (_refreshing) return; + _refreshing = true; + try { + final catalog = context.read(); + await catalog.refresh(); + if (!mounted) return; + _pager!.replaceAll(_ranked(catalog.dramas)); + if (_scroll.hasClients) { + _scroll.jumpTo(_scroll.position.minScrollExtent); + } + } finally { + _refreshing = false; + } + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0 || _refreshing) return false; + final reachedBottom = + notification.metrics.extentAfter <= _loadMoreThreshold; + if (reachedBottom && + (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification)) { + unawaited(_pager!.loadMore()); + } + return false; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final dramas = [...context.read().dramas] - ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); - final top = dramas.take(_topCount).toList(); + final pager = _pager!; + final top = pager.visibleItems; final podium = top.take(3).toList(); final rest = top.skip(3).toList(); return PlainPageScaffold( @@ -46,32 +114,58 @@ class RankingPage extends StatelessWidget { backKey: const Key('ranking-back'), title: l10n.topCharts, subtitle: l10n.rankingSubtitle, - child: top.isEmpty - ? EmptyState(icon: Icons.leaderboard_outlined, title: l10n.noStories) - : ListView( - padding: const EdgeInsets.only(top: 4, bottom: 24), - children: [ - if (podium.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: _Podium(podium: podium, l10n: l10n), - ), - if (rest.isNotEmpty) ...[ - const SizedBox(height: 16), - for (var index = 0; index < rest.length; index++) ...[ - if (index > 0) const SizedBox(height: _rowGap), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: _RankingRow( - index: index + 3, - drama: rest[index], - l10n: l10n, + child: NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('ranking-refresh'), + onRefresh: _refresh, + child: ListView( + key: const Key('ranking-list'), + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 4, bottom: 24), + children: top.isEmpty + ? [ + SizedBox( + height: 420, + child: EmptyState( + icon: Icons.leaderboard_outlined, + title: l10n.noStories, ), ), + ] + : [ + if (podium.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _Podium(podium: podium, l10n: l10n), + ), + if (rest.isNotEmpty) ...[ + const SizedBox(height: 16), + for (var index = 0; index < rest.length; index++) ...[ + if (index > 0) const SizedBox(height: _rowGap), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _RankingRow( + index: index + 3, + drama: rest[index], + l10n: l10n, + ), + ), + ], + ], + PagedLoadFooter( + loading: pager.loading, + hasMore: pager.hasMore, + hasItems: pager.items.isNotEmpty, + loadMoreKey: const Key('ranking-load-more'), + noMoreKey: const Key('ranking-no-more'), + noMoreLabel: l10n.noMore, + ), ], - ], - ], - ), + ), + ), + ), ); } } diff --git a/lib/pages/reserve/reserve_page.dart b/lib/pages/reserve/reserve_page.dart index 26f5bd3..a4a3bc8 100644 --- a/lib/pages/reserve/reserve_page.dart +++ b/lib/pages/reserve/reserve_page.dart @@ -1,8 +1,11 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/data/services/catalog_controller.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../new_releases/new_releases_page.dart'; @@ -41,32 +44,42 @@ class _ReservePageState extends State { key: const Key('reserve-page'), backgroundColor: AppColors.ink, body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), - children: [ - AppSubpageHeader( - title: l10n.reserveTitle, - subtitle: l10n.reserveSubtitle, - backKey: const Key('reserve-back'), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _weekStrip(l10n), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: _ticketCard(context, l10n), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 0), - child: _steps(l10n), - ), - ], + child: AppPullToRefresh( + key: const Key('reserve-refresh'), + onRefresh: _refresh, + child: ListView( + key: const Key('reserve-list'), + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), + children: [ + AppSubpageHeader( + title: l10n.reserveTitle, + subtitle: l10n.reserveSubtitle, + backKey: const Key('reserve-back'), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _weekStrip(l10n), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: _ticketCard(context, l10n), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 0), + child: _steps(l10n), + ), + ], + ), ), ), ); } + Future _refresh() async { + await context.read().refresh(); + } + Widget _weekStrip(AppLocalizations l10n) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index bd12f50..7b6ae6e 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; @@ -172,14 +173,119 @@ void main() { expect(find.byKey(const Key('ranking-page')), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); }); + + testWidgets('should_page_ranking_and_reset_after_refresh', (tester) async { + final repository = _CountingCatalogRepository(dramas: _rankDramas(40)); + await _pumpTheater(tester, catalogRepository: repository); + await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); + await tester.pumpAndSettle(); + + final list = find.byKey(const Key('ranking-list')); + await tester.drag(list, const Offset(0, -8000)); + await tester.pump(); + + expect(find.byKey(const Key('ranking-row-19')), findsOneWidget); + expect(find.byKey(const Key('ranking-row-20')), findsNothing); + expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); + await tester.pump(const Duration(milliseconds: 299)); + expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1)); + + await tester.drag(list, const Offset(0, -8000)); + await tester.pump(); + expect(find.byKey(const Key('ranking-row-39')), findsOneWidget); + expect(find.byKey(const Key('ranking-row-40')), findsNothing); + expect(find.byKey(const Key('ranking-no-more')), findsOneWidget); + + final position = _scrollPosition(tester, const Key('ranking-list')); + position.jumpTo(0); + await tester.pump(); + final beforeRefresh = repository.fetchCalls; + await tester.drag(list, const Offset(0, 120)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(repository.fetchCalls, beforeRefresh + 1); + expect(position.pixels, 0); + await tester.drag(list, const Offset(0, -8000)); + await tester.pump(); + expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); + expect(find.byKey(const Key('ranking-no-more')), findsNothing); + await tester.pumpAndSettle(); + }); + + testWidgets('should_page_new_releases_and_reset_after_refresh', ( + tester, + ) async { + final repository = _CountingCatalogRepository(dramas: _rankDramas(40)); + await _pumpTheater(tester, catalogRepository: repository); + await tester.tap(find.byKey(const Key('theater-toolbar-new'))); + await tester.pumpAndSettle(); + + final list = find.byKey(const Key('new-releases-list')); + await tester.drag(list, const Offset(0, -400)); + await tester.pump(); + expect(_countKeys('home-drama-card-'), 20); + + await tester.drag(list, const Offset(0, -8000)); + await tester.pump(); + expect(find.byKey(const Key('new-releases-load-more')), findsOneWidget); + await tester.pump(const Duration(milliseconds: 299)); + expect(_countKeys('home-drama-card-'), 20); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + expect(_countKeys('home-drama-card-'), 40); + + await tester.pumpAndSettle(); + final position = _scrollPosition(tester, const Key('new-releases-list')); + position.jumpTo(position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('new-releases-no-more')), findsOneWidget); + + position.jumpTo(0); + await tester.pump(); + final beforeRefresh = repository.fetchCalls; + await tester.drag(list, const Offset(0, 120)); + await tester.pump(); + await tester.pumpAndSettle(); + expect(repository.fetchCalls, beforeRefresh + 1); + expect(position.pixels, 0); + + await tester.drag(list, const Offset(0, -400)); + await tester.pump(); + expect(_countKeys('home-drama-card-'), 20); + }); + + testWidgets('should_refresh_reserve_without_load_more', (tester) async { + final repository = _CountingCatalogRepository(dramas: _rankDramas(3)); + await _pumpTheater(tester, catalogRepository: repository); + await tester.tap(find.byKey(const Key('theater-toolbar-reserve'))); + await tester.pumpAndSettle(); + + final beforeRefresh = repository.fetchCalls; + await tester.drag( + find.byKey(const Key('reserve-list')), + const Offset(0, 120), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(repository.fetchCalls, beforeRefresh + 1); + expect(find.byKey(const Key('reserve-load-more')), findsNothing); + expect(find.byKey(const Key('reserve-no-more')), findsNothing); + }); } -Future _pumpTheater(WidgetTester tester, {List? dramas}) async { +Future _pumpTheater( + WidgetTester tester, { + List? dramas, + FakeCatalogRepository? catalogRepository, +}) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final catalog = FakeCatalogRepository(dramas: dramas); + final catalog = catalogRepository ?? FakeCatalogRepository(dramas: dramas); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, @@ -199,6 +305,34 @@ Future _pumpTheater(WidgetTester tester, {List? dramas}) async { await tester.pumpAndSettle(); } +ScrollPosition _scrollPosition(WidgetTester tester, Key listKey) { + final scrollable = find.descendant( + of: find.byKey(listKey), + matching: find.byType(Scrollable), + ); + return tester.state(scrollable.first).position; +} + +int _countKeys(String prefix) => find + .byWidgetPredicate((widget) { + final key = widget.key; + return key is ValueKey && key.value.startsWith(prefix); + }) + .evaluate() + .length; + +class _CountingCatalogRepository extends FakeCatalogRepository { + _CountingCatalogRepository({super.dramas}); + + int fetchCalls = 0; + + @override + Future fetchHomeFeed() { + fetchCalls++; + return super.fetchHomeFeed(); + } +} + List _rankDramas(int count) => [ for (var index = 0; index < count; index++) Drama( From 69eaacd74f4b3e1eb7887e746df403462c61792a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:57:28 +0800 Subject: [PATCH 096/124] =?UTF-8?q?fix(flutter):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E5=88=B7=E6=96=B0=E9=A1=B5=E9=9D=A2=E8=B7=9F?= =?UTF-8?q?=E9=9A=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/app_pull_to_refresh.dart | 42 ++++++++++++++++++- lib/pages/discover/discover_view.dart | 2 +- lib/pages/downloads/downloads_page.dart | 2 +- lib/pages/favorites/favorites_page.dart | 2 +- lib/pages/home/home_browse_view.dart | 2 +- lib/pages/new_releases/new_releases_page.dart | 2 +- lib/pages/notices/notices_page.dart | 6 +-- lib/pages/ranking/ranking_page.dart | 2 +- lib/pages/reserve/reserve_page.dart | 2 +- lib/pages/wallet/wallet_page.dart | 2 +- .../watch_records/watch_records_page.dart | 2 +- test/core/app_pull_to_refresh_test.dart | 13 +++--- test/discover/home_all_dramas_grid_test.dart | 6 ++- test/discover/theater_pages_test.dart | 26 ++++++------ test/library/favorites_page_test.dart | 6 ++- test/watch/watch_records_page_test.dart | 2 +- 16 files changed, 85 insertions(+), 34 deletions(-) diff --git a/lib/core/widgets/app_pull_to_refresh.dart b/lib/core/widgets/app_pull_to_refresh.dart index 3cf3d06..ba5ac26 100644 --- a/lib/core/widgets/app_pull_to_refresh.dart +++ b/lib/core/widgets/app_pull_to_refresh.dart @@ -9,6 +9,8 @@ import '../theme/app_colors.dart'; const double _maxPullDistance = 96; const double _refreshThreshold = 56; +const double _indicatorHeight = 64; +const double _indicatorMaxOffset = 10; const Duration _arrowTurnDuration = Duration(milliseconds: 160); const Duration _retractDuration = Duration(milliseconds: 200); @@ -23,6 +25,9 @@ class AppPullToRefresh extends StatefulWidget { this.edgeOffset = 0, }); + static const AlwaysScrollableScrollPhysics physics = + AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()); + final Widget child; final Future Function() onRefresh; final double edgeOffset; @@ -40,6 +45,12 @@ class _AppPullToRefreshState extends State { if (notification.depth != 0 || _refreshing) return false; if (notification is ScrollStartNotification) { if (notification.dragDetails != null) _setPullDistance(0); + } else if (notification is ScrollUpdateNotification) { + if (notification.dragDetails != null) { + _setPullDistance( + notification.metrics.minScrollExtent - notification.metrics.pixels, + ); + } } else if (notification is OverscrollNotification) { if (notification.dragDetails != null) { _setPullDistance(_pullDistance - notification.overscroll); @@ -100,19 +111,46 @@ class _AppPullToRefreshState extends State { child: Stack( fit: StackFit.expand, children: [ - widget.child, + Listener( + onPointerUp: (_) => _release(), + onPointerCancel: (_) => _release(), + child: widget.child, + ), if (_state != _PullState.idle) Positioned( top: widget.edgeOffset, left: 0, right: 0, - child: IgnorePointer(child: _indicator(context)), + child: IgnorePointer(child: _indicatorLayer(context)), ), ], ), ); } + Widget _indicatorLayer(BuildContext context) { + final progress = (_pullDistance / _refreshThreshold).clamp(0.0, 1.0); + final opacity = _state == _PullState.refreshing + ? 1.0 + : progress.clamp(0.0, 1.0); + return SizedBox( + height: _indicatorHeight, + child: Opacity( + opacity: opacity, + child: Transform.translate( + offset: Offset(0, _indicatorMaxOffset * progress), + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: _indicator(context), + ), + ), + ), + ), + ); + } + Widget _indicator(BuildContext context) { final l10n = AppLocalizations.of(context)!; if (_state == _PullState.refreshing) { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 606622a..4b1fa46 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -159,7 +159,7 @@ class _DiscoverPageState extends State { onRefresh: categories.load, child: ListView( controller: logic.scroll, - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, padding: EdgeInsets.only( top: showHeader ? _stickyHeaderHeight : 0, bottom: AppDimens.spaceXl, diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index f3e6997..359c2a7 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -94,7 +94,7 @@ class _DownloadsBody extends StatelessWidget { return AppPullToRefresh( onRefresh: downloads.load, child: ListView( - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, padding: const EdgeInsets.fromLTRB(16, 10, 16, 28), children: [ _PolicyPanel(downloads: downloads, policy: policy), diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index ee45a08..2676569 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -167,7 +167,7 @@ class _FavoritesBody extends StatelessWidget { return AppPullToRefresh( onRefresh: favorites.load, child: ListView( - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, padding: const EdgeInsets.fromLTRB( _pageHorizontal, 4, diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index 6924045..7a13bf0 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -146,7 +146,7 @@ class _HomeBrowseViewState extends State { child: ListView( key: const Key('home-browse-page'), controller: _scroll, - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), children: _sections(l10n, catalog), ), diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index e499302..3076aab 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -133,7 +133,7 @@ class _NewReleasesPageState extends State { child: ListView( key: const Key('new-releases-list'), controller: _scroll, - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, padding: const EdgeInsets.only(bottom: 24), children: hero == null ? [ diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index 4c42abf..ef59a4d 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -69,7 +69,7 @@ class _NoticesPageState extends State { return AppPullToRefresh( onRefresh: _controller.refresh, child: ListView( - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, children: [ SizedBox( height: MediaQuery.sizeOf(context).height * 0.7, @@ -94,7 +94,7 @@ class _NoticesPageState extends State { return AppPullToRefresh( onRefresh: _controller.refresh, child: ListView( - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, children: [ SizedBox( height: MediaQuery.sizeOf(context).height * 0.7, @@ -113,7 +113,7 @@ class _NoticesPageState extends State { onRefresh: _controller.refresh, child: ListView.separated( key: const Key('notices-list'), - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), itemCount: _controller.notices.length + 1, separatorBuilder: (_, _) => const SizedBox(height: 10), diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index bb2b3bb..e28923d 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -122,7 +122,7 @@ class _RankingPageState extends State { child: ListView( key: const Key('ranking-list'), controller: _scroll, - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, padding: const EdgeInsets.only(top: 4, bottom: 24), children: top.isEmpty ? [ diff --git a/lib/pages/reserve/reserve_page.dart b/lib/pages/reserve/reserve_page.dart index a4a3bc8..551449d 100644 --- a/lib/pages/reserve/reserve_page.dart +++ b/lib/pages/reserve/reserve_page.dart @@ -49,7 +49,7 @@ class _ReservePageState extends State { onRefresh: _refresh, child: ListView( key: const Key('reserve-list'), - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), children: [ AppSubpageHeader( diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart index 651244f..72a5948 100644 --- a/lib/pages/wallet/wallet_page.dart +++ b/lib/pages/wallet/wallet_page.dart @@ -120,7 +120,7 @@ class _WalletPageState extends State { return AppPullToRefresh( onRefresh: wallet.load, child: ListView( - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, padding: const EdgeInsets.fromLTRB(16, 8, 16, 28), children: [ _BalanceHero(wallet: snapshot.wallet), diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index 00fd4a2..157c48b 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -149,7 +149,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { return AppPullToRefresh( onRefresh: controller.load, child: ListView( - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, padding: const EdgeInsets.fromLTRB( _pageHorizontal, 4, diff --git a/test/core/app_pull_to_refresh_test.dart b/test/core/app_pull_to_refresh_test.dart index b86aa7e..7a55e00 100644 --- a/test/core/app_pull_to_refresh_test.dart +++ b/test/core/app_pull_to_refresh_test.dart @@ -26,7 +26,7 @@ void main() { }, child: ListView( key: const Key('pull-refresh-list'), - physics: const AlwaysScrollableScrollPhysics(), + physics: AppPullToRefresh.physics, children: const [SizedBox(height: 1200)], ), ), @@ -34,14 +34,17 @@ void main() { ), ); - final gesture = await tester.startGesture( - tester.getCenter(find.byKey(const Key('pull-refresh-list'))), - ); + final list = find.byKey(const Key('pull-refresh-list')); + final gesture = await tester.startGesture(tester.getCenter(list)); await gesture.moveBy(const Offset(0, 30)); await tester.pump(); + final scrollable = tester.state( + find.descendant(of: list, matching: find.byType(Scrollable)).first, + ); + expect(scrollable.position.pixels, lessThan(0)); expect(find.text('Pull to refresh'), findsOneWidget); - await gesture.moveBy(const Offset(0, 40)); + await gesture.moveBy(const Offset(0, 120)); await tester.pump(); expect(find.text('Release to refresh'), findsOneWidget); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index d90e923..0103065 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -300,7 +300,11 @@ void main() { ); expect(refresh.edgeOffset, 52); - await tester.drag(_verticalFeed, const Offset(0, 120)); + expect( + tester.widget(_verticalFeed).physics, + AppPullToRefresh.physics, + ); + await tester.drag(_verticalFeed, const Offset(0, 240)); await tester.pump(); expect(find.text('Refreshing…'), findsOneWidget); await tester.pumpAndSettle(); diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 7b6ae6e..4bb3437 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -6,6 +6,7 @@ import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/app_pull_to_refresh.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'; @@ -181,7 +182,9 @@ void main() { await tester.pumpAndSettle(); final list = find.byKey(const Key('ranking-list')); - await tester.drag(list, const Offset(0, -8000)); + expect(tester.widget(list).physics, AppPullToRefresh.physics); + final position = _scrollPosition(tester, const Key('ranking-list')); + position.jumpTo(position.maxScrollExtent); await tester.pump(); expect(find.byKey(const Key('ranking-row-19')), findsOneWidget); @@ -191,23 +194,22 @@ void main() { expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); await tester.pump(const Duration(milliseconds: 1)); - await tester.drag(list, const Offset(0, -8000)); + position.jumpTo(position.maxScrollExtent); await tester.pump(); expect(find.byKey(const Key('ranking-row-39')), findsOneWidget); expect(find.byKey(const Key('ranking-row-40')), findsNothing); expect(find.byKey(const Key('ranking-no-more')), findsOneWidget); - final position = _scrollPosition(tester, const Key('ranking-list')); position.jumpTo(0); await tester.pump(); final beforeRefresh = repository.fetchCalls; - await tester.drag(list, const Offset(0, 120)); + await tester.drag(list, const Offset(0, 240)); await tester.pump(); await tester.pumpAndSettle(); expect(repository.fetchCalls, beforeRefresh + 1); expect(position.pixels, 0); - await tester.drag(list, const Offset(0, -8000)); + position.jumpTo(position.maxScrollExtent); await tester.pump(); expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); expect(find.byKey(const Key('ranking-no-more')), findsNothing); @@ -223,11 +225,13 @@ void main() { await tester.pumpAndSettle(); final list = find.byKey(const Key('new-releases-list')); + expect(tester.widget(list).physics, AppPullToRefresh.physics); await tester.drag(list, const Offset(0, -400)); await tester.pump(); expect(_countKeys('home-drama-card-'), 20); - await tester.drag(list, const Offset(0, -8000)); + final position = _scrollPosition(tester, const Key('new-releases-list')); + position.jumpTo(position.maxScrollExtent); await tester.pump(); expect(find.byKey(const Key('new-releases-load-more')), findsOneWidget); await tester.pump(const Duration(milliseconds: 299)); @@ -237,7 +241,6 @@ void main() { expect(_countKeys('home-drama-card-'), 40); await tester.pumpAndSettle(); - final position = _scrollPosition(tester, const Key('new-releases-list')); position.jumpTo(position.maxScrollExtent); await tester.pumpAndSettle(); expect(find.byKey(const Key('new-releases-no-more')), findsOneWidget); @@ -245,7 +248,7 @@ void main() { position.jumpTo(0); await tester.pump(); final beforeRefresh = repository.fetchCalls; - await tester.drag(list, const Offset(0, 120)); + await tester.drag(list, const Offset(0, 240)); await tester.pump(); await tester.pumpAndSettle(); expect(repository.fetchCalls, beforeRefresh + 1); @@ -263,10 +266,9 @@ void main() { await tester.pumpAndSettle(); final beforeRefresh = repository.fetchCalls; - await tester.drag( - find.byKey(const Key('reserve-list')), - const Offset(0, 120), - ); + final list = find.byKey(const Key('reserve-list')); + expect(tester.widget(list).physics, AppPullToRefresh.physics); + await tester.drag(list, const Offset(0, 240)); await tester.pump(); await tester.pumpAndSettle(); diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 72a951c..b286d81 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -75,7 +75,11 @@ void main() { expect(find.byIcon(Icons.star_rounded), findsNWidgets(2)); expect(find.byIcon(Icons.favorite_rounded), findsNothing); expect(find.byType(AppPullToRefresh), findsOneWidget); - await tester.drag(find.byType(ListView), const Offset(0, 120)); + expect( + tester.widget(find.byType(ListView)).physics, + AppPullToRefresh.physics, + ); + await tester.drag(find.byType(ListView), const Offset(0, 240)); await tester.pump(); expect(find.text('Refreshing…'), findsOneWidget); await tester.pumpAndSettle(); diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index e88cef1..ec98178 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -85,7 +85,7 @@ void main() { matching: find.byType(ListView), ), ); - expect(list.physics, isA()); + expect(list.physics, AppPullToRefresh.physics); }); testWidgets( From 98705029017d251d32c5eb21a7e14896247e21be Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 17:00:03 +0800 Subject: [PATCH 097/124] =?UTF-8?q?fix(flutter):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=89=A7=E5=9C=BA=E6=90=9C=E7=B4=A2=E6=B8=85=E7=A9=BA=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/discover_search_field.dart | 66 ++++++++++++++++--- test/discover/discover_search_field_test.dart | 57 ++++++++++++++++ 2 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 test/discover/discover_search_field_test.dart diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index f4a889d..871548f 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -10,6 +10,10 @@ const double _searchRadius = 22; const double _searchPadding = 14; const double _iconSize = 18; const double _iconGap = 8; +const double _trailingWidth = 42; +const double _trailingGap = 6; +const double _clearSize = 18; +const double _clearIconSize = 12; const Duration _focusTransition = Duration(milliseconds: 160); /// 剧场顶部整行搜索框:浅灰胶囊,聚焦时变白并显示主题色描边。 @@ -40,6 +44,7 @@ class _DiscoverSearchFieldState extends State { void initState() { super.initState(); widget.focusNode.addListener(_onFocusChanged); + widget.controller.addListener(_onTextChanged); } @override @@ -50,11 +55,16 @@ class _DiscoverSearchFieldState extends State { widget.focusNode.addListener(_onFocusChanged); _focused = widget.focusNode.hasFocus; } + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onTextChanged); + widget.controller.addListener(_onTextChanged); + } } @override void dispose() { widget.focusNode.removeListener(_onFocusChanged); + widget.controller.removeListener(_onTextChanged); super.dispose(); } @@ -63,6 +73,16 @@ class _DiscoverSearchFieldState extends State { if (focused != _focused) setState(() => _focused = focused); } + void _onTextChanged() { + if (mounted) setState(() {}); + } + + void _clear() { + widget.controller.clear(); + widget.onChanged(''); + widget.focusNode.requestFocus(); + } + @override Widget build(BuildContext context) { return Padding( @@ -112,14 +132,7 @@ class _DiscoverSearchFieldState extends State { enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: EdgeInsets.zero, - suffixIcon: widget.loading - ? const SizedBox.square( - dimension: 18, - child: Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : null, + suffixIcon: _trailing(), suffixIconConstraints: const BoxConstraints( minWidth: 0, minHeight: 0, @@ -132,4 +145,41 @@ class _DiscoverSearchFieldState extends State { ), ); } + + Widget _trailing() { + final hasText = widget.controller.text.isNotEmpty; + return SizedBox( + width: _trailingWidth, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.loading) + const SizedBox.square( + key: Key('discover-search-loading'), + dimension: _iconSize, + child: CircularProgressIndicator(strokeWidth: 2), + ), + if (widget.loading && hasText) const SizedBox(width: _trailingGap), + if (hasText) + SizedBox.square( + key: const Key('discover-search-clear'), + dimension: _clearSize, + child: Material( + color: AppColors.raised, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: _clear, + child: const Icon( + Icons.close_rounded, + size: _clearIconSize, + color: AppColors.muted, + ), + ), + ), + ), + ], + ), + ); + } } diff --git a/test/discover/discover_search_field_test.dart b/test/discover/discover_search_field_test.dart new file mode 100644 index 0000000..62d8e29 --- /dev/null +++ b/test/discover/discover_search_field_test.dart @@ -0,0 +1,57 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/discover/widgets/discover_search_field.dart'; + +void main() { + testWidgets('should_clear_text_and_keep_focus_when_clear_is_tapped', ( + tester, + ) async { + final controller = TextEditingController(); + final focusNode = FocusNode(); + final changes = []; + addTearDown(controller.dispose); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: DiscoverSearchField( + controller: controller, + focusNode: focusNode, + onChanged: changes.add, + loading: true, + ), + ), + ), + ); + + final search = find.byKey(const Key('discover-search')); + expect(find.byKey(const Key('discover-search-clear')), findsNothing); + + await tester.tap(search); + await tester.enterText(search, 'velvet'); + await tester.pump(); + changes.clear(); + + final clear = find.byKey(const Key('discover-search-clear')); + final loading = find.byKey(const Key('discover-search-loading')); + expect(clear, findsOneWidget); + expect(loading, findsOneWidget); + expect(tester.getSize(clear), const Size(18, 18)); + expect(tester.getRect(loading).right, lessThan(tester.getRect(clear).left)); + + await tester.tap(clear); + await tester.pump(); + + expect(controller.text, isEmpty); + expect(changes, ['']); + expect(find.byKey(const Key('discover-search-clear')), findsNothing); + expect(focusNode.hasFocus, isTrue); + }); +} From 107ae40d2ad0290b2d64e78a73788649678e0ecc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 17:02:20 +0800 Subject: [PATCH 098/124] =?UTF-8?q?fix(flutter):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E5=88=B7=E6=96=B0=E4=B8=BB=E8=89=B2=E6=96=87?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/app_pull_to_refresh.dart | 13 ++++++------- test/core/app_pull_to_refresh_test.dart | 13 +++++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/core/widgets/app_pull_to_refresh.dart b/lib/core/widgets/app_pull_to_refresh.dart index ba5ac26..b71c335 100644 --- a/lib/core/widgets/app_pull_to_refresh.dart +++ b/lib/core/widgets/app_pull_to_refresh.dart @@ -173,15 +173,14 @@ class _AppPullToRefreshState extends State { child: Icon( Icons.arrow_downward_rounded, size: 18, - color: armed ? AppColors.blue : AppColors.muted, + color: AppColors.blue, ), ), armed ? l10n.releaseToRefresh : l10n.pullToRefresh, - emphasized: armed, ); } - Widget _label(Widget icon, String text, {bool emphasized = false}) { + Widget _label(Widget icon, String text) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -189,10 +188,10 @@ class _AppPullToRefreshState extends State { const SizedBox(width: 6), Text( text, - style: TextStyle( - color: emphasized ? AppColors.blue : AppColors.muted, - fontSize: 12, - fontWeight: emphasized ? FontWeight.w700 : FontWeight.w500, + style: const TextStyle( + color: AppColors.blue, + fontSize: 13, + fontWeight: FontWeight.w600, ), ), ], diff --git a/test/core/app_pull_to_refresh_test.dart b/test/core/app_pull_to_refresh_test.dart index 7a55e00..deb4df4 100644 --- a/test/core/app_pull_to_refresh_test.dart +++ b/test/core/app_pull_to_refresh_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; @@ -43,14 +44,26 @@ void main() { ); expect(scrollable.position.pixels, lessThan(0)); expect(find.text('Pull to refresh'), findsOneWidget); + expect( + tester.widget(find.text('Pull to refresh')).style?.color, + AppColors.blue, + ); await gesture.moveBy(const Offset(0, 120)); await tester.pump(); expect(find.text('Release to refresh'), findsOneWidget); + expect( + tester.widget(find.text('Release to refresh')).style?.color, + AppColors.blue, + ); await gesture.up(); await tester.pump(); expect(find.text('Refreshing…'), findsOneWidget); + expect( + tester.widget(find.text('Refreshing…')).style?.color, + AppColors.blue, + ); expect(calls, 1); final duplicate = await tester.startGesture( From 5af70c345a62016b33ee426df0f2b46520729380 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 17:07:25 +0800 Subject: [PATCH 099/124] =?UTF-8?q?fix(flutter):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=BC=A7=E7=BA=BF=E4=B8=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 173 ++++++++++++++----- test/discover/home_all_dramas_grid_test.dart | 67 +++++-- 2 files changed, 177 insertions(+), 63 deletions(-) diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 4b1fa46..5245c5e 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -38,8 +38,12 @@ const double _categoryTabHeight = 44; const double _categoryTabGap = 20; const double _categorySelectedFontSize = 16; const double _categoryFontSize = 15; +const double _categoryUnderlineWidth = 20; +const double _categoryUnderlineHeight = 3; +const double _categoryUnderlineStrokeWidth = 2; const double _stickyHeaderHeight = 44; const Duration _stickyHeaderTransition = Duration(milliseconds: 160); +const Duration _categoryScrollDuration = Duration(milliseconds: 200); /// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 class DiscoverPage extends StatefulWidget { @@ -63,6 +67,10 @@ class DiscoverPage extends StatefulWidget { class _DiscoverPageState extends State { late final DiscoverLogic logic; final ValueNotifier _headerPinned = ValueNotifier(false); + final ScrollController _categoryScroll = ScrollController(); + final Map _categoryAnchors = {}; + String? _ensuredCategoryId; + bool _categoryRevealScheduled = false; bool get embedded => widget.callbacks.embedded; @@ -99,6 +107,7 @@ class _DiscoverPageState extends State { @override void dispose() { logic.scroll.removeListener(_onScroll); + _categoryScroll.dispose(); _headerPinned.dispose(); logic.dispose(); super.dispose(); @@ -277,78 +286,126 @@ class _DiscoverPageState extends State { } Widget _categoryTabs(AppLocalizations l10n, CategoryController categories) { + _scheduleSelectedCategoryReveal(); return SizedBox( key: const Key('theater-category-chips'), height: _categoryTabHeight, - child: ListView.separated( + child: SingleChildScrollView( + key: const Key('theater-category-scroll'), + controller: _categoryScroll, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 14), - itemCount: categories.categories.length + 1, - separatorBuilder: (_, _) => const SizedBox(width: _categoryTabGap), - itemBuilder: (context, index) { - if (index == 0) { - return _categoryTab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _categoryTab( key: const Key('theater-category-all'), + anchorKey: _anchorFor('all'), id: 'all', label: l10n.all, selected: logic.selectedCategoryId == null, - onTap: () => logic.selectCategory(null), - ); - } - final category = categories.categories[index - 1]; - return _categoryTab( - key: Key('theater-category-${category.id}'), - id: category.id, - label: category.name, - selected: logic.selectedCategoryId == category.id, - onTap: () => logic.selectCategory(category.id), - ); - }, + onTap: () => _selectCategory(null), + ), + for (final category in categories.categories) ...[ + const SizedBox(width: _categoryTabGap), + _categoryTab( + key: Key('theater-category-${category.id}'), + anchorKey: _anchorFor(category.id), + id: category.id, + label: category.name, + selected: logic.selectedCategoryId == category.id, + onTap: () => _selectCategory(category.id), + ), + ], + ], + ), ), ); } Widget _categoryTab({ required Key key, + required GlobalKey anchorKey, required String id, required String label, required bool selected, required VoidCallback onTap, }) { - return GestureDetector( - key: key, - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - label, - maxLines: 1, - style: TextStyle( - color: selected ? AppColors.onSurface : AppColors.muted, - fontSize: selected - ? _categorySelectedFontSize - : _categoryFontSize, - fontWeight: selected ? FontWeight.w800 : FontWeight.w500, + return KeyedSubtree( + key: anchorKey, + child: GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + maxLines: 1, + style: TextStyle( + color: selected ? AppColors.onSurface : AppColors.muted, + fontSize: selected + ? _categorySelectedFontSize + : _categoryFontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w500, + ), ), - ), - const SizedBox(height: 4), - Container( - key: Key('theater-category-underline-$id'), - width: 18, - height: 2, - decoration: BoxDecoration( - color: selected ? AppColors.blue : Colors.transparent, - borderRadius: BorderRadius.circular(1), + const SizedBox(height: 4), + CustomPaint( + key: Key('theater-category-underline-$id'), + size: const Size( + _categoryUnderlineWidth, + _categoryUnderlineHeight, + ), + painter: CategoryUnderlinePainter( + color: selected ? AppColors.blue : Colors.transparent, + ), ), - ), - ], + ], + ), ), ); } + GlobalKey _anchorFor(String id) { + return _categoryAnchors.putIfAbsent(id, GlobalKey.new); + } + + void _selectCategory(String? categoryId) { + logic.selectCategory(categoryId); + final id = categoryId ?? 'all'; + _ensuredCategoryId = id; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_revealCategory(id)); + }); + } + + void _scheduleSelectedCategoryReveal() { + final selectedId = logic.selectedCategoryId ?? 'all'; + if (_categoryRevealScheduled || _ensuredCategoryId == selectedId) return; + _categoryRevealScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + _categoryRevealScheduled = false; + if (!mounted || (logic.selectedCategoryId ?? 'all') != selectedId) return; + final revealed = await _revealCategory(selectedId); + if (revealed) _ensuredCategoryId = selectedId; + }); + } + + Future _revealCategory(String id) async { + final target = _categoryAnchors[id]?.currentContext; + if (target == null) return false; + await Scrollable.ensureVisible( + target, + alignment: 0.5, + duration: _categoryScrollDuration, + curve: Curves.easeOutCubic, + ); + return true; + } + Widget _toolbar(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.fromLTRB(14, 2, 14, 12), @@ -501,3 +558,27 @@ class _DiscoverPageState extends State { ], ); } + +/// 分类 tab 的弧形选中线。 +class CategoryUnderlinePainter extends CustomPainter { + const CategoryUnderlinePainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = _categoryUnderlineStrokeWidth + ..strokeCap = StrokeCap.round; + final path = Path() + ..moveTo(1, size.height - 1) + ..quadraticBezierTo(size.width / 2, 0, size.width - 1, size.height - 1); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CategoryUnderlinePainter oldDelegate) => + color != oldDelegate.color; +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 0103065..16bb0bf 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -12,6 +12,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; +import 'package:fruitshorts/pages/discover/discover_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'; @@ -274,23 +275,54 @@ void main() { testWidgets('should_show_category_underline_for_selection', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - Color underline(String id) => - (tester - .widget( - find.byKey(Key('theater-category-underline-$id')), - ) - .decoration! - as BoxDecoration) - .color!; + CategoryUnderlinePainter underline(String id) => + tester + .widget( + find.byKey(Key('theater-category-underline-$id')), + ) + .painter! + as CategoryUnderlinePainter; - expect(underline('all'), AppColors.blue); - expect(underline('7'), Colors.transparent); + expect( + tester + .widget( + find.byKey(const Key('theater-category-underline-all')), + ) + .size, + const Size(20, 3), + ); + expect(underline('all').color, AppColors.blue); + expect(underline('7').color, Colors.transparent); await tester.tap(find.byKey(const Key('theater-category-7'))); await tester.pumpAndSettle(); - expect(underline('all'), Colors.transparent); - expect(underline('7'), AppColors.blue); + expect(underline('all').color, Colors.transparent); + expect(underline('7').color, AppColors.blue); + }); + + testWidgets('should_scroll_offscreen_category_into_view_when_tapped', ( + tester, + ) async { + final categories = [ + for (var index = 0; index < 12; index++) + ContentCategory(id: '$index', name: 'Long category $index'), + ]; + await _pumpTheater( + tester, + categoryRepository: _GridCategoryRepository(categories: categories), + ); + + final target = find.byKey(const Key('theater-category-11')); + expect(tester.getRect(target).right, greaterThan(390)); + + tester.widget(target).onTap!(); + await tester.pump(); + await tester.pumpAndSettle(); + + final rect = tester.getRect(target); + expect(rect.left, greaterThanOrEqualTo(0)); + expect(rect.right, lessThanOrEqualTo(390)); }); testWidgets('should_use_text_pull_to_refresh', (tester) async { @@ -344,15 +376,16 @@ Future _pumpTheater( } class _GridCategoryRepository implements CategoryRepository { - _GridCategoryRepository({HomeFeed? discoverFeed}) - : discoverFeed = discoverFeed ?? _defaultDiscoverFeed; + _GridCategoryRepository({ + HomeFeed? discoverFeed, + this.categories = const [ContentCategory(id: '7', name: 'Romance')], + }) : discoverFeed = discoverFeed ?? _defaultDiscoverFeed; final HomeFeed discoverFeed; + final List categories; @override - Future> fetchCategories() async => const [ - ContentCategory(id: '7', name: 'Romance'), - ]; + Future> fetchCategories() async => categories; @override Future fetchDiscoverFeed() async => discoverFeed; From 420da296582badf78701a63c25d19ad0ed85e495 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 17:09:37 +0800 Subject: [PATCH 100/124] =?UTF-8?q?fix(flutter):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=BC=A7=E7=BA=BF=E5=BC=80=E5=8F=A3=E6=96=B9?= =?UTF-8?q?=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 5245c5e..93e8b89 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -573,8 +573,8 @@ class CategoryUnderlinePainter extends CustomPainter { ..strokeWidth = _categoryUnderlineStrokeWidth ..strokeCap = StrokeCap.round; final path = Path() - ..moveTo(1, size.height - 1) - ..quadraticBezierTo(size.width / 2, 0, size.width - 1, size.height - 1); + ..moveTo(1, 1) + ..quadraticBezierTo(size.width / 2, size.height + 1, size.width - 1, 1); canvas.drawPath(path, paint); } From 5034ac7670f7bc4da754659af428268f699fc1a0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 17:12:28 +0800 Subject: [PATCH 101/124] =?UTF-8?q?fix(flutter):=20=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=8A=A0=E9=80=9F=E6=80=81=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=B8=85=E5=B1=8F=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 2 +- test/home/k28_a_navigation_test.dart | 35 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 186c370..9991f03 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -537,7 +537,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ? null : () => _openUnlockSheet(context, index, episode), ), - if (widget.homeMode && !_chromeVisible) + if (widget.homeMode && !_chromeVisible && !_boosting) Positioned( right: 12, bottom: 56, diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 31a6fe0..880bbeb 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -188,6 +188,41 @@ void main() { expect(find.byKey(const Key('home-feed-search')), findsOneWidget); }); + testWidgets('should_hide_exit_clear_button_while_boosting', (tester) async { + await _pumpApp(tester); + final moreGesture = await tester.startGesture(const Offset(195, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + await moreGesture.up(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more-clear'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + + final boostGesture = await tester.startGesture(const Offset(60, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + + expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsOneWidget); + expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); + expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); + expect(find.byKey(const Key('home-feed-search')), findsNothing); + + await boostGesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + expect(find.byKey(const Key('home-feed-search')), findsOneWidget); + }); + testWidgets('should_resume_playback_when_boosting_while_paused', ( tester, ) async { From 15570f117afd277b06339a42a57e9b43c4de1973 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 17:20:06 +0800 Subject: [PATCH 102/124] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E5=81=9A?= =?UTF-8?q?=E5=93=81=E7=89=8C=E5=BC=80=E5=B1=8F=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/splash/splash_page.dart | 220 +++++++++++++++++++++++++----- test/splash/splash_gate_test.dart | 79 +++++++---- 2 files changed, 243 insertions(+), 56 deletions(-) diff --git a/lib/pages/splash/splash_page.dart b/lib/pages/splash/splash_page.dart index 2eac25c..f3f3709 100644 --- a/lib/pages/splash/splash_page.dart +++ b/lib/pages/splash/splash_page.dart @@ -1,52 +1,210 @@ // Author: fruit.com.cn +import 'dart:ui'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../core/theme/app_colors.dart'; -const double _markSize = 84; -const double _markRadius = 18; +const Color _splashBackground = Color(0xFF100C0A); +const Color _markGlow = Color(0x33FF7A1A); +const double _markSize = 96; +const double _markRadius = 24; +const double _logoGlowSize = 144; +const double _logoGlowBlur = 40; const double _loaderSize = 22; -const double _loaderStroke = 2.5; +const double _loaderStroke = 2; const String _markAsset = 'assets/icon/splash_mark.png'; +const Duration _introDuration = Duration(milliseconds: 1200); -/// 系统开屏已经放好同一块底和图标。这一页接上加载,直到首页数据回来。 -class SplashPage extends StatelessWidget { +/// 品牌开屏:深暖底上依次呈现水果标记、FruitShorts 与中文标语。 +class SplashPage extends StatefulWidget { const SplashPage({super.key}); + @override + State createState() => _SplashPageState(); +} + +class _SplashPageState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _logoScale; + late final Animation _logoOpacity; + late final Animation _brandReveal; + late final Animation _taglineReveal; + late final Animation _loadingReveal; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: _introDuration) + ..forward(); + _logoScale = Tween(begin: 0.86, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0, 0.42, curve: Curves.easeOutBack), + ), + ); + _logoOpacity = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0, 0.42, curve: Curves.easeOut), + ), + ); + _brandReveal = _reveal(0.125, 0.542); + _taglineReveal = _reveal(0.25, 0.667); + _loadingReveal = _reveal(0.417, 0.833); + } + + Animation _reveal(double begin, double end) { + return Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(begin, end, curve: Curves.easeOutCubic), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return ColoredBox( - key: Key('splash-screen'), - color: AppColors.ink, - child: Stack( - fit: StackFit.expand, - children: [ - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(_markRadius), - child: const Image( - image: AssetImage(_markAsset), - width: _markSize, - height: _markSize, - filterQuality: FilterQuality.medium, - gaplessPlayback: true, + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: ColoredBox( + key: const Key('splash-screen'), + color: _splashBackground, + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _logo(), + const SizedBox(height: 24), + _brand(), + const SizedBox(height: 6), + _tagline(), + ], ), ), - ), - Align( - alignment: Alignment(0, 0.24), - child: SizedBox( - width: _loaderSize, - height: _loaderSize, - child: CircularProgressIndicator( - key: Key('splash-loading'), - strokeWidth: _loaderStroke, - color: AppColors.gold, + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 48), + child: FadeTransition( + key: const Key('splash-loading-fade'), + opacity: _loadingReveal, + child: const SizedBox.square( + dimension: _loaderSize, + child: CircularProgressIndicator( + key: Key('splash-loading'), + strokeWidth: _loaderStroke, + color: AppColors.blue, + ), + ), + ), ), ), + ], + ), + ), + ); + } + + Widget _logo() { + return FadeTransition( + key: const Key('splash-logo-fade'), + opacity: _logoOpacity, + child: ScaleTransition( + scale: _logoScale, + child: SizedBox.square( + dimension: _logoGlowSize, + child: Stack( + alignment: Alignment.center, + children: [ + ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: _logoGlowBlur, + sigmaY: _logoGlowBlur, + ), + child: Container( + width: _markSize, + height: _markSize, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [_markGlow, Color(0x00FF7A1A)], + ), + ), + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(_markRadius), + child: Image.asset( + _markAsset, + width: _markSize, + height: _markSize, + filterQuality: FilterQuality.medium, + gaplessPlayback: true, + ), + ), + ], ), - ], + ), + ), + ); + } + + Widget _brand() { + return FadeTransition( + key: const Key('splash-brand-fade'), + opacity: _brandReveal, + child: AnimatedBuilder( + animation: _brandReveal, + builder: (context, child) => Transform.translate( + offset: Offset(0, 12 * (1 - _brandReveal.value)), + child: child, + ), + child: const Text( + 'FruitShorts', + key: Key('splash-brand'), + style: TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.w900, + fontFamily: 'Outfit', + ), + ), + ), + ); + } + + Widget _tagline() { + return FadeTransition( + key: const Key('splash-tagline-fade'), + opacity: _taglineReveal, + child: AnimatedBuilder( + animation: _taglineReveal, + builder: (context, child) => Transform.translate( + offset: Offset(0, 8 * (1 - _taglineReveal.value)), + child: child, + ), + child: const Text( + '水果短剧', + key: Key('splash-tagline'), + style: TextStyle( + color: Colors.white70, + fontSize: 13, + letterSpacing: 4, + ), + ), ), ); } diff --git a/test/splash/splash_gate_test.dart b/test/splash/splash_gate_test.dart index 0806c82..51729cd 100644 --- a/test/splash/splash_gate_test.dart +++ b/test/splash/splash_gate_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; @@ -10,38 +11,66 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/home/home_view.dart'; +import 'package:fruitshorts/pages/splash/splash_page.dart'; import 'package:provider/provider.dart'; void main() { - testWidgets( - 'should_keep_splash_with_loading_until_home_feed_settles', - (tester) async { - final repository = _PendingCatalog(); - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: ChangeNotifierProvider( - create: (_) => CatalogController(repository)..load(), - child: const HomePage(), - ), + testWidgets('should_keep_splash_with_loading_until_home_feed_settles', ( + tester, + ) async { + final repository = _PendingCatalog(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider( + create: (_) => CatalogController(repository)..load(), + child: const HomePage(), ), - ); + ), + ); - expect(find.byKey(const Key('splash-screen')), findsOneWidget); - expect(find.byKey(const Key('splash-loading')), findsOneWidget); - expect(find.byKey(const Key('home-empty')), findsNothing); - final clip = tester.widget(find.byType(ClipRRect)); - expect(clip.borderRadius, BorderRadius.circular(18)); + expect(find.byKey(const Key('splash-screen')), findsOneWidget); + expect(find.byKey(const Key('splash-loading')), findsOneWidget); + expect(find.byKey(const Key('home-empty')), findsNothing); + final clip = tester.widget(find.byType(ClipRRect)); + expect(clip.borderRadius, BorderRadius.circular(24)); - repository.pending.complete(const HomeFeed()); - await tester.pump(); - await tester.pump(); + repository.pending.complete(const HomeFeed()); + await tester.pump(); + await tester.pump(); - expect(find.byKey(const Key('splash-screen')), findsNothing); - expect(find.byKey(const Key('home-empty')), findsOneWidget); - }, - ); + expect(find.byKey(const Key('splash-screen')), findsNothing); + expect(find.byKey(const Key('home-empty')), findsOneWidget); + }); + + testWidgets('should_animate_brand_copy_and_loading_to_full_opacity', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: SplashPage())); + + expect(find.byKey(const Key('splash-screen')), findsOneWidget); + expect(find.byKey(const Key('splash-brand')), findsOneWidget); + expect(find.byKey(const Key('splash-tagline')), findsOneWidget); + expect(find.byKey(const Key('splash-loading')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('splash-screen'))).color, + const Color(0xFF100C0A), + ); + final overlay = tester.widget>( + find.byType(AnnotatedRegion).first, + ); + expect(overlay.value.statusBarIconBrightness, Brightness.light); + + await tester.pump(const Duration(milliseconds: 1200)); + + double opacity(String key) => + tester.widget(find.byKey(Key(key))).opacity.value; + expect(opacity('splash-logo-fade'), 1); + expect(opacity('splash-brand-fade'), 1); + expect(opacity('splash-tagline-fade'), 1); + expect(opacity('splash-loading-fade'), 1); + }); } class _PendingCatalog implements CatalogRepository { From 353196aaa3701a971b1b4a7b4527a9db81937770 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 17:32:53 +0800 Subject: [PATCH 103/124] =?UTF-8?q?fix(flutter):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E8=B7=B3=E8=BD=AC=E7=81=B0=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_logic.dart | 22 +-- test/player/player_open_detail_test.dart | 181 +++++++++++++++++++++++ 2 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 test/player/player_open_detail_test.dart diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 5c66f9f..3d47495 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -607,17 +607,19 @@ class PlayerLogic extends ChangeNotifier { } Future openDetail(BuildContext context) async { - session.setRouteVisible(false); - await session.playbackSettled; + session.setOverlayOpen(true); + try { + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama), + ), + ); + } finally { + session.setOverlayOpen(false); + } await saveCurrentProgress(); - if (!context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama), - ), - ); - if (isMounted()) session.setRouteVisible(active); } Future openSeriesAt( diff --git a/test/player/player_open_detail_test.dart b/test/player/player_open_detail_test.dart new file mode 100644 index 0000000..ae36b5b --- /dev/null +++ b/test/player/player_open_detail_test.dart @@ -0,0 +1,181 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/player_port.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/core/data/services/android_picture_in_picture.dart'; +import 'package:fruitshorts/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/core/data/services/video_player_port.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; +import 'package:fruitshorts/pages/player/player_logic.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; +import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_watch_history.dart'; + +void main() { + testWidgets( + 'should_keep_active_player_and_progress_when_detail_route_is_open', + (tester) async { + final drama = _drama(); + final history = FakeWatchHistory(); + final playback = FakePlaybackRepository(); + final catalog = CatalogController(FakeCatalogRepository(dramas: [drama])); + final favorites = FavoriteController( + InMemoryFavoriteRepository(saved: {drama.id}, cards: [drama]), + ); + final logic = PlayerLogic( + history: history, + playback: playback, + pictureInPicture: AndroidPictureInPicture( + channel: const MethodChannel('test/player_open_detail/pip'), + ), + ); + final factory = _RecordingFactory(); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _pumpSession(tester); + + final active = logic.session.active; + final index = logic.session.currentIndex; + final player = factory.created.first; + final playsBefore = player.plays; + final tracked = VideoPlayerPort(drama.episodes.first); + tracked.controller.value = const VideoPlayerValue( + duration: Duration(minutes: 5), + position: Duration(seconds: 37), + isInitialized: true, + ); + logic + ..trackedPlayer = tracked + ..trackedEpisode = drama.episodes.first + ..trackedDrama = drama; + + late BuildContext pageContext; + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: history), + Provider.value(value: playback), + ChangeNotifierProvider.value(value: catalog), + ChangeNotifierProvider.value(value: favorites), + Provider.value(value: FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + pageContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + unawaited(logic.openDetail(pageContext)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await _pumpSession(tester); + + expect(find.byType(DramaDetailPage), findsOneWidget); + expect(logic.session.active, same(active)); + expect(logic.session.currentIndex, index); + expect(logic.session.userWantsPlay, isTrue); + expect(player.pauses, 1); + expect(player.plays, playsBefore); + expect(tracked.controller.value.position, const Duration(seconds: 37)); + expect(history.writes, isEmpty); + + await tester.pageBack(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await _pumpSession(tester); + await logic.pendingWrite; + + expect(logic.session.active, same(active)); + expect(logic.session.currentIndex, index); + expect(logic.session.userWantsPlay, isTrue); + expect(player.plays, playsBefore + 1); + expect(tracked.controller.value.position, const Duration(seconds: 37)); + expect(history.writes, ['signal-1|37000|false']); + + logic + ..trackedPlayer = null + ..trackedEpisode = null + ..trackedDrama = null + ..dispose(); + await _pumpSession(tester); + await tracked.dispose(); + }, + ); +} + +Drama _drama() => Drama( + id: 'signal', + title: 'Signal', + synopsis: 'synopsis', + posterUrl: '/files/signal.png', + episodes: const [ + Episode( + id: 'signal-1', + number: 1, + title: 'Episode 1', + videoUrl: 'https://example.test/signal-1.mp4', + isFree: true, + ), + ], +); + +Future _pumpSession(WidgetTester tester) async { + for (var attempt = 0; attempt < 5; attempt++) { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + } +} + +class _RecordingFactory { + final List<_RecordingPlayer> created = []; + + PlayerPort create(Episode episode) { + final player = _RecordingPlayer(); + created.add(player); + return player; + } +} + +class _RecordingPlayer implements PlayerPort { + int plays = 0; + int pauses = 0; + + @override + Future initialize() async {} + + @override + Future play() async => plays++; + + @override + Future pause() async => pauses++; + + @override + Future dispose() async {} +} From 7e60e5ebc4706177cefc37a8ce66fac96a662698 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 17:34:15 +0800 Subject: [PATCH 104/124] =?UTF-8?q?feat(flutter):=20=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E6=B0=B4=E6=9E=9C=E7=9F=AD=E5=89=A7=E5=93=81=E7=89=8C=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E4=B8=8E=E5=BC=80=E5=B1=8F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drawable-hdpi/ic_launcher_foreground.png | Bin 3385 -> 6648 bytes .../main/res/drawable-hdpi/splash_icon.png | Bin 3233 -> 12598 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 1853 -> 3978 bytes .../main/res/drawable-mdpi/splash_icon.png | Bin 2175 -> 7544 bytes .../drawable-xhdpi/ic_launcher_foreground.png | Bin 3644 -> 9192 bytes .../main/res/drawable-xhdpi/splash_icon.png | Bin 4290 -> 18015 bytes .../ic_launcher_foreground.png | Bin 6010 -> 14326 bytes .../main/res/drawable-xxhdpi/splash_icon.png | Bin 6390 -> 28760 bytes .../ic_launcher_foreground.png | Bin 8208 -> 19991 bytes .../main/res/drawable-xxxhdpi/splash_icon.png | Bin 8044 -> 40117 bytes .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 1374 -> 4722 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 772 -> 2657 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 1539 -> 6823 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 2499 -> 11595 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 3058 -> 16407 bytes android/app/src/main/res/values/colors.xml | 2 +- assets/icon/fruitshorts.png | Bin 27043 -> 59048 bytes assets/icon/splash_mark.png | Bin 6007 -> 65801 bytes .../Icon-App-1024x1024@1x.png | Bin 23750 -> 59048 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 295 -> 783 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 594 -> 2035 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 939 -> 3699 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 436 -> 1321 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 902 -> 3339 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 1388 -> 6092 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 594 -> 2035 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 1151 -> 5403 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 1922 -> 9038 bytes .../AppIcon.appiconset/Icon-App-50x50@1x.png | Bin 742 -> 2817 bytes .../AppIcon.appiconset/Icon-App-50x50@2x.png | Bin 1611 -> 7217 bytes .../AppIcon.appiconset/Icon-App-57x57@1x.png | Bin 867 -> 3366 bytes .../AppIcon.appiconset/Icon-App-57x57@2x.png | Bin 1800 -> 8581 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 1922 -> 9038 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 2943 -> 15129 bytes .../AppIcon.appiconset/Icon-App-72x72@1x.png | Bin 1241 -> 4722 bytes .../AppIcon.appiconset/Icon-App-72x72@2x.png | Bin 2266 -> 11595 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 1221 -> 5013 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 2777 -> 12304 bytes .../Icon-App-83.5x83.5@2x.png | Bin 2887 -> 13834 bytes 39 files changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index a88dd380b609e344184adf3b1532346b2735c172..3943108faa8fd01c15661e696874c6e9a9d64cd3 100644 GIT binary patch literal 6648 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 literal 3385 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 Rn*1fAFw!>%Rp~lE{|{kEe^LMd diff --git a/android/app/src/main/res/drawable-hdpi/splash_icon.png b/android/app/src/main/res/drawable-hdpi/splash_icon.png index f2367855658acf6bdbef06a952482120598d42b2..df52602512eba4cdca6039185d3408fe0172f7f1 100644 GIT binary patch literal 12598 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 YKk2!t_4F?WE&u=k07*qoM6N<$g3Qy+?*IS* delta 3230 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 literal 1853 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 diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index 83b9072946e0dc6f23460b7910371dae9a637768..f1793c287ac88472ea6e09d3f9805c1db7eb3b99 100644 GIT binary patch literal 9192 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 literal 3644 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 Ha*X~DVL?yH diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 871b1435deabb23a6e3a3f74c768c031eb98cd74..a4d51ad412315acf16ed39bf1ba67e2fa24bee29 100644 GIT binary patch literal 14326 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 NHGQ)yrAWu8{|lRoMj`+J diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index 98f3b9bd1de845168a332b966f6b06c7ade6d75d..41f5445eb4aa3e0d2d753a0caff51b4a72ce729d 100644 GIT binary patch literal 19991 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 literal 8208 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 diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash_icon.png b/android/app/src/main/res/drawable-xxxhdpi/splash_icon.png index ab8ca49a14edf998e371d95d3ae17e30c736fc37..7ab977c47022776921c19144c2eab1cf72416bae 100644 GIT binary patch literal 40117 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 literal 8044 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 QDLP11QB$Ep&LZSL08=wpZvX%Q diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 0d3f7bc72a2cb581ba30cf9d862232c17b6f3c99..e42ec61f9c747f5cb546575dba55a93555aa8720 100644 GIT binary patch literal 4722 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 AkN^Mx delta 1366 zcmV-c1*!V-B;E>;8Gi-<0033(vqt~`1tm#DK~#90?V4RoQ&#}T|EIKI2~?y|J_eYf zF566GmBg5gM-y4t!bmn_Vx}b9gJyg4WqWcYzWCxBG7&`PKmiFD&4~N(!D%vBAdYO3 zjE#jrTPT!5p&yjqdzO1Gq`iEc%G%o&&j0Nk{^y?4Kj-}JxqtT_?JwrHMTCeD5K2Ke zEAKXs7F=732gDE$h#{@C&XkqPy4~#2CK?nI&BK=%Lp&gcID;4-&LD<|FENIAKn(GK z7~+s-csQgP9=^mF;sG(l8N~2#1~EK*i7}*lKqNSUDP=hmL#hR&&B{W-Cud%Ag#^L##{B*kd~jn=1DItMNU-}?7h8Yy5lYa3ky-{E7~4a zAYJt7XL2}{hA*rXy`JQsK21&4)tK(;#8gKI5j@`A25Abzv1400G&YnZg`yasC z(t_E(??DA|%Qk9B6$YiGr$^*VH5#%1;zi`0It5E}6MyEPJc;?(Sc*A=LKhVwWCIT& z%Cf$s1P8vZgJ*OUmgZ(G^#2-W+i?t9tx_^b`qiOz%5wUlLwMuL6|(sJIh|~5#L~zM zY4kgdLH}PFWo3A?z8>>WpJKA{I(!q8s`Z@|0j(;lq5^x5AIHpt2e94#7AwyAnD-gi zfV|`5@PGdEH};h5NBY5o5PUv)0|_c3(`<%DzaH)yoerj&8st<~u5Jw=dc83lV_YCD zVP>-78m7Ct5M&efy;}kI=wDcwnUOsy1_KV%)xq$Nc|(v(cgDhVwJ?2H16zAL?7cmN z`u$NEb67y}fb>FtKc+f6gHV<{1kPvA!rBBDTz~lY@?{+9=p@i+HU+s9WbuUyB=6&o zsHLe1jz^DzeI+rZmxh0rx@Y=U91bj7tw<{>VtRGP9K8PJS4exU2-cg;Q5tES7^K^m z9hs33X!q`o_`1@>DmZfnj=nzltk&%hFhp@-kn!Y6OyBE*-)>)PN3>edKp^Zoxz*K3 z$$!k;kU{JF3f2*V%%f&3+pNjUASnpxjupm=Kmba12un+0?XUrZHm)Pd%^=5*KgbXc zod?p-Q_hRIpMFO1=U=?iGLy6@GcG!cOF#jaD?%rgXVI$!WbeL*<%x-Hk4>q#1Y|f` z4qICr^G=p(%0pUVVMNyyTrOB!Z^7Qv6Mv;qzEoTwZ7467H+i=2Zi9%TT$g>~1l%Jd zShiT?vd(Bk>7`2`8V!_5hC*6!yX6h%R97bvJ)WqHIkX(Nr}@I#TI5z$5zp8d-BVmF z&lD;M#Qo zvJDjkdADr2)dIhLMy^|b(~q8#=V_i0xCRC=dHoxB#>No-zmJ^~@{OX>vp6(JtgWqB z93GbbF26KW_O>hEC`zwFCK?)uYjBXhK()5q#N6Y@!B4O;gbYKf z1SEL95q;TMaXRI_% zTYDSv&&=}kL6QQ_IgDSuiqPXP@#ae;QvEKLEa!q&#m^`*Jp7Cz!;_F3#ZN>M4~QWi z5JMc&3=fAi!^4*tLp&gcID;4-&LD<|FENIAKn(GK7~+s-csQgP9=^mF;sG(l8N~4X Y2UY=)`J}M58vpeWB@7x*3nFNXIduzJ7>vnzh zo$s7F)q}Kn5w=o@$knzOK8)6ei#D2DhBO!=AOt`Ffhg=(3Umjes}KNO4FDb(y(K^Z z77^D(U<{B!GW;|?FKYsD9|Qql7#N?UtBa3CQ~;ta0iqHU@u&=iS;?UoU0|}e+<3-~ zY8Vr80}zR8OAkZjhkE?8Ps)%f>XMiemgNt4+=2|WO?zU2xLX9;S?v=uL1X>_uI7*_`QxOqI0;6qK(5~4A%6SypTRSWoHkJcqUCf0; z^LDEq{^ROOVB}wxqiswOR=~OEJMpXs#P;F^W7y}((nG*<2KIKVYj?@UgAqV#{g|6s zj|x3oER|)f{5Sy_$|EEL%3a%r=-g<wr;29!srDlBV48eu3GbB>4o%T~E=xMj3Y6avP^1om+W z@v^`X$3b!26yvQ2WSF6nI_D3=U5etCt|~u8?6Jv)wUl8Hc%72x``50Rqyh3P!NhUD zr8$I_erBD@E)+HbIBk;PznY7(F+I-h zQ#WvlrqDGIh)_}ovX`^@@6nXX{T^#euLnz~QK#VffKmn9yW1a%-kgcvI0tv_g(Sg+ z1(Z^po2~caZMgFx*n1y8OM39( zX$V4`H(+F*E^@|bQw~GmNqJ?_+_RH2HDlvo&OcdWY>{fY$P7=ufA`8@0(v(d+xd2*<`FB7AgJg0S1Q2w;p!Fm%S(_XOp@p2LdY~`SuWo7 zfp>%Ap;0AmL?ZN)cQ5Rp3%_+`adBy`>&azamnBV1-QExOy!l#hacSE(TfY#Z&X2r$ z7Nq2XYnpW`1jj6KDa?`j8IRzl?MDL!WQh8|ljDA>Ly|F7!!i8&l zr^o;H;N5)}FbKraU9Tp7XiQj%XhayQz2h?MiJ2Zq`!G$^3h1sH4wlOF4hd?0&F*10 zE!h9v(^bIk+?QDp49;9UmIm2!xEADty@(YG6}a(={$Uh9BD#XVTK z(UxO3$&@`odOaY5RC}arp6G1bF{c}cjEk_gNZ-ietfU;VnpfsmLdH>C!LT*C+FQ-~ zgFuFx8EyoSZ3xh4XsSKiS83W=5MGq))UFY3tfF_hH6Cd)L_>xbZ`amJQJPBd!4$!7 z6cASTWjtt^r$S9=nW|*wYIx^9n_!TT9@`F)lqRS)700@2lYL$RdSc>`Xx@RsqpX{q z|HoLjFT*h#5jSB7jUL%&EIP<0MISX0kMBwApurQ1QTM9W=&lQ>wT}#`tw^ zqLA63M<#1d9}f0^4QrF!hqG4<_dX7%{s?dV0c3C!hDt|eFQ~$HA&fGGH|A-)ZS!TD zEOV;ic7v{o<-W(}AxK7LDabuv#&8_60fY^RI~KYJ4?Lyfj(6$>2%Gr|Gs@5=SY67B z2s%`0Dt+{f3fl>$i_1V(G&2U&N~zvEGFVv1CTul^FE7Cic0-a*G5 zNIT}piSFsYHdRu-6B+_!_fW&EtSbm+Wywfk8e>`9=M_{4oyNv!5&O<$I*pks>62EH zba!E8^6&vrF?w}axCF2K3eLU=PyP`0JPaxcT9b>->H3Y6qCP&lk>|B+17?OShXFv8 zWl60b)SHeHwu@G_saVUrXVgKxX_WFp-zfANEw?WNKZ52q_|MC5>IL}5PwDeN;DF?h zrTt!1Z?W)47+Qa6JxPQHNG;5&Ml&Je?81tgMZ>P-MBV524UAG79rhPyb==EeBjm`s!}8@(KReaciYW*Nbwq^!0zt5NxUZ-vRnB8Xph~t?b`Q P00000NkvXXu0mjf*AD|3 delta 759 zcmV(7Q(+j#zsIf7*_bP9PL!of zBvh6V7~a;xi!$nB-5PaMmKSwV_!ne%byJxdNb8p+v>S6bXo70Ch$jRtiOx}hvB70-{fwxY~p z!TQK2tPTty?)Q_(GcS?_H1!SX-X!N+1!LuOHwB0iwgi8W%dTE%6x?r;DcM`Z4zb|IC%95wgu z!(hDvckgoqCMG1$Nn9Z8a$#fiGY%S$L0wymjW1*3vA(TMtPKIyWI|o{Lxg5$vGSrH z;h7nLVfGY{s6f=?!S}m&a<214Ab`sD_N+0j*^Cp99)BY+ISF^qGenk_3XVxsAncmU zTcjc9x18e)!{AU$3$#s5n7?@gk>%xrViFb5nopvHWx;K4r?Lb1ov${!=H|cND|D=* z=P!u=L?I9r;FC%5ml?=>a^j-@&_4H22h^8P3N9Lj-!W2fO!7-etFdSz;$p!ui3+I7%TfR2DK^GN!B$s;H60h*sBnDv%-O7Kg~T@(=Hc$? zLD=csb4=p<09#Xos%zKN=T#=tAJ+(-#@*kCpX1|_;}vx$j(WZDyt3^)O8j2z5-5s5 zA{d0nW`ocE5qu&+bZ*H4d@_GR$w&mgp&@wRy>LZxb5m-loJbZ3&&?qo2#8OdDY$qX z>%+t1a{2FHXoQyeqpJ(X&Q7Q*DiEBS5+9jkYhE%L?gEh-5Zd8F=WP;G6^#56-um002ovPDHLkV1o1dZ4dwe diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index d7511150514260408efeb2201c827d54b8c9447c..05937b5b114b4e166a4761a3413ece3f2b341f00 100644 GIT binary patch literal 6823 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 Wlv-h2PboivI{;;awx(b4PWl%Q4Zei{ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index bf85ac7d7862a31cdf36ec3a9a9049a58511db15..ec70412515cf6824a51c89710d40e49c5adb37da 100644 GIT binary patch literal 11595 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 JPDHLkV1g1Cb|L@( literal 2499 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 D&V7bc diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 7dd208f35fe03b8f27e83522a63fa2cd1de3ea27..d0165fad11ef5367358ff61b68ea113deb7775c0 100644 GIT binary patch literal 16407 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 literal 3058 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 Mf6fVBXXBspAGXxA>Hq)$ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 3462ee2..8e90441 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -3,5 +3,5 @@ #FF100C0A - #C4322A + #FFF1E5 \ No newline at end of file diff --git a/assets/icon/fruitshorts.png b/assets/icon/fruitshorts.png index bed17d0ffcff13583bfc55ebdabdf3346aeca899..eaa859c940966fee7e330f8347297722fe2153f1 100644 GIT binary patch literal 59048 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�xWrf6Z@;^86jsLm01zA zGR&;2tv1K2*WKgPDf>l9ORJwN;ugmFSIm(vp-iKW- z|6L*I{Gu)ePjZ5uRV7Si_1pI^jM3dY$43xAhS66=g!^*tGw{8Qrf71`z~qrHb> zU^Jl&s+xX3KG`+!esLGduS+(a&EM1C$e-f@fkN23Q_YIA~ zZrp^Uv>S$pbWYBwpR~z}`inzTuIroI{zEB&bLL^e9{8n!l1RG2eS-+RX^3z85-L@WCqimLP_qH%4&Zi zqwBwE9E`TXeD8m}#oZQAjPK$;h=&QR&B*9<~@Gz_tT+r@LX zo7woJ%dLJ{!r0K!{B8#}3xMrCJ|;hypGozMx-LLW6o@Hn)xkeLv5$5Oo=EOY$Fm30 zv1_u`6<$P>VK1*_He&obUcy0!gbny5b?+TBQXGRMAwB$!5VoAjz!+B5TMjA#Paj2ug{G!xah-Q})o-&&02!CadeXG`b&u<1E zBg>&$*qPWLA_g7^qX&ckIUf@U|Hyim#F~%0@B?hdQ@JOh`B0EYm3*SpMfRPn3Mh3X z{`|a{t67|gck7hBisU3KY+t4I(SW$Js`;N5je8u%`Nv|e20^;TY4+KFL`%=#JJx`Z zbYmWAmGcnFze#_uK-a(3(;v4pz%Df3-Ru>t%GYDCP$j&MCws

Bbj%sk-6a2i}{B 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 literal 27043 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 Q33cF#w!T&|-sbUt0nW?M#sB~S diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 3858b4378f871f97a9773e83a03f91d3e755c83f..eaa859c940966fee7e330f8347297722fe2153f1 100644 GIT binary patch literal 59048 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�xWrf6Z@;^86jsLm01zA zGR&;2tv1K2*WKgPDf>l9ORJwN;ugmFSIm(vp-iKW- z|6L*I{Gu)ePjZ5uRV7Si_1pI^jM3dY$43xAhS66=g!^*tGw{8Qrf71`z~qrHb> zU^Jl&s+xX3KG`+!esLGduS+(a&EM1C$e-f@fkN23Q_YIA~ zZrp^Uv>S$pbWYBwpR~z}`inzTuIroI{zEB&bLL^e9{8n!l1RG2eS-+RX^3z85-L@WCqimLP_qH%4&Zi zqwBwE9E`TXeD8m}#oZQAjPK$;h=&QR&B*9<~@Gz_tT+r@LX zo7woJ%dLJ{!r0K!{B8#}3xMrCJ|;hypGozMx-LLW6o@Hn)xkeLv5$5Oo=EOY$Fm30 zv1_u`6<$P>VK1*_He&obUcy0!gbny5b?+TBQXGRMAwB$!5VoAjz!+B5TMjA#Paj2ug{G!xah-Q})o-&&02!CadeXG`b&u<1E zBg>&$*qPWLA_g7^qX&ckIUf@U|Hyim#F~%0@B?hdQ@JOh`B0EYm3*SpMfRPn3Mh3X z{`|a{t67|gck7hBisU3KY+t4I(SW$Js`;N5je8u%`Nv|e20^;TY4+KFL`%=#JJx`Z zbYmWAmGcnFze#_uK-a(3(;v4pz%Df3-Ru>t%GYDCP$j&MCws

Bbj%sk-6a2i}{B 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 literal 23750 zcmeEuc_5T++xI0xB#Mw^Dnv;tiinXG+1(^tmaAY7YQFdU2Z4+-Qb-;u4Y!nFym37Z8M9_FQa!=#7>fmj)W>f= zJwmDe!fAYGo*Aqwjn$?G0Y0Jnt*h_{#BuERM<5UUfm36LKU9ywJAu-8;GIDF>`d^7 zFf<(-?kKy^f{H(PGP|h?v?irv+GEeUs>iJ7CFiaL9>8(XkTk`rhdQ9So)yHF3rb* z`6ZgpDHGb3lx6hvldQv`7uVj_)omK#L|zJgf9oNfj=Fu($)ThP-{|639WC;ev5_B9 zS2yNOJVct4_47*}yIX=M>O9xKxk?RTT!{iaWv^wkPI> z+cD%$SGP;D(V7e@-of(GCF-D)y`dcBJEu9d6K+u?ux5Tk z0^QV&^?Cm)#(+cnEluvAGV3m7rO%Z|A=N{`2}0S>GQ^I_B(-CY+AkO~b~<_83;8kF zWgLCxb4p3|A|tI?G6sh-3;eUI^xJOEQ-zf$C|?c-AV>O2Y?@uDckgblyTqXZxwU%& zK`wQBEMjBq?9YD>kx40jFp$|v6x+f0x#&;1O`}Gze>w2qNyGm=N&8$F$B z3SlPc67b=Ddp~M1TnE;XFUAFSm0S4Na4v3}El5{*p`8{VK0M>$A-MdJD$aTcBoiH= z`_(HmRnaYiFFvm^w)0!CD-tRvN6OZHj%*&v$e8}K?bf%OVqlSa3+)x9$l`*EtR`W> zv-i%O2h2Sf(D~^&9+hKlbo#G1dKPQmSb>xatwMsY)TfuCo_>$z-=^+dxWFZKTer@} zv_B@B;E>`Pd4k`ac+6g#2TJn*2+?sY9o58HlRe_DM>D2>nZ8+qw-WqvO<@sx|EUX_ z^{h^bK6|I5qsf;3LVm>&%jy%g=u{73QGOdUNhs|WJix)bg1Rb&>Y`jyh@`cFPIFRJ zl!0K@&QO@Qpyo!y!)vwnq2hj6d;zad{RXAmNMA?hhLSYiwQ=WdEUhaCpD|;Crte_tYW+64gS`+S7gR?rrLtlyj*O8o|Xx$e4-sGct#aQZPj_96Pn>4`qZx#sM^`v(v9#1IWD zN{fKEbTnW5JsVRrgWDdOeyKE%Ob+wS!-^g#3JMA==t7^*@D9q_9+}&>SX<${Jl-L# z6Cf>pThaORgf{1HNSlX)_JGwzw=$2>(C{_sl0czFPEaFZ7H@lkmU>|^?r3AJ+|g(b zc{#cFCYL-11cv$x$s=2()WZ+uGyw#EIRe!4falYpL(3A8T-@(nI%y6irwZudCv`d7 zr|r%dpUb%WqmjFx;9=N2r9=90JC(;*>{Av95mx{^3WuM*%D%LkPF6?KU1g&U#)cU| zQs6vL3p_=F82u4qujU|w&z=}hn!#_vEz&POSX{*7uATx zZ5T>7E@ZJ9zwu+Js$6beWi=E8agkt;8WS|V5BY&9DQ0{u_1gFwJWVNe;%2H|D1|p# z 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

?%dwK4Iwp$T)DixpMMvmC<5p*FmYA;iCDj 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