From ef29fc5f15d17656fa3827044bc66b2833641b9f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 12:38:47 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(app):=20=E5=AE=8C=E5=96=84=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=8D=8F=E8=AE=AE=E6=90=9C=E7=B4=A2=E4=B8=8E=E7=A9=BA?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/models/play_ticket.dart | 68 ++++- lib/core/data/models/watch_progress.dart | 8 + lib/core/data/remote/api_client.dart | 3 +- lib/core/data/remote/auth_api.dart | 22 +- lib/core/data/remote/library_api.dart | 5 + .../repositories/favorite_repository.dart | 15 + lib/core/data/services/auth_controller.dart | 64 +++-- .../data/services/favorite_controller.dart | 31 +++ lib/core/data/services/playback_session.dart | 122 ++++++++- lib/core/formatting/error_text.dart | 8 + lib/core/legal/legal_article_view.dart | 127 +++++++++ lib/core/legal/legal_html.dart | 36 +++ lib/core/legal/legal_opener.dart | 12 + lib/core/legal/legal_page.dart | 27 ++ lib/core/legal/legal_web_page.dart | 96 +++++++ lib/core/theme/app_colors.dart | 1 + lib/core/theme/app_dimens.dart | 2 +- lib/core/widgets/empty_state.dart | 7 +- lib/core/widgets/subpage_header.dart | 12 +- lib/l10n/app_en.arb | 23 +- lib/l10n/app_id.arb | 23 +- lib/l10n/app_localizations.dart | 112 +++++++- lib/l10n/app_localizations_en.dart | 61 ++++- lib/l10n/app_localizations_id.dart | 60 +++- lib/l10n/app_localizations_zh.dart | 60 +++- lib/l10n/app_zh.arb | 23 +- .../account/account_deactivate_reason.dart | 106 ++++++++ lib/pages/account/account_entry_card.dart | 211 +++++++------- lib/pages/auth/auth_form_widgets.dart | 1 - lib/pages/auth/auth_legal_links.dart | 75 +++++ lib/pages/auth/auth_view.dart | 51 +--- lib/pages/discover/search_page.dart | 87 ++++-- lib/pages/following/following_edit_bar.dart | 228 ++++++++++++++++ .../following/following_favorites_grid.dart | 118 ++++++++ lib/pages/following/following_grid_card.dart | 47 +++- lib/pages/following/following_page.dart | 257 ++++++++---------- lib/pages/home/home_view.dart | 22 +- lib/pages/home/widgets/home_feed_grid.dart | 15 +- lib/pages/mine/mine_types.dart | 2 - lib/pages/mine/mine_view.dart | 83 +----- lib/pages/mine/widgets/mine_header.dart | 2 +- lib/pages/mine/widgets/mine_menu.dart | 9 +- lib/pages/player/player_logic.dart | 86 +++++- lib/pages/player/player_view.dart | 58 +++- lib/pages/player/player_view_actions.dart | 15 + .../player/widgets/drama_comments_sheet.dart | 13 +- .../player/widgets/home_feed_overlay.dart | 91 +++++-- .../widgets/home_feed_overlay_parts.dart | 15 +- .../widgets/home_feed_recommend_panel.dart | 24 +- .../widgets/player_double_tap_hearts.dart | 88 +++--- .../player/widgets/player_more_sheet.dart | 45 ++- lib/pages/player/widgets/player_overlay.dart | 11 +- .../player/widgets/player_overlay_models.dart | 1 - .../player/widgets/player_video_surface.dart | 35 ++- .../player/widgets/seek_progress_bar.dart | 8 +- lib/pages/recommend/recommend_page.dart | 63 +++-- .../widgets/all_dramas_filter_grid.dart | 8 +- .../recommend/widgets/recommend_top_nav.dart | 46 +++- lib/pages/settings/legal_links_card.dart | 104 +++++++ lib/pages/settings/settings_page.dart | 34 +-- pubspec.yaml | 1 + test/account/account_pages_test.dart | 18 ++ test/account/mine_account_entry_test.dart | 16 ++ test/auth/auth_controller_test.dart | 178 +++++++----- test/auth/auth_page_test.dart | 3 + test/auth/auth_visual_test.dart | 27 ++ test/auth/phone_auth_sheet_test.dart | 1 + test/comment/drama_comments_sheet_test.dart | 5 + test/core/legal_page_test.dart | 27 ++ test/core/legal_web_page_test.dart | 33 +++ test/core/subpage_header_test.dart | 4 +- test/following/following_page_test.dart | 52 +++- test/library/favorite_controller_test.dart | 3 + test/mine/mine_preview_layout_test.dart | 28 +- test/mine/settings_page_test.dart | 3 +- test/notice/notices_entry_test.dart | 6 +- test/playback/play_ticket_quality_test.dart | 20 +- test/playback/watch_history_test.dart | 1 + test/player/double_tap_like_test.dart | 4 +- .../player/player_double_tap_hearts_test.dart | 29 ++ test/player/player_more_sheet_test.dart | 79 +++++- test/player/player_video_surface_test.dart | 45 +++ test/player/seek_scrub_visibility_test.dart | 29 +- test/settings/legal_links_card_test.dart | 37 +++ test/support/widget_player.dart | 8 +- 85 files changed, 2976 insertions(+), 768 deletions(-) create mode 100644 lib/core/legal/legal_article_view.dart create mode 100644 lib/core/legal/legal_html.dart create mode 100644 lib/core/legal/legal_opener.dart create mode 100644 lib/core/legal/legal_page.dart create mode 100644 lib/core/legal/legal_web_page.dart create mode 100644 lib/pages/account/account_deactivate_reason.dart create mode 100644 lib/pages/auth/auth_legal_links.dart create mode 100644 lib/pages/following/following_edit_bar.dart create mode 100644 lib/pages/following/following_favorites_grid.dart create mode 100644 lib/pages/settings/legal_links_card.dart create mode 100644 test/core/legal_page_test.dart create mode 100644 test/core/legal_web_page_test.dart create mode 100644 test/player/player_double_tap_hearts_test.dart create mode 100644 test/settings/legal_links_card_test.dart diff --git a/lib/core/data/models/play_ticket.dart b/lib/core/data/models/play_ticket.dart index 2a5ad1d..0094ba4 100644 --- a/lib/core/data/models/play_ticket.dart +++ b/lib/core/data/models/play_ticket.dart @@ -14,6 +14,7 @@ class PlayTicket { this.unlockCoin = 0, this.previewSeconds = 0, this.qualities = const [], + this.tracks = const [], this.thumbnailsUrl, this.likeCount, this.commentCount, @@ -39,8 +40,11 @@ class PlayTicket { /// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。 final int previewSeconds; - /// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。 + /// 后端下发的清晰度档位(如 AUTO / 720p / 1080p)。 final List qualities; + + /// 各档签名地址。AUTO 是主列表,点选其它档位时换成对应地址。 + final List tracks; final String? thumbnailsUrl; final int? likeCount; final int? commentCount; @@ -64,6 +68,7 @@ class PlayTicket { .whereType() .where((quality) => quality.isNotEmpty) .toList(), + tracks: _tracks(json['tracks'], json['playUrl'] as String? ?? ''), thumbnailsUrl: _optionalString(json['thumbnailsUrl']), likeCount: json['likeCount'] == null ? null @@ -75,6 +80,67 @@ class PlayTicket { } } +List _tracks(dynamic raw, String playUrl) { + final tracks = []; + if (raw is List) { + for (final item in raw) { + if (item is! Map) continue; + final quality = item['quality'] as String? ?? ''; + final url = item['playUrl'] as String? ?? ''; + if (quality.isEmpty || url.isEmpty) continue; + tracks.add(PlayQualityTrack(quality: quality, playUrl: url)); + } + } + if (tracks.isEmpty && playUrl.isNotEmpty) { + tracks.add(PlayQualityTrack(quality: 'AUTO', playUrl: playUrl)); + } + return tracks; +} + +/// 按票据里的分轨地址取播放地址;没有分轨时,从主列表路径推出同目录的单档地址。 +String? qualityPlayUrl({ + required String quality, + required List tracks, + required String currentUrl, +}) { + final wanted = quality.toUpperCase(); + for (final track in tracks) { + if (track.quality.toUpperCase() == wanted && track.playUrl.isNotEmpty) { + return track.playUrl; + } + } + return siblingRenditionUrl(currentUrl, wanted); +} + +/// `.../master.m3u8` 与 `.../720p/index.m3u8` 互相换算,查询参数原样保留。 +String? siblingRenditionUrl(String currentUrl, String quality) { + final uri = Uri.tryParse(currentUrl); + if (uri == null || uri.path.isEmpty) return null; + final path = uri.path; + const masterSuffix = '/master.m3u8'; + String? root; + if (path.endsWith(masterSuffix)) { + root = path.substring(0, path.length - masterSuffix.length); + } else { + final match = RegExp(r'/(\d+p)/index\.m3u8$').firstMatch(path); + if (match == null) return null; + root = path.substring(0, match.start); + } + final next = quality.toUpperCase() == 'AUTO' + ? '$root$masterSuffix' + : '$root/${quality.toLowerCase()}/index.m3u8'; + if (next == path) return currentUrl; + return uri.replace(path: next).toString(); +} + +/// 一档清晰度的签名播放地址。 +class PlayQualityTrack { + const PlayQualityTrack({required this.quality, required this.playUrl}); + + final String quality; + final String playUrl; +} + String? _optionalString(dynamic value) { final text = value is String ? value.trim() : ''; return text.isEmpty ? null : text; diff --git a/lib/core/data/models/watch_progress.dart b/lib/core/data/models/watch_progress.dart index 2ba70e3..cc61615 100644 --- a/lib/core/data/models/watch_progress.dart +++ b/lib/core/data/models/watch_progress.dart @@ -18,6 +18,7 @@ class WatchProgress { this.totalEpisodes, this.finished = false, this.playCount = 0, + this.viewCount, this.updatedAt, }); @@ -31,6 +32,9 @@ class WatchProgress { final Duration duration; final bool finished; final int playCount; + + /// 短剧热度。历史列表没下发时为空,不能拿用户自己的 playCount 冒充。 + final int? viewCount; final DateTime? updatedAt; bool get hasPosition => position > Duration.zero; @@ -57,6 +61,9 @@ class WatchProgress { duration: Duration(milliseconds: jsonToInt(json['durationMs'])), finished: json['finished'] == true, playCount: jsonToInt(json['playCount']), + viewCount: json['viewCount'] == null + ? null + : jsonToInt(json['viewCount']), updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''), ); } @@ -72,6 +79,7 @@ class WatchProgress { 'durationMs': duration.inMilliseconds, 'finished': finished, 'playCount': playCount, + if (viewCount != null) 'viewCount': viewCount, if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(), }; } diff --git a/lib/core/data/remote/api_client.dart b/lib/core/data/remote/api_client.dart index 6568177..e37543e 100644 --- a/lib/core/data/remote/api_client.dart +++ b/lib/core/data/remote/api_client.dart @@ -148,7 +148,8 @@ class ApiClient { skipAuthRetry: skipAuthRetry, ); } - onUnauthorized?.call(); + final token = accessTokenReader?.call(); + if (token == null || token.isEmpty) onUnauthorized?.call(); throw ApiException(401, cause.message ?? '登录已过期'); } if (cause is ApiException) { diff --git a/lib/core/data/remote/auth_api.dart b/lib/core/data/remote/auth_api.dart index b54855d..a6841d3 100644 --- a/lib/core/data/remote/auth_api.dart +++ b/lib/core/data/remote/auth_api.dart @@ -33,6 +33,7 @@ class AuthApi { required String code, required String deviceId, String platform = '', + String osVersion = '', String appVersion = '', }) async { final data = await _client.post( @@ -43,6 +44,7 @@ class AuthApi { 'code': code, 'deviceId': deviceId, 'platform': platform, + 'osVersion': osVersion, 'appVersion': appVersion, }, ); @@ -55,6 +57,7 @@ class AuthApi { required String code, required String deviceId, String platform = '', + String osVersion = '', String appVersion = '', }) async { final data = await _client.post( @@ -65,6 +68,7 @@ class AuthApi { 'code': code, 'deviceId': deviceId, 'platform': platform, + 'osVersion': osVersion, 'appVersion': appVersion, }, ); @@ -76,6 +80,7 @@ class AuthApi { required String password, required String deviceId, String platform = '', + String osVersion = '', String appVersion = '', }) async { final data = await _client.post( @@ -87,6 +92,7 @@ class AuthApi { 'countryCode': 'ID', 'deviceId': deviceId, 'platform': platform, + 'osVersion': osVersion, 'appVersion': appVersion, }, ); @@ -98,6 +104,7 @@ class AuthApi { required String password, required String deviceId, String platform = '', + String osVersion = '', String appVersion = '', }) async { final data = await _client.post( @@ -108,18 +115,25 @@ class AuthApi { 'password': password, 'deviceId': deviceId, 'platform': platform, + 'osVersion': osVersion, 'appVersion': appVersion, }, ); return AuthSession.fromJson((data as Map).cast()); } - Future refresh(String refreshToken) async { + Future refresh( + String refreshToken, { + String platform = '', + String osVersion = '', + }) async { final data = await _client.post( '/app/auth/refresh', - body: {'refreshToken': refreshToken}, - // 刷新请求本身不带旧令牌重试,失败就是失败 - skipAuthRetry: true, + body: { + 'refreshToken': refreshToken, + 'platform': platform, + 'osVersion': osVersion, + }, ); return AuthSession.fromJson((data as Map).cast()); } diff --git a/lib/core/data/remote/library_api.dart b/lib/core/data/remote/library_api.dart index 73588d8..9de0b10 100644 --- a/lib/core/data/remote/library_api.dart +++ b/lib/core/data/remote/library_api.dart @@ -36,4 +36,9 @@ class LibraryApi { Future removeFavorite(String dramaId) async { await _client.delete('/app/favorites/$dramaId'); } + + /// 一次取消多部短剧,请求体为 dramaIds 数组。 + Future removeFavorites(List dramaIds) async { + await _client.post('/app/favorites/remove', body: {'dramaIds': dramaIds}); + } } diff --git a/lib/core/data/repositories/favorite_repository.dart b/lib/core/data/repositories/favorite_repository.dart index 402da00..dd7d917 100644 --- a/lib/core/data/repositories/favorite_repository.dart +++ b/lib/core/data/repositories/favorite_repository.dart @@ -13,6 +13,9 @@ abstract interface class FavoriteRepository { Future> page({int pageIndex = 1, int pageSize = 20}); Future remove(String dramaId); + + /// 一次取消多部短剧。 + Future removeMany(List dramaIds); } class HttpFavoriteRepository implements FavoriteRepository { @@ -36,6 +39,10 @@ class HttpFavoriteRepository implements FavoriteRepository { @override Future remove(String dramaId) => _api.removeFavorite(dramaId); + + @override + Future removeMany(List dramaIds) => + _api.removeFavorites(dramaIds); } /// 内存实现:单测与 widget 测试注入用。 @@ -51,6 +58,7 @@ class InMemoryFavoriteRepository implements FavoriteRepository { bool failRemove = false; int toggleCalls = 0; int pageCalls = 0; + int removeManyCalls = 0; @override Future toggle(String dramaId) async { @@ -80,4 +88,11 @@ class InMemoryFavoriteRepository implements FavoriteRepository { if (failRemove) throw StateError('favorite unavailable'); _saved.remove(dramaId); } + + @override + Future removeMany(List dramaIds) async { + removeManyCalls += 1; + if (failRemove) throw StateError('favorite unavailable'); + _saved.removeAll(dramaIds); + } } diff --git a/lib/core/data/services/auth_controller.dart b/lib/core/data/services/auth_controller.dart index 2ce72dd..7364098 100644 --- a/lib/core/data/services/auth_controller.dart +++ b/lib/core/data/services/auth_controller.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:flutter/foundation.dart'; import '../models/auth_session.dart'; @@ -9,7 +11,7 @@ import 'auth_session_store.dart'; /// /// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新; /// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 —— -/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。 +/// 401 → 刷新 → 重试。令牌被拒绝才清会话,服务中断时保持登录。 class AuthController extends ChangeNotifier { AuthController(this._api, this._store); @@ -19,6 +21,10 @@ class AuthController extends ChangeNotifier { AuthSession? _session; String _deviceId = ''; bool _loaded = false; + Future? _refreshing; + + /// 登录已过期、账号停用才清本地会话。服务不可达不能当成退出登录。 + static const Set _rejectedSessionCodes = {401, 100001}; bool get isLoaded => _loaded; bool get isSignedIn => _session != null; @@ -43,6 +49,7 @@ class AuthController extends ChangeNotifier { password: password, deviceId: await _store.deviceId(), platform: _platform, + osVersion: _osVersion, ); await _apply(session); } @@ -53,14 +60,12 @@ class AuthController extends ChangeNotifier { password: password, deviceId: await _store.deviceId(), platform: _platform, + osVersion: _osVersion, ); await _apply(session); } - Future sendOtp({ - required String target, - String scene = 'LOGIN', - }) async { + Future sendOtp({required String target, String scene = 'LOGIN'}) async { await _api.sendOtp( target: target, scene: scene, @@ -77,6 +82,7 @@ class AuthController extends ChangeNotifier { code: code, deviceId: await _store.deviceId(), platform: _platform, + osVersion: _osVersion, ); await _apply(session); } @@ -90,10 +96,17 @@ class AuthController extends ChangeNotifier { code: code, deviceId: await _store.deviceId(), platform: _platform, + osVersion: _osVersion, ); await _apply(session); } + Future clear() async { + _session = null; + await _store.clear(); + notifyListeners(); + } + Future signOut() async { final refreshToken = _session?.refreshToken ?? ''; _session = null; @@ -107,29 +120,43 @@ class AuthController extends ChangeNotifier { } } - /// 供 [ApiClient] 调用:刷新令牌并落盘。失败时清本地会话。 - Future refresh() async { + /// 供 [ApiClient] 调用:刷新令牌并落盘。 + /// + /// 同一时刻只刷新一次,避免重启期间并发 401 把新令牌轮换掉。 + /// 网络失败或 5xx 保留本地会话;只有令牌被拒绝才退出登录。 + Future refresh() { + final current = _refreshing; + if (current != null) return current; + final run = _refreshSingle(); + _refreshing = run; + return run.whenComplete(() { + if (identical(_refreshing, run)) _refreshing = null; + }); + } + + Future _refreshSingle() async { final refreshToken = _session?.refreshToken ?? ''; if (refreshToken.isEmpty) { await clear(); return false; } try { - await _apply(await _api.refresh(refreshToken)); + await _apply( + await _api.refresh( + refreshToken, + platform: _platform, + osVersion: _osVersion, + ), + ); return true; + } on ApiException catch (error) { + if (_rejectedSessionCodes.contains(error.code)) await clear(); + return false; } catch (_) { - await clear(); return false; } } - Future clear() async { - if (_session == null) return; - _session = null; - await _store.clear(); - notifyListeners(); - } - /// 资料保存成功后同步登录态摘要,保留当前令牌。 Future applyProfile(AuthUser user) async { final current = _session; @@ -154,4 +181,9 @@ class AuthController extends ChangeNotifier { if (kIsWeb) return 'WEB'; return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID'; } + + String get _osVersion { + if (kIsWeb) return ''; + return Platform.operatingSystemVersion; + } } diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index ee55bc4..1a30e0e 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -161,6 +161,37 @@ class FavoriteController extends ChangeNotifier { } } + /// 一次取消多部收藏。失败时整批回滚。 + Future removeMany(List dramaIds) async { + final ids = dramaIds.toSet(); + if (ids.isEmpty) return true; + final previousCards = cards; + final previousTotal = total; + final previousHasMore = hasMore; + final removed = cards.where((drama) => ids.contains(drama.id)).toList(); + if (removed.isEmpty) return true; + cards = cards.where((drama) => !ids.contains(drama.id)).toList(); + for (final id in ids) { + _saved.remove(id); + } + total = total > removed.length ? total - removed.length : 0; + hasMore = cards.length < total; + notify(); + try { + await _repository.removeMany(dramaIds); + return true; + } catch (_) { + cards = previousCards; + total = previousTotal; + hasMore = previousHasMore; + for (final drama in removed) { + _saved.add(drama.id); + } + notify(); + return false; + } + } + /// 取消收藏("我的"页列表里删除某条)。 Future remove(String dramaId) async { final index = cards.indexWhere((drama) => drama.id == dramaId); diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index ccb4526..61de1cb 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -5,6 +5,10 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/drama.dart'; import '../models/player_port.dart'; +import 'video_player_port.dart'; + +/// 新纹理第一帧常是黑的,旧画面只盖住这一小段。 +const Duration _qualityHandoffHold = Duration(milliseconds: 200); typedef PlayerFactory = PlayerPort Function(Episode episode); typedef PlayerPreparation = @@ -66,6 +70,7 @@ class PlaybackSession extends ChangeNotifier { final Set<_PlayerSlot> _live = {}; _PlayerSlot? _active; _PlayerSlot? _next; + _PlayerSlot? _handoff; Future _operations = Future.value(); Future _preloadTask = Future.value(); int _generation = 0; @@ -127,6 +132,15 @@ class PlaybackSession extends ChangeNotifier { return null; } + /// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。 + PlayerPort? handoffAt(int index) { + final slot = _handoff; + if (_closed || slot == null || slot.retiring || slot.index != index) { + return null; + } + return slot.player; + } + /// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。 void remapCurrentIndex(int index, {int? preloadIndex}) { if (_closed || index < 0 || index >= episodes.length) return; @@ -184,9 +198,10 @@ class PlaybackSession extends ChangeNotifier { Future _retire(_PlayerSlot? slot) async { if (slot == null || !_live.contains(slot)) return; slot.retiring = true; - final cleared = _active == slot || _next == slot; + final cleared = _active == slot || _next == slot || _handoff == slot; if (_active == slot) _active = null; if (_next == slot) _next = null; + if (_handoff == slot) _handoff = null; if (cleared) _notify(); await waitForFrame?.call(); await slot.ready; @@ -235,6 +250,7 @@ class PlaybackSession extends ChangeNotifier { _PlayerSlot? target; await _enqueue(() async { if (_closed || request != _generation) return; + await _retire(_handoff); final old = _active; if (old != null && old.index == index && @@ -272,6 +288,108 @@ class PlaybackSession extends ChangeNotifier { if (!_closed && request == _generation) _schedulePreload(); } + /// 后台准备新片源,成功后再替换当前播放器。失败时原播放器保持不动。 + Future replaceCurrent() async { + if (_closed || !_selected) return false; + final index = _currentIndex; + if (index < 0 || index >= episodes.length || isLocked) return false; + if (!canPlay(episodes[index])) return false; + final request = _generation; + final slot = await _prepareReplacement(index, request); + if (slot == null) { + await _resumeActive(); + return false; + } + await slot.ready; + if (!_replacementReady(slot, request)) { + await _dropReplacement(slot); + await _resumeActive(); + return false; + } + final previous = await _showReplacement(slot, request); + if (_active != slot) { + await _dropReplacement(slot); + return false; + } + await _enqueue(() => _start(slot, request)); + await Future.delayed(_qualityHandoffHold); + if (previous != null && _handoff == previous && _active == slot) { + await _enqueue(() => _retire(previous)); + } + if (!_closed && request == _generation) _schedulePreload(); + return _active == slot && slot.failure == null; + } + + Future<_PlayerSlot?> _prepareReplacement(int index, int request) async { + _PlayerSlot? created; + await _enqueue(() async { + if (_closed || request != _generation || _active == null) return; + final warm = _next; + _next = null; + await _retire(_handoff); + await _retire(warm); + if (_closed || request != _generation || _active == null) return; + try { + created = _create(index); + } catch (_) { + created = null; + } + }); + return created; + } + + bool _replacementReady(_PlayerSlot slot, int request) { + return !_closed && + request == _generation && + _active != null && + slot.failure == null && + slot.prepared; + } + + Future _resumeActive() async { + final slot = _active; + if (slot == null || !_userWantsPlay || _closed) return; + await _enqueue(() => _applyPlayback(slot)); + } + + Future _dropReplacement(_PlayerSlot slot) { + return _enqueue(() async { + if (_live.contains(slot)) await _retire(slot); + }); + } + + /// 先把已出画的新播放器交到界面上,旧播放器留给调用方稍后释放。 + Future<_PlayerSlot?> _showReplacement(_PlayerSlot slot, int request) async { + _PlayerSlot? previous; + var shown = false; + await _enqueue(() async { + if (!_replacementReady(slot, request)) return; + previous = _active; + await _catchUp(slot, previous); + _handoff = previous; + _active = slot; + shown = true; + if (previous != null) await previous!.player.pause(); + _notify(); + }); + if (!shown || _active != slot) return null; + return previous; + } + + /// 旧片仍在播,把新片对齐到当前进度,盖住时画面才接得上。 + Future _catchUp(_PlayerSlot slot, _PlayerSlot? previous) async { + final next = slot.player; + final current = previous?.player; + if (next is! VideoPlayerPort || current is! VideoPlayerPort) return; + final position = current.controller.value.position; + if (position <= Duration.zero) return; + try { + await next.seekTo(position); + } catch (_) { + // 对齐失败仍用准备阶段的进度,不因此放弃切换。 + } + } + Future _start(_PlayerSlot slot, int request) async { bool valid() => !_closed && request == _generation && _active == slot; if (!valid()) return; @@ -387,7 +505,7 @@ class PlaybackSession extends ChangeNotifier { Future _releasePlayers() { _generation++; return _enqueue(() async { - _active = _next = null; + _active = _next = _handoff = null; _loading = false; _notify(); for (final slot in _live.toList()) { diff --git a/lib/core/formatting/error_text.dart b/lib/core/formatting/error_text.dart index fb5fd77..8b1876d 100644 --- a/lib/core/formatting/error_text.dart +++ b/lib/core/formatting/error_text.dart @@ -2,6 +2,14 @@ import '../../l10n/app_localizations.dart'; +const int _serverErrorCode = 500; + +/// 用户端不展示内部故障文案。500 与空提示改成可理解的说明,业务提示原样保留。 +String userFacingApiMessage(AppLocalizations l10n, int code, String message) { + if (code >= _serverErrorCode || message.isEmpty) return l10n.authServerError; + return message; +} + /// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与 /// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。 String errorText(AppLocalizations l10n, String? code, String fallback) { diff --git a/lib/core/legal/legal_article_view.dart b/lib/core/legal/legal_article_view.dart new file mode 100644 index 0000000..2b08d2a --- /dev/null +++ b/lib/core/legal/legal_article_view.dart @@ -0,0 +1,127 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +import '../theme/app_colors.dart'; + +const double _bodySize = 16; +const double _headingSize = 18; +const double _blockGap = 12; +const double _lineHeight = 1.7; + +const TextStyle _bodyStyle = TextStyle( + color: AppColors.textPrimary, + fontSize: _bodySize, + height: _lineHeight, +); + +/// 把协议页的 HTML 正文画成普通控件。 +class LegalArticleView extends StatelessWidget { + const LegalArticleView({super.key, required this.html}); + + final String html; + + @override + Widget build(BuildContext context) { + final fragment = html_parser.parseFragment(html); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final node in fragment.nodes) _block(node), + ], + ); + } +} + +Widget _block(dom.Node node) { + if (node is! dom.Element) { + return _paragraph(node.text ?? ''); + } + switch (node.localName) { + case 'h1': + case 'h2': + case 'h3': + return _heading(node.text); + case 'ul': + case 'ol': + return _list(node); + case 'pre': + return _paragraph(node.text); + case 'blockquote': + return _paragraph(node.text); + default: + return _paragraphSpan(node); + } +} + +Widget _heading(String text) { + return Padding( + padding: const EdgeInsets.only(bottom: _blockGap), + child: Text( + text.trim(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _headingSize, + height: 1.4, + fontWeight: FontWeight.w700, + ), + ), + ); +} + +Widget _list(dom.Element element) { + final items = element.children.where((child) => child.localName == 'li'); + return Padding( + padding: const EdgeInsets.only(bottom: _blockGap), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final item in items) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text('• ${item.text.trim()}', style: _bodyStyle), + ), + ], + ), + ); +} + +Widget _paragraph(String text) { + final value = text.trim(); + if (value.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: _blockGap), + child: Text(value, style: _bodyStyle), + ); +} + +Widget _paragraphSpan(dom.Element element) { + return Padding( + padding: const EdgeInsets.only(bottom: _blockGap), + child: Text.rich(TextSpan(style: _bodyStyle, children: _inlines(element))), + ); +} + +List _inlines(dom.Node node) { + if (node is dom.Text) { + return [TextSpan(text: node.text)]; + } + if (node is! dom.Element) return const []; + final children = node.nodes.expand(_inlines).toList(); + switch (node.localName) { + case 'strong': + case 'b': + return [TextSpan(style: const TextStyle(fontWeight: FontWeight.w700), children: children)]; + case 'em': + case 'i': + return [TextSpan(style: const TextStyle(fontStyle: FontStyle.italic), children: children)]; + case 'a': + return [TextSpan(style: const TextStyle(color: AppColors.blue), children: children)]; + case 'br': + return const [TextSpan(text: '\n')]; + default: + return children; + } +} diff --git a/lib/core/legal/legal_html.dart b/lib/core/legal/legal_html.dart new file mode 100644 index 0000000..a16b326 --- /dev/null +++ b/lib/core/legal/legal_html.dart @@ -0,0 +1,36 @@ +// Author: fruit.com.cn + +import 'package:dio/dio.dart'; + +import '../config/app_config.dart'; + +final RegExp _articlePattern = RegExp( + '
([\\s\\S]*)
', + caseSensitive: false, +); + +/// 从协议页里取出正文。没有 article 时退回整页。 +String legalArticle(String html) { + final match = _articlePattern.firstMatch(html); + final body = match?.group(1); + if (body == null || body.trim().isEmpty) return html; + return body.trim(); +} + +/// 拉取协议页 HTML。不走业务 JSON 包装。 +Future fetchLegalArticle(String url) async { + final dio = Dio( + BaseOptions( + connectTimeout: AppConfig.requestTimeout, + receiveTimeout: AppConfig.requestTimeout, + responseType: ResponseType.plain, + validateStatus: (code) => code != null && code >= 200 && code < 300, + ), + ); + final response = await dio.get(url); + final body = response.data; + if (body == null || body.trim().isEmpty) { + throw StateError('empty legal page'); + } + return legalArticle(body); +} diff --git a/lib/core/legal/legal_opener.dart b/lib/core/legal/legal_opener.dart new file mode 100644 index 0000000..0f2bd70 --- /dev/null +++ b/lib/core/legal/legal_opener.dart @@ -0,0 +1,12 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import 'legal_web_page.dart'; + +/// 在当前 App 里打开协议地址。 +Future openLegalUrlOrNotify(BuildContext context, String url) { + return Navigator.of(context).push( + MaterialPageRoute(builder: (_) => LegalWebPage(url: url)), + ); +} diff --git a/lib/core/legal/legal_page.dart b/lib/core/legal/legal_page.dart new file mode 100644 index 0000000..f74539a --- /dev/null +++ b/lib/core/legal/legal_page.dart @@ -0,0 +1,27 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../config/app_config.dart'; + +/// 隐私协议公开路径,对应 `/privacy/{语言}`。 +const String legalPrivacyPath = 'privacy'; + +/// 服务条款公开路径,对应 `/terms/{语言}`。 +const String legalTermsPath = 'terms'; + +const Set _legalLanguages = {'zh', 'en', 'id'}; + +/// 界面语言落到协议页支持的三种语言。其余语言用英文。 +String legalLanguageCode(Locale locale) { + final code = locale.languageCode; + if (_legalLanguages.contains(code)) return code; + return 'en'; +} + +/// 拼出当前服务器上的协议页地址。 +String legalPageUrl(String path, Locale locale) { + final base = AppConfig.apiBaseUrl; + final root = base.endsWith('/') ? base.substring(0, base.length - 1) : base; + return '$root/$path/${legalLanguageCode(locale)}'; +} diff --git a/lib/core/legal/legal_web_page.dart b/lib/core/legal/legal_web_page.dart new file mode 100644 index 0000000..6e987db --- /dev/null +++ b/lib/core/legal/legal_web_page.dart @@ -0,0 +1,96 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import '../../l10n/app_localizations.dart'; +import '../theme/app_colors.dart'; +import '../widgets/plain_page_scaffold.dart'; +import 'legal_article_view.dart'; +import 'legal_html.dart'; + +const double _pagePadding = 20; + +/// 在应用内展示协议页,不使用系统 WebView。 +class LegalWebPage extends StatefulWidget { + const LegalWebPage({super.key, required this.url, this.load}); + + final String url; + + /// 测试时可替换网络请求。 + final Future Function(String url)? load; + + @override + State createState() => _LegalWebPageState(); +} + +class _LegalWebPageState extends State { + String? _html; + var _failed = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _html = null; + _failed = false; + }); + try { + final loader = widget.load ?? fetchLegalArticle; + final html = await loader(widget.url); + if (!mounted) return; + setState(() => _html = html); + } catch (_) { + if (!mounted) return; + setState(() => _failed = true); + } + } + + @override + Widget build(BuildContext context) { + return PlainPageScaffold( + pageKey: const Key('legal-web-page'), + backKey: const Key('legal-web-back'), + title: '', + child: _body(context), + ); + } + + Widget _body(BuildContext context) { + final html = _html; + if (html != null) { + return ListView( + padding: const EdgeInsets.fromLTRB(_pagePadding, 8, _pagePadding, 28), + children: [ + LegalArticleView(html: html), + ], + ); + } + if (_failed) return _failure(context); + return const Center( + child: CircularProgressIndicator( + key: Key('legal-web-loading'), + color: AppColors.blue, + ), + ); + } + + Widget _failure(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.legalOpenFailed, style: const TextStyle(color: AppColors.muted)), + TextButton( + key: const Key('legal-web-retry'), + onPressed: _load, + child: Text(l10n.retry), + ), + ], + ), + ); + } +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 759f7bb..db89555 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -105,6 +105,7 @@ abstract final class AppColors { static const mineIconSupport = Color(0xFFA78BFA); static const mineIconLanguage = Color(0xFF22D3EE); static const mineIconSettings = Color(0xFFA1A1AA); + static const mineIconNotices = Color(0xFFFF8A4C); static const recommendPlaylistGlow = Color(0x26FF6B2C); /// 次级文字与列表 chevron。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index b4f4733..afe1645 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -187,7 +187,7 @@ abstract final class AppDimens { static const double followingAddIconStroke = 1.5; static const double followingGridGap = 6; static const double followingGridRowGap = 12; - static const double followingCardTextExtent = 66; + static const double followingCardTextExtent = 70; static const double followingSubtitleFont = 12; static const double followingSubtitleGap = 4; static const double followingProgressHeight = 2; diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart index d650459..7efd58a 100644 --- a/lib/core/widgets/empty_state.dart +++ b/lib/core/widgets/empty_state.dart @@ -115,7 +115,6 @@ class _EmptyCopy extends StatelessWidget { @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -126,10 +125,10 @@ class _EmptyCopy extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, - style: TextStyle( - color: scheme.onSurface, + style: const TextStyle( + color: AppColors.textTertiary, fontSize: 15, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w400, height: 1.35, ), ), diff --git a/lib/core/widgets/subpage_header.dart b/lib/core/widgets/subpage_header.dart index 1f19d0d..acdfb12 100644 --- a/lib/core/widgets/subpage_header.dart +++ b/lib/core/widgets/subpage_header.dart @@ -13,7 +13,9 @@ const double _titleFontSize = 18; const double _subtitleFontSize = 12; const double _actionGap = 8; const double _iconSize = 22; +const double _backIconSize = 34; const double _iconWeight = 600; +const double _backIconWeight = 400; const double _shadowBlur = 8; /// 二级页面统一圆形操作按钮,返回、分享和更多共用同一尺寸与表面样式。 @@ -24,6 +26,8 @@ class AppSubpageCircleButton extends StatelessWidget { required this.tooltip, required this.icon, required this.onPressed, + this.iconSize = _iconSize, + this.iconWeight = _iconWeight, this.dark = false, }); @@ -31,6 +35,8 @@ class AppSubpageCircleButton extends StatelessWidget { final String tooltip; final IconData icon; final VoidCallback onPressed; + final double iconSize; + final double iconWeight; final bool dark; @override @@ -65,8 +71,8 @@ class AppSubpageCircleButton extends StatelessWidget { child: Icon( icon, color: dark ? AppColors.onSurface : AppColors.textPrimary, - size: _iconSize, - weight: _iconWeight, + size: iconSize, + weight: iconWeight, ), ), ), @@ -96,6 +102,8 @@ class AppSubpageBackButton extends StatelessWidget { buttonKey: backKey, tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Icons.chevron_left_rounded, + iconSize: _backIconSize, + iconWeight: _backIconWeight, dark: dark, onPressed: onBack ?? () => Navigator.of(context).maybePop(), ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4549c2d..634d00e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -23,6 +23,13 @@ "authLandingSubtitle": "Sign in to sync progress, favorites and coins", "comingSoon": "Coming soon", "authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.", + "authLegalLead": "By continuing, you agree to the", + "authTerms": "Terms of Service", + "authLegalJoin": "and", + "authPrivacy": "Privacy Policy", + "legalTerms": "Terms of Service", + "legalPrivacy": "Privacy Policy", + "legalOpenFailed": "Can't open this page right now", "authContinueGoogle": "Continue with Google", "authPhoneSignIn": "Phone sign in", "authEmailSignIn": "Email sign in", @@ -430,6 +437,7 @@ "authPasswordLength": "Password must be 8-64 characters", "authPasswordWeak": "Password needs both letters and numbers", "authNetworkError": "Network error, please try again", + "authServerError": "The service is busy. Please try again shortly.", "authSwitchToSignUp": "New here? Create an account", "authSwitchToSignIn": "Already have an account? Sign in", "authGateTitle": "Sign in to continue", @@ -586,7 +594,11 @@ "accountUnverified": "Unverified", "accountDeactivateWarning": "Requests enter a 15-day cooling-off period. All devices are signed out immediately.", "accountDeactivateReason": "Reason (optional)", - "accountDeactivateReasonHint": "Tell us why you are leaving", + "accountDeactivateReasonHint": "Add more detail here", + "accountDeactivateReasonUnused": "Not using it anymore", + "accountDeactivateReasonContent": "Not interested in the content", + "accountDeactivateReasonPrivacy": "Privacy concerns", + "accountDeactivateReasonOther": "Other", "accountRequestDeactivate": "Request account deletion", "accountConfirmDeactivateTitle": "Request account deletion?", "accountConfirmDeactivateMessage": "All devices will be signed out immediately. You can cancel during the 15-day cooling-off period after signing in again.", @@ -666,6 +678,8 @@ "paymentPending": "Waiting for payment.", "orderCreateFailed": "Could not create the order.", "playbackQuality": "Quality", + "qualitySwitched": "Switched to {quality}", + "@qualitySwitched": { "placeholders": { "quality": { "type": "String" } } }, "qualityAuto": "Auto", "qualityAutoHint": "HLS adjusts the bitrate automatically for your connection.", "searchFailed": "Search failed. Please try again.", @@ -710,6 +724,7 @@ "homeBadgeAnime": "Anime", "homeGridFilterEmpty": "No dramas match these filters", "homeGridClearFilter": "Clear filters", + "emptyContent": "No content yet", "recommendTopTab": "Recommend", "playlistTopTab": "Playlists", "allDramasTopTab": "All Dramas", @@ -728,6 +743,10 @@ "modernFilter": "Modern", "edit": "Edit", "done": "Done", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "cancelFollowing": "Unfollow", + "confirmCancelFollowing": "Unfollow the selected dramas?", "visitor": "Visitor", "copyUid": "Copy UID", "uidCopied": "UID copied", @@ -746,7 +765,7 @@ "watchedToEpisode": "Watched to Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Watch all", - "confirmRemove": "Remove this item?", + "confirmRemove": "Remove this watch history?", "followingCountLabel": "Following", "watchRecordsCountLabel": "Watch records", "dataUnavailable": "—", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 75452ef..d3e45e9 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -23,6 +23,13 @@ "authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin", "comingSoon": "Segera hadir", "authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.", + "authLegalLead": "Dengan melanjutkan, Anda menyetujui", + "authTerms": "Ketentuan Layanan", + "authLegalJoin": "dan", + "authPrivacy": "Kebijakan Privasi", + "legalTerms": "Ketentuan Layanan", + "legalPrivacy": "Kebijakan Privasi", + "legalOpenFailed": "Halaman ini belum bisa dibuka", "authContinueGoogle": "Lanjutkan dengan Google", "authPhoneSignIn": "Masuk dengan ponsel", "authEmailSignIn": "Masuk dengan email", @@ -404,6 +411,7 @@ "authPasswordLength": "Kata sandi harus 8-64 karakter", "authPasswordWeak": "Kata sandi perlu huruf dan angka", "authNetworkError": "Jaringan bermasalah, coba lagi", + "authServerError": "Layanan sedang sibuk. Coba lagi sebentar.", "authSwitchToSignUp": "Baru di sini? Buat akun", "authSwitchToSignIn": "Sudah punya akun? Masuk", "authGateTitle": "Masuk untuk melanjutkan", @@ -550,7 +558,11 @@ "accountUnverified": "Belum terverifikasi", "accountDeactivateWarning": "Permintaan masuk masa tenang 15 hari. Semua perangkat langsung keluar.", "accountDeactivateReason": "Alasan (opsional)", - "accountDeactivateReasonHint": "Beri tahu alasan kamu", + "accountDeactivateReasonHint": "Tambahkan penjelasan di sini", + "accountDeactivateReasonUnused": "Tidak lagi digunakan", + "accountDeactivateReasonContent": "Konten tidak menarik", + "accountDeactivateReasonPrivacy": "Masalah privasi", + "accountDeactivateReasonOther": "Lainnya", "accountRequestDeactivate": "Ajukan penghapusan akun", "accountConfirmDeactivateTitle": "Ajukan penghapusan akun?", "accountConfirmDeactivateMessage": "Semua perangkat akan langsung keluar. Setelah masuk lagi, kamu dapat membatalkannya selama masa tenang 15 hari.", @@ -623,6 +635,8 @@ "paymentPending": "Menunggu pembayaran.", "orderCreateFailed": "Gagal membuat pesanan.", "playbackQuality": "Kualitas", + "qualitySwitched": "Beralih ke {quality}", + "@qualitySwitched": { "placeholders": { "quality": { "type": "String" } } }, "qualityAuto": "Otomatis", "qualityAutoHint": "HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.", "searchFailed": "Pencarian gagal. Coba lagi.", @@ -667,6 +681,7 @@ "homeBadgeAnime": "Anime", "homeGridFilterEmpty": "Tidak ada drama yang cocok dengan filter", "homeGridClearFilter": "Hapus filter", + "emptyContent": "Belum ada konten", "recommendTopTab": "Rekomendasi", "playlistTopTab": "Daftar Putar", "allDramasTopTab": "Semua", @@ -685,6 +700,10 @@ "modernFilter": "Modern", "edit": "Edit", "done": "Selesai", + "selectAll": "Pilih semua", + "deselectAll": "Batal pilih", + "cancelFollowing": "Berhenti mengikuti", + "confirmCancelFollowing": "Berhenti mengikuti drama yang dipilih?", "visitor": "Pengunjung", "copyUid": "Salin UID", "uidCopied": "UID disalin", @@ -703,7 +722,7 @@ "watchedToEpisode": "Ditonton hingga Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Tonton semua", - "confirmRemove": "Hapus item ini?", + "confirmRemove": "Hapus riwayat tontonan ini?", "followingCountLabel": "Mengikuti", "watchRecordsCountLabel": "Riwayat tontonan", "dataUnavailable": "—", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 42223d4..13fbdd1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -238,6 +238,48 @@ abstract class AppLocalizations { /// **'By continuing, you agree to the Terms of Service and Privacy Policy.'** String get authLegal; + /// No description provided for @authLegalLead. + /// + /// In en, this message translates to: + /// **'By continuing, you agree to the'** + String get authLegalLead; + + /// No description provided for @authTerms. + /// + /// In en, this message translates to: + /// **'Terms of Service'** + String get authTerms; + + /// No description provided for @authLegalJoin. + /// + /// In en, this message translates to: + /// **'and'** + String get authLegalJoin; + + /// No description provided for @authPrivacy. + /// + /// In en, this message translates to: + /// **'Privacy Policy'** + String get authPrivacy; + + /// No description provided for @legalTerms. + /// + /// In en, this message translates to: + /// **'Terms of Service'** + String get legalTerms; + + /// No description provided for @legalPrivacy. + /// + /// In en, this message translates to: + /// **'Privacy Policy'** + String get legalPrivacy; + + /// No description provided for @legalOpenFailed. + /// + /// In en, this message translates to: + /// **'Can\'t open this page right now'** + String get legalOpenFailed; + /// No description provided for @authContinueGoogle. /// /// In en, this message translates to: @@ -1858,6 +1900,12 @@ abstract class AppLocalizations { /// **'Network error, please try again'** String get authNetworkError; + /// No description provided for @authServerError. + /// + /// In en, this message translates to: + /// **'The service is busy. Please try again shortly.'** + String get authServerError; + /// No description provided for @authSwitchToSignUp. /// /// In en, this message translates to: @@ -2437,9 +2485,33 @@ abstract class AppLocalizations { /// No description provided for @accountDeactivateReasonHint. /// /// In en, this message translates to: - /// **'Tell us why you are leaving'** + /// **'Add more detail here'** String get accountDeactivateReasonHint; + /// No description provided for @accountDeactivateReasonUnused. + /// + /// In en, this message translates to: + /// **'Not using it anymore'** + String get accountDeactivateReasonUnused; + + /// No description provided for @accountDeactivateReasonContent. + /// + /// In en, this message translates to: + /// **'Not interested in the content'** + String get accountDeactivateReasonContent; + + /// No description provided for @accountDeactivateReasonPrivacy. + /// + /// In en, this message translates to: + /// **'Privacy concerns'** + String get accountDeactivateReasonPrivacy; + + /// No description provided for @accountDeactivateReasonOther. + /// + /// In en, this message translates to: + /// **'Other'** + String get accountDeactivateReasonOther; + /// No description provided for @accountRequestDeactivate. /// /// In en, this message translates to: @@ -2824,6 +2896,12 @@ abstract class AppLocalizations { /// **'Quality'** String get playbackQuality; + /// No description provided for @qualitySwitched. + /// + /// In en, this message translates to: + /// **'Switched to {quality}'** + String qualitySwitched(String quality); + /// No description provided for @qualityAuto. /// /// In en, this message translates to: @@ -3070,6 +3148,12 @@ abstract class AppLocalizations { /// **'Clear filters'** String get homeGridClearFilter; + /// No description provided for @emptyContent. + /// + /// In en, this message translates to: + /// **'No content yet'** + String get emptyContent; + /// No description provided for @recommendTopTab. /// /// In en, this message translates to: @@ -3178,6 +3262,30 @@ abstract class AppLocalizations { /// **'Done'** String get done; + /// No description provided for @selectAll. + /// + /// In en, this message translates to: + /// **'Select all'** + String get selectAll; + + /// No description provided for @deselectAll. + /// + /// In en, this message translates to: + /// **'Deselect all'** + String get deselectAll; + + /// No description provided for @cancelFollowing. + /// + /// In en, this message translates to: + /// **'Unfollow'** + String get cancelFollowing; + + /// No description provided for @confirmCancelFollowing. + /// + /// In en, this message translates to: + /// **'Unfollow the selected dramas?'** + String get confirmCancelFollowing; + /// No description provided for @visitor. /// /// In en, this message translates to: @@ -3247,7 +3355,7 @@ abstract class AppLocalizations { /// No description provided for @confirmRemove. /// /// In en, this message translates to: - /// **'Remove this item?'** + /// **'Remove this watch history?'** String get confirmRemove; /// No description provided for @followingCountLabel. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 96f45c6..46ee7ee 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -80,6 +80,27 @@ class AppLocalizationsEn extends AppLocalizations { String get authLegal => 'By continuing, you agree to the Terms of Service and Privacy Policy.'; + @override + String get authLegalLead => 'By continuing, you agree to the'; + + @override + String get authTerms => 'Terms of Service'; + + @override + String get authLegalJoin => 'and'; + + @override + String get authPrivacy => 'Privacy Policy'; + + @override + String get legalTerms => 'Terms of Service'; + + @override + String get legalPrivacy => 'Privacy Policy'; + + @override + String get legalOpenFailed => 'Can\'t open this page right now'; + @override String get authContinueGoogle => 'Continue with Google'; @@ -943,6 +964,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authNetworkError => 'Network error, please try again'; + @override + String get authServerError => + 'The service is busy. Please try again shortly.'; + @override String get authSwitchToSignUp => 'New here? Create an account'; @@ -1262,7 +1287,19 @@ class AppLocalizationsEn extends AppLocalizations { String get accountDeactivateReason => 'Reason (optional)'; @override - String get accountDeactivateReasonHint => 'Tell us why you are leaving'; + String get accountDeactivateReasonHint => 'Add more detail here'; + + @override + String get accountDeactivateReasonUnused => 'Not using it anymore'; + + @override + String get accountDeactivateReasonContent => 'Not interested in the content'; + + @override + String get accountDeactivateReasonPrivacy => 'Privacy concerns'; + + @override + String get accountDeactivateReasonOther => 'Other'; @override String get accountRequestDeactivate => 'Request account deletion'; @@ -1472,6 +1509,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get playbackQuality => 'Quality'; + @override + String qualitySwitched(String quality) { + return 'Switched to $quality'; + } + @override String get qualityAuto => 'Auto'; @@ -1602,6 +1644,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeGridClearFilter => 'Clear filters'; + @override + String get emptyContent => 'No content yet'; + @override String get recommendTopTab => 'Recommend'; @@ -1656,6 +1701,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get done => 'Done'; + @override + String get selectAll => 'Select all'; + + @override + String get deselectAll => 'Deselect all'; + + @override + String get cancelFollowing => 'Unfollow'; + + @override + String get confirmCancelFollowing => 'Unfollow the selected dramas?'; + @override String get visitor => 'Visitor'; @@ -1694,7 +1751,7 @@ class AppLocalizationsEn extends AppLocalizations { String get homeWatchAll => 'Watch all'; @override - String get confirmRemove => 'Remove this item?'; + String get confirmRemove => 'Remove this watch history?'; @override String get followingCountLabel => 'Following'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 5417abe..80b23c5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -80,6 +80,27 @@ class AppLocalizationsId extends AppLocalizations { String get authLegal => 'Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.'; + @override + String get authLegalLead => 'Dengan melanjutkan, Anda menyetujui'; + + @override + String get authTerms => 'Ketentuan Layanan'; + + @override + String get authLegalJoin => 'dan'; + + @override + String get authPrivacy => 'Kebijakan Privasi'; + + @override + String get legalTerms => 'Ketentuan Layanan'; + + @override + String get legalPrivacy => 'Kebijakan Privasi'; + + @override + String get legalOpenFailed => 'Halaman ini belum bisa dibuka'; + @override String get authContinueGoogle => 'Lanjutkan dengan Google'; @@ -947,6 +968,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get authNetworkError => 'Jaringan bermasalah, coba lagi'; + @override + String get authServerError => 'Layanan sedang sibuk. Coba lagi sebentar.'; + @override String get authSwitchToSignUp => 'Baru di sini? Buat akun'; @@ -1262,7 +1286,19 @@ class AppLocalizationsId extends AppLocalizations { String get accountDeactivateReason => 'Alasan (opsional)'; @override - String get accountDeactivateReasonHint => 'Beri tahu alasan kamu'; + String get accountDeactivateReasonHint => 'Tambahkan penjelasan di sini'; + + @override + String get accountDeactivateReasonUnused => 'Tidak lagi digunakan'; + + @override + String get accountDeactivateReasonContent => 'Konten tidak menarik'; + + @override + String get accountDeactivateReasonPrivacy => 'Masalah privasi'; + + @override + String get accountDeactivateReasonOther => 'Lainnya'; @override String get accountRequestDeactivate => 'Ajukan penghapusan akun'; @@ -1472,6 +1508,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get playbackQuality => 'Kualitas'; + @override + String qualitySwitched(String quality) { + return 'Beralih ke $quality'; + } + @override String get qualityAuto => 'Otomatis'; @@ -1603,6 +1644,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeGridClearFilter => 'Hapus filter'; + @override + String get emptyContent => 'Belum ada konten'; + @override String get recommendTopTab => 'Rekomendasi'; @@ -1659,6 +1703,18 @@ class AppLocalizationsId extends AppLocalizations { @override String get done => 'Selesai'; + @override + String get selectAll => 'Pilih semua'; + + @override + String get deselectAll => 'Batal pilih'; + + @override + String get cancelFollowing => 'Berhenti mengikuti'; + + @override + String get confirmCancelFollowing => 'Berhenti mengikuti drama yang dipilih?'; + @override String get visitor => 'Pengunjung'; @@ -1697,7 +1753,7 @@ class AppLocalizationsId extends AppLocalizations { String get homeWatchAll => 'Tonton semua'; @override - String get confirmRemove => 'Hapus item ini?'; + String get confirmRemove => 'Hapus riwayat tontonan ini?'; @override String get followingCountLabel => 'Mengikuti'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 62f7489..d8d11f5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -77,6 +77,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get authLegal => '继续即代表同意《用户协议》与《隐私政策》'; + @override + String get authLegalLead => '继续即代表同意'; + + @override + String get authTerms => '《用户协议》'; + + @override + String get authLegalJoin => '与'; + + @override + String get authPrivacy => '《隐私政策》'; + + @override + String get legalTerms => '服务条款'; + + @override + String get legalPrivacy => '隐私协议'; + + @override + String get legalOpenFailed => '暂时无法打开协议页面'; + @override String get authContinueGoogle => '使用 Google 继续'; @@ -931,6 +952,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get authNetworkError => '网络异常,请稍后重试'; + @override + String get authServerError => '服务繁忙,请稍后再试'; + @override String get authSwitchToSignUp => '还没有账号?去注册'; @@ -1240,7 +1264,19 @@ class AppLocalizationsZh extends AppLocalizations { String get accountDeactivateReason => '注销原因(选填)'; @override - String get accountDeactivateReasonHint => '告诉我们你离开的原因'; + String get accountDeactivateReasonHint => '也可以在这里补充说明'; + + @override + String get accountDeactivateReasonUnused => '不再使用'; + + @override + String get accountDeactivateReasonContent => '内容不感兴趣'; + + @override + String get accountDeactivateReasonPrivacy => '隐私顾虑'; + + @override + String get accountDeactivateReasonOther => '其他'; @override String get accountRequestDeactivate => '申请注销账号'; @@ -1449,6 +1485,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get playbackQuality => '清晰度'; + @override + String qualitySwitched(String quality) { + return '已切换至$quality'; + } + @override String get qualityAuto => '自动'; @@ -1578,6 +1619,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeGridClearFilter => '清除筛选'; + @override + String get emptyContent => '暂无内容'; + @override String get recommendTopTab => '推荐'; @@ -1632,6 +1676,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get done => '完成'; + @override + String get selectAll => '全选'; + + @override + String get deselectAll => '取消全选'; + + @override + String get cancelFollowing => '取消追剧'; + + @override + String get confirmCancelFollowing => '确定取消已选短剧的追剧吗?'; + @override String get visitor => '访客'; @@ -1670,7 +1726,7 @@ class AppLocalizationsZh extends AppLocalizations { String get homeWatchAll => '观看全集'; @override - String get confirmRemove => '确定移除这条内容吗?'; + String get confirmRemove => '确定要移除该条观看记录吗?'; @override String get followingCountLabel => '追剧数'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9af6280..af96143 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -23,6 +23,13 @@ "authLandingSubtitle": "登录后同步进度、收藏与金币权益", "comingSoon": "即将上线", "authLegal": "继续即代表同意《用户协议》与《隐私政策》", + "authLegalLead": "继续即代表同意", + "authTerms": "《用户协议》", + "authLegalJoin": "与", + "authPrivacy": "《隐私政策》", + "legalTerms": "服务条款", + "legalPrivacy": "隐私协议", + "legalOpenFailed": "暂时无法打开协议页面", "authContinueGoogle": "使用 Google 继续", "authPhoneSignIn": "手机号登录", "authEmailSignIn": "邮箱登录", @@ -366,6 +373,7 @@ "authPasswordLength": "密码长度需在 8-64 位之间", "authPasswordWeak": "密码需同时包含字母和数字", "authNetworkError": "网络异常,请稍后重试", + "authServerError": "服务繁忙,请稍后再试", "authSwitchToSignUp": "还没有账号?去注册", "authSwitchToSignIn": "已有账号?去登录", "authGateTitle": "登录后可继续", @@ -512,7 +520,11 @@ "accountUnverified": "未验证", "accountDeactivateWarning": "提交后进入 15 天冷静期,所有设备会立即退出登录。", "accountDeactivateReason": "注销原因(选填)", - "accountDeactivateReasonHint": "告诉我们你离开的原因", + "accountDeactivateReasonHint": "也可以在这里补充说明", + "accountDeactivateReasonUnused": "不再使用", + "accountDeactivateReasonContent": "内容不感兴趣", + "accountDeactivateReasonPrivacy": "隐私顾虑", + "accountDeactivateReasonOther": "其他", "accountRequestDeactivate": "申请注销账号", "accountConfirmDeactivateTitle": "确认申请注销?", "accountConfirmDeactivateMessage": "所有设备会立即退出登录。重新登录后,可在 15 天冷静期内撤销申请。", @@ -585,6 +597,8 @@ "paymentPending": "等待支付。", "orderCreateFailed": "订单创建失败。", "playbackQuality": "清晰度", + "qualitySwitched": "已切换至{quality}", + "@qualitySwitched": { "placeholders": { "quality": { "type": "String" } } }, "qualityAuto": "自动", "qualityAutoHint": "HLS 会根据当前网络自动切换码率。", "searchFailed": "搜索失败,请重试。", @@ -629,6 +643,7 @@ "homeBadgeAnime": "漫剧", "homeGridFilterEmpty": "没有符合筛选条件的短剧", "homeGridClearFilter": "清除筛选", + "emptyContent": "暂无内容", "recommendTopTab": "推荐", "playlistTopTab": "剧单", "allDramasTopTab": "全部短剧", @@ -647,6 +662,10 @@ "modernFilter": "现代", "edit": "编辑", "done": "完成", + "selectAll": "全选", + "deselectAll": "取消全选", + "cancelFollowing": "取消追剧", + "confirmCancelFollowing": "确定取消已选短剧的追剧吗?", "visitor": "访客", "copyUid": "复制 UID", "uidCopied": "UID 已复制", @@ -665,7 +684,7 @@ "watchedToEpisode": "已看至 第 {episode} 集", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "观看全集", - "confirmRemove": "确定移除这条内容吗?", + "confirmRemove": "确定要移除该条观看记录吗?", "followingCountLabel": "追剧数", "watchRecordsCountLabel": "观看记录", "dataUnavailable": "—", diff --git a/lib/pages/account/account_deactivate_reason.dart b/lib/pages/account/account_deactivate_reason.dart new file mode 100644 index 0000000..475b364 --- /dev/null +++ b/lib/pages/account/account_deactivate_reason.dart @@ -0,0 +1,106 @@ +// 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'; +import 'account_deactivate_logic.dart'; + +/// 注销原因:固定选项在标题下换行排列,补充说明留在输入框内。 +class DeactivateReasonField extends StatelessWidget { + const DeactivateReasonField({ + super.key, + required this.controller, + required this.onChanged, + }); + + final TextEditingController controller; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final options = _options(l10n); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.accountDeactivateReason, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppDimens.spaceSm), + Wrap( + spacing: AppDimens.spaceSm, + runSpacing: AppDimens.spaceSm, + children: [ + for (var index = 0; index < options.length; index++) + _chip(options[index], index, options), + ], + ), + const SizedBox(height: AppDimens.spaceMd), + TextField( + key: const Key('account-deactivate-reason'), + controller: controller, + maxLength: AccountDeactivateLogic.maxReasonLength, + minLines: 3, + maxLines: 4, + textAlignVertical: TextAlignVertical.top, + onChanged: (_) => onChanged(), + decoration: InputDecoration( + hintText: l10n.accountDeactivateReasonHint, + floatingLabelBehavior: FloatingLabelBehavior.never, + alignLabelWithHint: true, + contentPadding: const EdgeInsets.all(AppDimens.cardPadding), + ), + ), + ], + ); + } + + Widget _chip(_ReasonOption option, int index, List<_ReasonOption> options) { + final selected = option.custom + ? _customSelected(options) + : controller.text == option.label; + return ChoiceChip( + key: Key('account-deactivate-reason-$index'), + label: Text(option.label), + selected: selected, + onSelected: (_) => _select(option), + ); + } + + bool _customSelected(List<_ReasonOption> options) { + final text = controller.text.trim(); + if (text.isEmpty) return false; + return options + .where((item) => !item.custom) + .every((item) => item.label != text); + } + + void _select(_ReasonOption option) { + controller.text = option.custom ? '' : option.label; + controller.selection = TextSelection.collapsed( + offset: controller.text.length, + ); + onChanged(); + } + + List<_ReasonOption> _options(AppLocalizations l10n) => [ + _ReasonOption(l10n.accountDeactivateReasonUnused), + _ReasonOption(l10n.accountDeactivateReasonContent), + _ReasonOption(l10n.accountDeactivateReasonPrivacy), + _ReasonOption(l10n.accountDeactivateReasonOther, custom: true), + ]; +} + +class _ReasonOption { + const _ReasonOption(this.label, {this.custom = false}); + + final String label; + final bool custom; +} diff --git a/lib/pages/account/account_entry_card.dart b/lib/pages/account/account_entry_card.dart index e12dc83..8b21ec3 100644 --- a/lib/pages/account/account_entry_card.dart +++ b/lib/pages/account/account_entry_card.dart @@ -10,132 +10,119 @@ import 'account_devices_page.dart'; import 'account_export_page.dart'; import 'account_profile_page.dart'; -const _accountTitleWidth = 64.0; - -/// 我的页账号入口卡;只在登录态由父页面展示。 +/// 设置页账号入口,登录后按列表展示。 class AccountEntryCard extends StatelessWidget { const AccountEntryCard({super.key}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final rows = <_AccountRow>[ + _AccountRow( + key: const Key('mine-account-profile'), + icon: Icons.person_outline_rounded, + title: l10n.accountProfile, + page: const AccountProfilePage(), + ), + _AccountRow( + key: const Key('mine-account-devices'), + icon: Icons.devices_other_rounded, + title: l10n.accountDevices, + page: const AccountDevicesPage(), + ), + _AccountRow( + key: const Key('mine-account-export'), + icon: Icons.file_download_outlined, + title: l10n.accountDataExport, + page: const AccountExportPage(), + ), + _AccountRow( + key: const Key('mine-account-deactivate'), + icon: Icons.person_remove_outlined, + title: l10n.accountDeactivate, + page: const AccountDeactivatePage(), + ), + ]; return Material( key: const Key('mine-account-card'), color: AppColors.card, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(AppDimens.cardRadius), clipBehavior: Clip.antiAlias, - child: SizedBox( - height: AppDimens.accountCardHeight, - child: Row( - children: [ - SizedBox( - width: _accountTitleWidth, - child: Padding( - padding: const EdgeInsets.only(left: 12), - child: Text( - l10n.accountCenter, - style: const TextStyle( - fontSize: AppDimens.caption, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - const VerticalDivider( - width: 1, - color: AppColors.outlineVariantDark, - ), - Expanded( - child: Row( - children: [ - _entry( - context, - key: const Key('mine-account-profile'), - icon: Icons.person_outline_rounded, - title: l10n.accountProfile, - page: const AccountProfilePage(), - divider: true, - ), - _entry( - context, - key: const Key('mine-account-devices'), - icon: Icons.devices_other_rounded, - title: l10n.accountDevices, - page: const AccountDevicesPage(), - divider: true, - ), - _entry( - context, - key: const Key('mine-account-export'), - icon: Icons.file_download_outlined, - title: l10n.accountDataExport, - page: const AccountExportPage(), - divider: true, - ), - _entry( - context, - key: const Key('mine-account-deactivate'), - icon: Icons.person_remove_outlined, - title: l10n.accountDeactivate, - page: const AccountDeactivatePage(), - divider: false, - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _entry( - BuildContext context, { - required Key key, - required IconData icon, - required String title, - required Widget page, - required bool divider, - }) { - return Expanded( - child: Row( + child: Column( children: [ - Expanded( - child: InkWell( - key: key, - onTap: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => page)), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - color: AppColors.blueSoft, - size: AppDimens.accountIcon, - ), - const SizedBox(height: 2), - Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: AppDimens.accountLabel, - height: 1.15, - ), - ), - ], + for (var index = 0; index < rows.length; index++) ...[ + if (index > 0) + const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.outlineVariantDark, ), - ), - ), - if (divider) - const VerticalDivider( - width: 1, - color: AppColors.outlineVariantDark, - ), + _AccountListTile(row: rows[index]), + ], ], ), ); } } + +class _AccountRow { + const _AccountRow({ + required this.key, + required this.icon, + required this.title, + required this.page, + }); + + final Key key; + final IconData icon; + final String title; + final Widget page; +} + +class _AccountListTile extends StatelessWidget { + const _AccountListTile({required this.row}); + + final _AccountRow row; + + @override + Widget build(BuildContext context) { + return InkWell( + key: row.key, + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => row.page)), + child: SizedBox( + height: AppDimens.mineMoreRowHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), + child: Row( + children: [ + Icon( + row.icon, + color: AppColors.blueSoft, + size: AppDimens.accountIcon, + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Text( + row.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.listTitle, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textSecondary, + size: AppDimens.accountIcon, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/auth/auth_form_widgets.dart b/lib/pages/auth/auth_form_widgets.dart index b46fd1a..50ec19c 100644 --- a/lib/pages/auth/auth_form_widgets.dart +++ b/lib/pages/auth/auth_form_widgets.dart @@ -336,7 +336,6 @@ class _AuthBrand extends StatelessWidget { key: const Key('auth-brand-mark'), size: compact ? _compactBrandMarkSize : _brandMarkSize, radius: compact ? _compactBrandMarkRadius : _brandMarkRadius, - iconSize: compact ? _compactBrandIconSize : _brandIconSize, ); Widget _brandCopy(BuildContext context, {required bool centered}) => Column( diff --git a/lib/pages/auth/auth_legal_links.dart b/lib/pages/auth/auth_legal_links.dart new file mode 100644 index 0000000..ca4b75a --- /dev/null +++ b/lib/pages/auth/auth_legal_links.dart @@ -0,0 +1,75 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../core/legal/legal_opener.dart'; +import '../../core/legal/legal_page.dart'; +import '../../core/theme/app_colors.dart'; +import '../../l10n/app_localizations.dart'; + +const double _legalFontSize = 12; + +/// 登录页底部的协议入口。点用户协议或隐私政策会打开对应语言的页面。 +class AuthLegalLinks extends StatefulWidget { + const AuthLegalLinks({super.key, this.open}); + + /// 测试时可替换真正的跳转。 + final Future Function(String url)? open; + + @override + State createState() => _AuthLegalLinksState(); +} + +class _AuthLegalLinksState extends State { + late final TapGestureRecognizer _termsTap = TapGestureRecognizer() + ..onTap = () => _open(legalTermsPath); + late final TapGestureRecognizer _privacyTap = TapGestureRecognizer() + ..onTap = () => _open(legalPrivacyPath); + + @override + void dispose() { + _termsTap.dispose(); + _privacyTap.dispose(); + super.dispose(); + } + + void _open(String path) { + final url = legalPageUrl(path, Localizations.localeOf(context)); + final custom = widget.open; + if (custom != null) { + unawaited(custom(url)); + return; + } + unawaited(openLegalUrlOrNotify(context, url)); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final muted = TextStyle(color: AppColors.muted, fontSize: _legalFontSize, height: 1.6); + final link = muted.copyWith(color: AppColors.blue); + return Text.rich( + TextSpan( + style: muted, + children: [ + TextSpan(text: l10n.authLegalLead), + const TextSpan(text: ' '), + TextSpan(text: l10n.authTerms, style: link, recognizer: _termsTap), + const TextSpan(text: ' '), + TextSpan(text: l10n.authLegalJoin), + const TextSpan(text: ' '), + TextSpan( + text: l10n.authPrivacy, + style: link, + recognizer: _privacyTap, + ), + ], + ), + key: const Key('auth-legal'), + textAlign: TextAlign.center, + ); + } +} diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index f0906ce..0f02fb6 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; +import '../../core/formatting/error_text.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/platform/keyboard_inset.dart'; @@ -15,6 +16,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/centered_toast.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; +import 'auth_legal_links.dart'; import 'auth_logic.dart'; import 'phone_auth_sheet.dart'; @@ -26,8 +28,8 @@ 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 _landingLogoTopGap = 28; +const String _appLogoAsset = 'assets/icon/fruitshorts.png'; const double _contentMaxWidth = 440; // Auth keeps its approved 20px card silhouette. const double _cardRadius = AppDimens.authCardRadius; @@ -58,31 +60,15 @@ Widget _brandMarkWidget({ Key? key, required double size, required double radius, - required double iconSize, }) { - return Container( + return ClipRRect( 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, + borderRadius: BorderRadius.circular(radius), + child: Image.asset( + _appLogoAsset, + width: size, + height: size, + fit: BoxFit.cover, ), ); } @@ -257,7 +243,7 @@ class _AuthPageState extends State { if (!mounted) return; Navigator.of(context).maybePop(); } on ApiException catch (error) { - failureMessage = error.message; + failureMessage = userFacingApiMessage(l10n, error.code, error.message); } catch (_) { failureMessage = l10n.authNetworkError; } finally { @@ -431,12 +417,12 @@ class _AuthPageState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const SizedBox(height: _landingLogoTopGap), Center( child: _brandMarkWidget( key: const Key('auth-brand-logo'), size: _brandMarkSize, radius: _brandMarkRadius, - iconSize: _brandIconSize, ), ), const SizedBox(height: 16), @@ -473,16 +459,7 @@ class _AuthPageState extends State { onTap: () => setState(() => _emailEntryOpen = true), ), const SizedBox(height: 18), - Text( - l10n.authLegal, - key: const Key('auth-legal'), - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.muted, - fontSize: 9, - height: 1.6, - ), - ), + const AuthLegalLinks(), ], ); diff --git a/lib/pages/discover/search_page.dart b/lib/pages/discover/search_page.dart index f93779c..c0b149f 100644 --- a/lib/pages/discover/search_page.dart +++ b/lib/pages/discover/search_page.dart @@ -20,6 +20,11 @@ import 'discover_binding.dart'; import 'discover_logic.dart'; import 'widgets/search_result_card.dart'; +const double _clearHit = 20; +const double _clearIconSize = 16; +const double _clearEndGap = 10; +const double _loadingInset = 2; + class SearchPage extends StatefulWidget { const SearchPage({super.key}); @@ -119,11 +124,10 @@ class _SearchPageState extends State { } Widget _field(AppLocalizations l10n) { - final hasText = _logic.search.text.isNotEmpty; return Container( key: const Key('search-field'), height: AppDimens.searchHeight, - padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + padding: const EdgeInsets.only(left: AppDimens.spaceMd), decoration: BoxDecoration( color: AppColors.contentSurface, borderRadius: BorderRadius.circular(AppDimens.searchRadius), @@ -143,7 +147,9 @@ class _SearchPageState extends State { focusNode: _logic.searchFocus, autofocus: true, onChanged: (_) => _logic.onSearchChanged(), + onTapOutside: (_) => _logic.searchFocus.unfocus(), textInputAction: TextInputAction.search, + textAlignVertical: TextAlignVertical.center, style: const TextStyle( color: AppColors.textPrimary, fontSize: 14, @@ -163,35 +169,64 @@ class _SearchPageState extends State { scrollPadding: EdgeInsets.zero, ), ), - if (_logic.searchLoading) - const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.textPrimary, - ), - ) - else if (hasText) - IconButton( - key: const Key('search-clear'), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor(width: 24, height: 24), - onPressed: () { - _logic.search.clear(); - _logic.onSearchChanged(); - _logic.searchFocus.requestFocus(); - }, - icon: const Icon( - Icons.close_rounded, - color: AppColors.textSecondary, - size: 16, - ), - ), + _fieldTrailing(), ], ), ); } + /// 有内容时把清除按钮放在输入框内部右侧,并留出一点边距。 + /// 刷新时不拿转圈替换叉号,避免宽度变化把按钮抖开。 + Widget _fieldTrailing() { + final hasText = _logic.search.text.isNotEmpty; + if (!hasText && !_logic.searchLoading) { + return const SizedBox(width: AppDimens.spaceMd); + } + return SizedBox( + width: _clearHit + _clearEndGap, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: _clearEndGap), + child: hasText ? _clearButton() : _loadingMark(), + ), + ), + ); + } + + Widget _clearButton() { + return GestureDetector( + key: const Key('search-clear'), + behavior: HitTestBehavior.opaque, + onTap: () { + _logic.search.clear(); + _logic.onSearchChanged(); + _logic.searchFocus.requestFocus(); + }, + child: const SizedBox.square( + dimension: _clearHit, + child: Icon( + Icons.close_rounded, + color: AppColors.textSecondary, + size: _clearIconSize, + ), + ), + ); + } + + Widget _loadingMark() { + return const SizedBox.square( + dimension: _clearHit, + child: Padding( + padding: EdgeInsets.all(_loadingInset), + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textPrimary, + ), + ), + ); + } + Widget _body(AppLocalizations l10n) { if (!_logic.hasSearchQuery) { return _stateScroll( diff --git a/lib/pages/following/following_edit_bar.dart b/lib/pages/following/following_edit_bar.dart new file mode 100644 index 0000000..e45dacd --- /dev/null +++ b/lib/pages/following/following_edit_bar.dart @@ -0,0 +1,228 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../core/data/services/favorite_controller.dart'; +import '../../core/theme/app_colors.dart'; +import '../../l10n/app_localizations.dart'; + +enum FollowingTab { favorites, history } + +/// 编辑底栏的全选与取消动作。 +class FollowingEditActions { + const FollowingEditActions({ + required this.onToggleAll, + required this.onRemove, + }); + + final void Function(Set ids) onToggleAll; + final VoidCallback onRemove; +} + +const double _barHeight = 48; +const double _barHorizontal = 16; +const double _barFontSize = 13; +const double _barButtonHeight = 30; +const double _removeDialogButtonHeight = 32; +const double _removeDialogRadius = 6; +const double _removeDialogBottomInset = 8; + +/// 播放历史移除确认。只把移除按钮压矮。 +Future confirmHistoryRemove(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + return await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + actionsPadding: const EdgeInsets.fromLTRB( + 24, + 0, + 16, + _removeDialogBottomInset, + ), + content: Text(l10n.confirmRemove), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(0, _removeDialogButtonHeight), + padding: const EdgeInsets.symmetric(horizontal: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_removeDialogRadius), + ), + ), + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.remove), + ), + ], + ), + ) ?? + false; +} + +/// 确认后返回是否取消已选追剧。 +Future confirmCancelFollowing(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + return await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + content: Text(l10n.confirmCancelFollowing), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.cancelFollowing), + ), + ], + ), + ) ?? + false; +} + +/// 只在追剧编辑中显示底栏。 +class FollowingEditSlot extends StatelessWidget { + const FollowingEditSlot({ + super.key, + required this.tab, + required this.editing, + required this.favorites, + required this.selected, + required this.actions, + }); + + final ValueListenable tab; + final ValueListenable editing; + final FavoriteController favorites; + final ValueListenable> selected; + final FollowingEditActions actions; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: tab, + builder: (context, current, _) { + return ValueListenableBuilder( + valueListenable: editing, + builder: (context, isEditing, _) { + if (!isEditing || current != FollowingTab.favorites) { + return const SizedBox.shrink(); + } + return _bar(); + }, + ); + }, + ); + } + + Widget _bar() { + return ListenableBuilder( + listenable: favorites, + builder: (context, _) { + return ValueListenableBuilder>( + valueListenable: selected, + builder: (context, picked, _) { + final ids = favorites.cards.map((drama) => drama.id).toSet(); + return FollowingEditBar( + allSelected: ids.isNotEmpty && ids.every(picked.contains), + canRemove: picked.isNotEmpty, + onToggleAll: () => actions.onToggleAll(ids), + onRemove: actions.onRemove, + ); + }, + ); + }, + ); + } +} + +/// 追剧编辑底栏:全选,以及取消已选追剧。 +class FollowingEditBar extends StatelessWidget { + const FollowingEditBar({ + super.key, + required this.allSelected, + required this.canRemove, + required this.onToggleAll, + required this.onRemove, + }); + + final bool allSelected; + final bool canRemove; + final VoidCallback onToggleAll; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Material( + key: const Key('following-edit-bar'), + color: AppColors.card, + child: SafeArea( + top: false, + child: SizedBox( + height: _barHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _barHorizontal), + child: Row( + children: [ + TextButton( + key: const Key('following-select-all'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(0, _barButtonHeight), + padding: const EdgeInsets.symmetric(horizontal: 4), + textStyle: const TextStyle( + fontSize: _barFontSize, + fontWeight: FontWeight.w500, + ), + ), + onPressed: onToggleAll, + child: Text( + allSelected ? l10n.deselectAll : l10n.selectAll, + style: const TextStyle( + fontSize: _barFontSize, + fontWeight: FontWeight.w500, + ), + ), + ), + const Spacer(), + FilledButton( + key: const Key('following-cancel-follow'), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(0, _removeDialogButtonHeight), + padding: const EdgeInsets.symmetric(horizontal: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_removeDialogRadius), + ), + textStyle: const TextStyle( + fontSize: _barFontSize, + fontWeight: FontWeight.w600, + ), + ), + onPressed: canRemove ? onRemove : null, + child: Text( + l10n.cancelFollowing, + style: const TextStyle( + fontSize: _barFontSize, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/following/following_favorites_grid.dart b/lib/pages/following/following_favorites_grid.dart new file mode 100644 index 0000000..11211c6 --- /dev/null +++ b/lib/pages/following/following_favorites_grid.dart @@ -0,0 +1,118 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../core/data/services/favorite_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../l10n/app_localizations.dart'; +import '../player/open_player.dart'; +import 'following_grid_card.dart'; +import 'following_paged_grid.dart'; + +/// 追剧编辑中的选中状态。 +class FollowingFavoriteSelection { + const FollowingFavoriteSelection({ + required this.editing, + required this.selected, + required this.onToggle, + }); + + final ValueListenable editing; + final ValueListenable> selected; + final ValueChanged onToggle; +} + +/// 追剧列表。编辑时点卡片只切换选中,不进入播放。 +class FollowingFavoritesGrid extends StatelessWidget { + const FollowingFavoritesGrid({ + super.key, + required this.controller, + required this.selection, + required this.scrollController, + required this.onGoHome, + }); + + final FavoriteController controller; + final FollowingFavoriteSelection selection; + final ScrollController scrollController; + final VoidCallback onGoHome; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: selection.editing, + builder: (context, isEditing, _) { + return ValueListenableBuilder>( + valueListenable: selection.selected, + builder: (context, picked, _) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) => _grid(context, isEditing, picked), + ); + }, + ); + }, + ); + } + + Widget _grid(BuildContext context, bool isEditing, Set picked) { + final l10n = AppLocalizations.of(context)!; + final extra = isEditing ? 0 : 1; + return FollowingPagedGrid( + key: const Key('following-favorites-grid'), + scrollController: scrollController, + loading: controller.loading, + loadingMore: controller.loadingMore, + hasMore: controller.hasMore, + hasItems: controller.cards.isNotEmpty, + showInitialLoading: false, + errorMessage: controller.error == null + ? null + : errorText(l10n, controller.error, l10n.favoritesError), + loadMoreError: controller.loadMoreError == null + ? null + : errorText(l10n, controller.loadMoreError, l10n.favoritesError), + onRefresh: controller.load, + onLoadMore: controller.loadMore, + itemCount: controller.cards.length + extra, + empty: const SizedBox.shrink(), + itemBuilder: (context, index) => _item(context, index, isEditing, picked), + ); + } + + Widget _item( + BuildContext context, + int index, + bool isEditing, + Set picked, + ) { + if (index == controller.cards.length) { + return FollowingAddCard(onTap: onGoHome); + } + final l10n = AppLocalizations.of(context)!; + final drama = controller.cards[index]; + final episode = drama.episodes.isEmpty ? 1 : drama.episodes.first.number; + return FollowingGridCard( + dramaId: drama.id, + title: drama.title, + posterUrl: drama.posterUrl, + viewCount: drama.viewCount, + editing: isEditing, + selected: isEditing ? picked.contains(drama.id) : null, + subtitle: l10n.favoriteEpisodeProgress(episode, drama.episodeCount), + onTap: () => _openOrToggle(context, drama.id, isEditing), + ); + } + + void _openOrToggle(BuildContext context, String dramaId, bool isEditing) { + if (isEditing) { + selection.onToggle(dramaId); + return; + } + final drama = controller.cards.firstWhere((item) => item.id == dramaId); + unawaited(playDramaFromCatalog(context, drama)); + } +} diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index e20f320..870831a 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -7,6 +7,11 @@ import '../../core/theme/app_dimens.dart'; import '../../core/widgets/cover_play_count.dart'; import '../../core/widgets/remote_image.dart'; +const double _selectMarkSize = 22; +const double _selectMarkInset = 6; +const double _selectMarkIconSize = 16; +const double _selectBorderWidth = 1.5; + class FollowingGridCard extends StatelessWidget { const FollowingGridCard({ super.key, @@ -18,6 +23,7 @@ class FollowingGridCard extends StatelessWidget { required this.onTap, this.progress, this.editing = false, + this.selected, this.onRemove, }); @@ -29,6 +35,7 @@ class FollowingGridCard extends StatelessWidget { final VoidCallback onTap; final double? progress; final bool editing; + final bool? selected; final VoidCallback? onRemove; @override @@ -59,7 +66,16 @@ class FollowingGridCard extends StatelessWidget { ), ), ), - if (editing) + if (selected != null) + Positioned( + top: _selectMarkInset, + right: _selectMarkInset, + child: _SelectMark( + key: Key('following-select-$dramaId'), + selected: selected!, + ), + ) + else if (editing) Positioned( top: -2, right: -2, @@ -141,6 +157,35 @@ class FollowingGridCard extends StatelessWidget { } } +class _SelectMark extends StatelessWidget { + const _SelectMark({super.key, required this.selected}); + + final bool selected; + + @override + Widget build(BuildContext context) { + return Container( + width: _selectMarkSize, + height: _selectMarkSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? AppColors.blue : AppColors.transparent, + border: Border.all( + color: selected ? AppColors.blue : AppColors.textPrimary, + width: _selectBorderWidth, + ), + ), + child: selected + ? const Icon( + Icons.check_rounded, + size: _selectMarkIconSize, + color: AppColors.onPrimary, + ) + : null, + ); + } +} + class FollowingAddCard extends StatelessWidget { const FollowingAddCard({super.key, required this.onTap}); diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 4a8e05d..4154daa 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -19,12 +19,14 @@ import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; -import 'following_grid_card.dart'; +import 'following_edit_bar.dart'; +import 'following_favorites_grid.dart'; import 'following_history_card.dart'; import 'following_keep_alive_tab.dart'; import 'following_paged_grid.dart'; -enum FollowingTab { favorites, history } +const double _doneFontSize = 14; +const double _doneTextGap = 12; class FollowingPage extends StatefulWidget { const FollowingPage({ @@ -50,21 +52,33 @@ class _FollowingPageState extends State { FollowingTab.favorites, ); final ValueNotifier _editing = ValueNotifier(false); + final ValueNotifier> _selected = ValueNotifier({}); late final WatchRecordsController _history; + late final AuthController _auth; + bool _wasSignedIn = false; @override void initState() { super.initState(); + _auth = context.read(); + _wasSignedIn = _auth.isSignedIn; + _auth.addListener(_onAuthChanged); _history = WatchRecordsController( history: context.read(), catalog: context.read(), ); - unawaited(_history.load()); + if (_wasSignedIn) unawaited(_history.load()); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(widget.favoriteController.load()); }); } + void _onAuthChanged() { + final signedIn = _auth.isSignedIn; + if (signedIn && !_wasSignedIn) unawaited(_history.load()); + _wasSignedIn = signedIn; + } + @override void dispose() { _pageController.dispose(); @@ -72,6 +86,8 @@ class _FollowingPageState extends State { _historyScroll.dispose(); _tab.dispose(); _editing.dispose(); + _selected.dispose(); + _auth.removeListener(_onAuthChanged); _history.dispose(); super.dispose(); } @@ -106,12 +122,15 @@ class _FollowingPageState extends State { onPageChanged: _onPageChanged, children: [ FollowingKeepAliveTab( - child: _FavoritesGrid( + child: FollowingFavoritesGrid( controller: widget.favoriteController, - editing: _editing, + selection: FollowingFavoriteSelection( + editing: _editing, + selected: _selected, + onToggle: _toggleDrama, + ), scrollController: _favoriteScroll, onGoHome: widget.onGoHome, - onListEmptied: _exitEditingIfEmpty, ), ), FollowingKeepAliveTab( @@ -125,6 +144,17 @@ class _FollowingPageState extends State { ], ), ), + if (signedIn) + FollowingEditSlot( + tab: _tab, + editing: _editing, + favorites: widget.favoriteController, + selected: _selected, + actions: FollowingEditActions( + onToggleAll: _toggleAll, + onRemove: () => unawaited(_cancelSelected()), + ), + ), ], ), ), @@ -147,36 +177,43 @@ class _FollowingPageState extends State { builder: (context, editing, _) { return Row( children: [ - _tabButton( - FollowingTab.favorites, - l10n.followingTab, - 'favorites', - selectedTab == FollowingTab.favorites, + Expanded( + child: Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: _tabButton( + FollowingTab.favorites, + l10n.followingTab, + 'favorites', + selectedTab == FollowingTab.favorites, + ), + ), + const SizedBox(width: AppDimens.followingTabGap), + Flexible( + fit: FlexFit.loose, + child: _tabButton( + FollowingTab.history, + l10n.watchHistory, + 'history', + selectedTab == FollowingTab.history, + ), + ), + ], + ), ), - const SizedBox(width: AppDimens.followingTabGap), - _tabButton( - FollowingTab.history, - l10n.watchHistory, - 'history', - selectedTab == FollowingTab.history, - ), - const Spacer(), if (editing) - SizedBox( - width: AppDimens.followingEditHitSize, - height: AppDimens.followingEditHitSize, - child: InkWell( - key: const Key('following-edit'), - onTap: _toggleEditing, - child: Align( - alignment: Alignment.centerRight, - child: Text( - l10n.done, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.followingTabFont, - fontWeight: FontWeight.w600, - ), + InkWell( + key: const Key('following-edit'), + onTap: _toggleEditing, + child: Padding( + padding: const EdgeInsets.only(left: _doneTextGap), + child: Text( + l10n.done, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _doneFontSize, + fontWeight: FontWeight.w500, ), ), ), @@ -223,7 +260,9 @@ class _FollowingPageState extends State { onTap: () => _selectTab(tab), child: SizedBox( height: AppDimens.followingHeaderHeight, - child: Center( + child: Align( + alignment: Alignment.centerLeft, + widthFactor: 1, child: AnimatedDefaultTextStyle( duration: AppDimens.followingTabAnimationDuration, style: TextStyle( @@ -233,7 +272,7 @@ class _FollowingPageState extends State { fontSize: AppDimens.followingTabFont, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), - child: Text(label, maxLines: 1), + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), ), ), ), @@ -241,7 +280,7 @@ class _FollowingPageState extends State { } void _selectTab(FollowingTab tab) { - _editing.value = false; + _leaveEditing(); _tab.value = tab; if (_pageController.hasClients) { unawaited( @@ -256,99 +295,50 @@ class _FollowingPageState extends State { void _onPageChanged(int index) { final tab = FollowingTab.values[index]; - _editing.value = false; + _leaveEditing(); _tab.value = tab; + if (tab == FollowingTab.history && _auth.isSignedIn) { + unawaited(_history.load()); + } } void _toggleEditing() { _editing.value = !_editing.value; + if (!_editing.value) _selected.value = {}; + } + + void _leaveEditing() { + _editing.value = false; + _selected.value = {}; + } + + void _toggleDrama(String id) { + final next = {..._selected.value}; + if (!next.add(id)) next.remove(id); + _selected.value = next; + } + + void _toggleAll(Set ids) { + final picked = _selected.value; + final all = ids.isNotEmpty && ids.every(picked.contains); + _selected.value = all ? {} : ids; + } + + Future _cancelSelected() async { + final ids = _selected.value.toList(); + if (ids.isEmpty || !mounted || !await confirmCancelFollowing(context)) { + return; + } + if (!mounted) return; + final removed = await widget.favoriteController.removeMany(ids); + if (!removed || !mounted) return; + _selected.value = {}; + if (widget.favoriteController.cards.isEmpty) _editing.value = false; } void _exitEditingIfEmpty() => _editing.value = false; } -class _FavoritesGrid extends StatelessWidget { - const _FavoritesGrid({ - required this.controller, - required this.editing, - required this.scrollController, - required this.onGoHome, - required this.onListEmptied, - }); - - final FavoriteController controller; - final ValueListenable editing; - final ScrollController scrollController; - final VoidCallback onGoHome; - final VoidCallback onListEmptied; - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: editing, - builder: (context, isEditing, _) { - return ListenableBuilder( - listenable: controller, - builder: (context, _) { - final l10n = AppLocalizations.of(context)!; - return FollowingPagedGrid( - key: const Key('following-favorites-grid'), - scrollController: scrollController, - loading: controller.loading, - loadingMore: controller.loadingMore, - hasMore: controller.hasMore, - hasItems: controller.cards.isNotEmpty, - showInitialLoading: false, - errorMessage: controller.error == null - ? null - : errorText(l10n, controller.error, l10n.favoritesError), - loadMoreError: controller.loadMoreError == null - ? null - : errorText( - l10n, - controller.loadMoreError, - l10n.favoritesError, - ), - onRefresh: controller.load, - onLoadMore: controller.loadMore, - itemCount: controller.cards.length + 1, - empty: const SizedBox.shrink(), - itemBuilder: (context, index) { - if (index == controller.cards.length) { - return FollowingAddCard(onTap: onGoHome); - } - final drama = controller.cards[index]; - final episode = drama.episodes.isEmpty - ? 1 - : drama.episodes.first.number; - return FollowingGridCard( - dramaId: drama.id, - title: drama.title, - posterUrl: drama.posterUrl, - viewCount: drama.viewCount, - editing: isEditing, - subtitle: l10n.favoriteEpisodeProgress( - episode, - drama.episodeCount, - ), - onTap: () => unawaited(playDramaFromCatalog(context, drama)), - onRemove: () async { - if (await _confirmRemove(context) && - await controller.remove(drama.id) && - controller.cards.isEmpty) { - onListEmptied(); - } - }, - ); - }, - ); - }, - ); - }, - ); - } -} - class _HistoryList extends StatelessWidget { const _HistoryList({ required this.controller, @@ -415,7 +405,9 @@ class _HistoryList extends StatelessWidget { title: _historyTitle(l10n, record), posterUrl: record.displayCover, viewCount: - record.drama?.viewCount ?? record.progress.playCount, + record.progress.viewCount ?? + record.drama?.viewCount ?? + record.progress.playCount, progress: hasProgress ? progress : null, progressLabel: hasProgress ? l10n.watchedPercent(watchedPercent) @@ -425,7 +417,7 @@ class _HistoryList extends StatelessWidget { onTap: openRecord, onContinue: openRecord, onRemove: () async { - if (await _confirmRemove(context) && + if (await confirmHistoryRemove(context) && await controller.remove(record.dramaId) && controller.records.isEmpty) { onListEmptied(); @@ -469,24 +461,3 @@ Future _openHistory( episodeId: record.progress.episodeId, ); } - -Future _confirmRemove(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - return await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - content: Text(l10n.confirmRemove), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(l10n.cancel), - ), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: Text(l10n.remove), - ), - ], - ), - ) ?? - false; -} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 1b8877a..ecc5ad3 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -174,17 +174,21 @@ class _HomeTabsState extends State<_HomeTabs> { } Widget _body(BuildContext context) { + final pages = [ + _homeGrid(context), + _recommend(context), + FollowingPage( + onGoHome: () => logic.select(0), + favoriteController: context.read(), + historyRequest: logic.followingHistoryRequest, + ), + _my(context), + ]; return IndexedStack( index: logic.tab, children: [ - _homeGrid(context), - _recommend(context), - FollowingPage( - onGoHome: () => logic.select(0), - favoriteController: context.read(), - historyRequest: logic.followingHistoryRequest, - ), - _my(context), + for (var index = 0; index < pages.length; index++) + TickerMode(enabled: logic.tab == index, child: pages[index]), ], ); } @@ -206,6 +210,8 @@ class _HomeTabsState extends State<_HomeTabs> { return RecommendPage( feedController: widget.feedController, active: logic.tab == 1, + videoOnly: logic.videoImmersive, + onImmersiveChanged: logic.onImmersiveChanged, onSearch: () => _openSearch(context), ); } diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 5fbce27..880cf89 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -207,7 +207,7 @@ class _HomeFeedGridState extends State { if (dramas.isEmpty) SliverFillRemaining( hasScrollBody: false, - child: _emptyState(l10n, tab, tag), + child: _emptyState(l10n), ) else SliverPadding( @@ -360,7 +360,7 @@ class _HomeFeedGridState extends State { } } - Widget _emptyState(AppLocalizations l10n, HomePrimaryTab tab, String? tag) { + Widget _emptyState(AppLocalizations l10n) { if (widget.feedController.lastError != null) { return EmptyState( key: const Key('home-grid-error'), @@ -370,19 +370,10 @@ class _HomeFeedGridState extends State { onAction: () => unawaited(widget.feedController.refreshCurrent()), ); } - final filtered = - tab == HomePrimaryTab.costume || - tab == HomePrimaryTab.community || - tag != null; return EmptyState( key: const Key('home-grid-empty'), icon: Icons.movie_filter_outlined, - title: filtered ? l10n.homeGridFilterEmpty : l10n.noStories, - subtitle: filtered ? null : l10n.emptyHint, - actionLabel: filtered ? l10n.homeGridClearFilter : null, - onAction: filtered - ? () => setState(() => _selectedTags[tab] = null) - : null, + title: l10n.emptyContent, ); } } diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart index 3d69297..4196b8c 100644 --- a/lib/pages/mine/mine_types.dart +++ b/lib/pages/mine/mine_types.dart @@ -12,8 +12,6 @@ enum MineDestination { wallet, vip, preferences, - works, - creatorCenter, notices, settings, help, diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 9105a09..4b74e4e 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -158,7 +158,10 @@ class _MyPageState extends State { padding: const EdgeInsets.symmetric( horizontal: AppDimens.pageHorizontal, ), - child: MineHeader(onLogin: _openLogin, onEdit: _openEditActions), + child: MineHeader( + onLogin: _openLogin, + onEdit: () => _push(const AccountProfilePage()), + ), ), Padding( padding: const EdgeInsets.symmetric( @@ -202,7 +205,7 @@ class _MyPageState extends State { horizontal: AppDimens.mineGridHorizontal, ), child: MineMoreServices( - onCommonQuestions: () => _pushPlaceholder(MinePlaceholderKind.help), + onCommonQuestions: _pushPlaceholder, onAbout: () => _push(const MineAboutPage()), ), ), @@ -222,16 +225,12 @@ class _MyPageState extends State { _openVip(); case MineDestination.preferences: _push(const FilterPage()); - 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); + _pushPlaceholder(); } } @@ -248,66 +247,12 @@ class _MyPageState extends State { _push(const WatchRecordsPage()); } - Future _openEditActions() async { - final selected = await showModalBottomSheet( - context: context, - backgroundColor: AppColors.transparent, - showDragHandle: false, - builder: (sheetContext) => Material( - color: AppColors.mineCard, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(AppDimens.mineCardRadius), - ), - clipBehavior: Clip.antiAlias, - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - key: const Key('mine-edit-profile'), - title: Text(AppLocalizations.of(sheetContext)!.accountProfile), - onTap: () => Navigator.pop(sheetContext, 'profile'), - ), - ListTile( - key: const Key('mine-edit-works'), - title: Text(AppLocalizations.of(sheetContext)!.myWorks), - onTap: () => Navigator.pop(sheetContext, 'works'), - ), - ListTile( - key: const Key('mine-edit-creator'), - title: Text(AppLocalizations.of(sheetContext)!.creatorCenter), - onTap: () => Navigator.pop(sheetContext, 'creator'), - ), - ListTile( - key: const Key('mine-edit-notices'), - title: Text(AppLocalizations.of(sheetContext)!.notices), - onTap: () => Navigator.pop(sheetContext, 'notices'), - ), - ], - ), - ), - ), - ); - if (!mounted || selected == null) return; - switch (selected) { - case 'profile': - _push(const AccountProfilePage()); - case 'works': - _pushPlaceholder(MinePlaceholderKind.works); - case 'creator': - _pushPlaceholder(MinePlaceholderKind.creator); - case 'notices': - _push(const NoticesPage()); - } - } - void _push(Widget page) { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } - void _pushPlaceholder(MinePlaceholderKind kind) { - _push(_MinePlaceholderPage(kind: kind)); + void _pushPlaceholder() { + _push(const _MinePlaceholderPage()); } Future _openProtected(Widget page) async { @@ -431,21 +376,13 @@ class _LanguageSheet extends StatelessWidget { } } -enum MinePlaceholderKind { works, creator, help } - class _MinePlaceholderPage extends StatelessWidget { - const _MinePlaceholderPage({required this.kind}); - - final MinePlaceholderKind kind; + const _MinePlaceholderPage(); @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, - }; + final title = l10n.helpFeedback; return PlainPageScaffold( pageKey: const Key('mine-placeholder-page'), backKey: const Key('mine-placeholder-back'), diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index a73dde6..94bb6f3 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -14,7 +14,7 @@ import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; const double _buttonHeight = 34; -const double _buttonRadius = 20; +const double _buttonRadius = 6; /// 「我的」页用户区。 class MineHeader extends StatelessWidget { diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index a146200..220b073 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -209,7 +209,14 @@ class MineMenuList extends StatelessWidget { label: l10n.settings, onTap: () => onOpen(MineDestination.settings), ), - _cell(index: 7), + _cell( + index: 7, + actionKey: const Key('mine-notices'), + icon: Icons.notifications_none_rounded, + iconColor: AppColors.mineIconNotices, + label: l10n.notices, + onTap: () => onOpen(MineDestination.notices), + ), ], ), ], diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 8cc6877..6c47780 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; @@ -88,6 +89,9 @@ class PlayerLogic extends ChangeNotifier { String? blockedEpisodeId; List sessionEpisodes = const []; List qualities = const []; + List qualityTracks = const []; + String selectedQuality = 'AUTO'; + Duration? _qualitySeek; int pageRequest = 0; bool muted = false; double playbackSpeed = 1.0; @@ -426,6 +430,7 @@ class PlayerLogic extends ChangeNotifier { blockedTicket = null; blockedEpisodeId = null; qualities = const []; + qualityTracks = const []; sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath)); return true; } @@ -437,10 +442,18 @@ class PlayerLogic extends ChangeNotifier { blockedTicket = null; blockedEpisodeId = null; qualities = ticket.qualities; - if (ticket.accessible && ticket.playUrl.isNotEmpty) { + qualityTracks = ticket.tracks; + final preferred = qualityPlayUrl( + quality: selectedQuality, + tracks: qualityTracks, + currentUrl: ticket.playUrl, + ); + final playUrl = preferred ?? ticket.playUrl; + if (preferred == null) selectedQuality = 'AUTO'; + if (ticket.accessible && playUrl.isNotEmpty) { // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) sessionEpisodes[index] = episode.withVideoUrl( - ticket.playUrl, + playUrl, likeCount: ticket.likeCount, commentCount: ticket.commentCount, thumbnailsUrl: ticket.thumbnailsUrl, @@ -470,6 +483,64 @@ class PlayerLogic extends ChangeNotifier { String _fileUrl(String path) => path.startsWith('file:') ? path : Uri.file(path).toString(); + /// 切到指定清晰度。新片源出画后再替换,失败则继续播原来的。 + Future selectQuality(String quality) async { + final index = session.currentIndex; + if (index < 0 || index >= sessionEpisodes.length) return false; + final current = sessionEpisodes[index]; + final url = qualityPlayUrl( + quality: quality, + tracks: qualityTracks, + currentUrl: current.videoUrl, + ); + if (url == null || url.isEmpty) return false; + if (selectedQuality.toUpperCase() == quality.toUpperCase() && + current.videoUrl == url) { + return true; + } + final previousQuality = selectedQuality; + final active = session.active; + if (active is VideoPlayerPort && active.controller.value.isInitialized) { + _qualitySeek = active.controller.value.position; + } + selectedQuality = quality.toUpperCase(); + sessionEpisodes[index] = current.withVideoUrl(url); + final switched = await session.replaceCurrent(); + if (switched) { + if (isMounted()) notify(); + return true; + } + _qualitySeek = null; + selectedQuality = previousQuality; + if (index < sessionEpisodes.length) { + sessionEpisodes[index] = current; + } + if (isMounted()) notify(); + return false; + } + + Future _waitUntilFrameReady(VideoPlayerController controller) async { + if (controller.value.hasError) { + throw StateError('quality source failed'); + } + if (!controller.value.isBuffering) return; + final done = Completer(); + void onChange() { + final value = controller.value; + if (done.isCompleted) return; + if (value.hasError || !value.isBuffering) done.complete(); + } + controller.addListener(onChange); + try { + await done.future.timeout(const Duration(seconds: 4)); + } finally { + controller.removeListener(onChange); + } + if (controller.value.hasError) { + throw StateError('quality source failed'); + } + } + /// 重试当前集的票据(失败态的"重试"按钮)。 Future retryTicket() async { ticketError = null; @@ -667,6 +738,15 @@ class PlayerLogic extends ChangeNotifier { if (player is! VideoPlayerPort) return; try { await player.controller.setPlaybackSpeed(playbackSpeed); + final override = _qualitySeek; + _qualitySeek = null; + if (override != null && override > Duration.zero) { + await player.seekTo( + clampResumePosition(override, player.controller.value.duration), + ); + await _waitUntilFrameReady(player.controller); + return; + } await pendingWrite; // 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播 if (resumePoint == null) await loadResumePoint(); @@ -689,7 +769,7 @@ class PlayerLogic extends ChangeNotifier { ); } } catch (_) { - // 续看位置取不到不影响播放:从头播。 + // 续看或清晰度定位失败时仍用已初始化的播放器,不把切换判失败。 } } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index e1e231e..f111dee 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -363,7 +363,7 @@ class _PlayerPageState extends State final lastDoubleTapAt = _lastDoubleTapAt; return lastDoubleTapAt != null && DateTime.now().difference(lastDoubleTapAt) < - const Duration(milliseconds: 350); + const Duration(milliseconds: 500); } void _handleDoubleTapLike( @@ -545,14 +545,18 @@ class _PlayerPageState extends State (selected ? logic.session.active : null) ?? logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; - final ready = videoPort?.controller.value.isInitialized ?? false; + final handoff = selected ? logic.session.handoffAt(index) : null; + final handoffPort = handoff is VideoPlayerPort ? handoff : null; + // 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。 + final videoReady = videoPort?.controller.value.isInitialized ?? false; + final ready = videoReady || port != null; final theater = logic.pip || logic.pipPreparing; final locked = logic.session.isLocked || episode.locked; - if (selected && ready) { + if (selected && videoPort != null && videoPort.controller.value.isInitialized) { unawaited( _spriteAxisService.prepareAt( episode.thumbnailsUrl, - videoPort!.controller.value.position, + videoPort.controller.value.position, ), ); } @@ -567,11 +571,30 @@ class _PlayerPageState extends State ), PlayerVideoSurface( controller: videoPort?.controller, + handoff: handoffPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, ), + if (selected && + !theater && + !ready && + !logic.ticketLoading && + logic.ticketError == null && + logic.session.error == null && + logic.blockedEpisodeId != episode.id && + !logic.session.isLocked) + const Center( + child: SizedBox.square( + dimension: _playerLoadingSize, + child: CircularProgressIndicator( + key: Key('player-video-loading'), + color: AppColors.blue, + strokeWidth: _playerLoadingStrokeWidth, + ), + ), + ), // 票据三态之一:起播前正在向后端要签名地址 - if (selected && logic.ticketLoading) + if (selected && !theater && logic.ticketLoading) const Center( child: SizedBox.square( dimension: _playerLoadingSize, @@ -583,7 +606,10 @@ class _PlayerPageState extends State ), ), // 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载 - if (selected && !logic.ticketLoading && logic.ticketError != null) + if (selected && + !theater && + !logic.ticketLoading && + logic.ticketError != null) Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 36), @@ -610,7 +636,7 @@ class _PlayerPageState extends State ), ), ), - if (selected && !ready && logic.session.error != null) + if (selected && !theater && !ready && logic.session.error != null) Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 36), @@ -634,6 +660,7 @@ class _PlayerPageState extends State ), // 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程) if (selected && + !theater && !ready && logic.blockedEpisodeId != episode.id && logic.session.error == null && @@ -668,7 +695,7 @@ class _PlayerPageState extends State l10n: l10n, drama: drama, episode: episode, - controller: ready ? videoPort!.controller : null, + controller: videoReady ? videoPort!.controller : null, locked: locked, visible: _chromeVisible, boosting: _boosting, @@ -699,11 +726,11 @@ class _PlayerPageState extends State }, onEpisodes: () => logic.showEpisodes(context), onMore: _openCurrentMore, - onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onScrubStart: videoReady ? () => logic.beginScrub(videoPort!) : null, onScrubEnd: _finishFeedScrub, onScrubVisibilityChanged: _setScrubbing, cancelScrubRequest: widget.scrubCancelRequest, - onSeek: ready + onSeek: videoReady ? (position) => logic.endScrub(context, videoPort!, position) : null, thumbnailService: _thumbnailService, @@ -715,7 +742,7 @@ class _PlayerPageState extends State l10n: l10n, drama: drama, episode: episode, - controller: ready ? videoPort!.controller : null, + controller: videoReady ? videoPort!.controller : null, locked: locked, liked: interactions.isEpisodeLiked(episode.id), likeCount: @@ -751,14 +778,14 @@ class _PlayerPageState extends State onSave: () => unawaited(_toggleSave(context, drama)), onShare: () => logic.shareDrama(context, drama), onMore: () => _openMore(context, episode), - onScrubStart: ready + onScrubStart: videoReady ? () { _setScrubbing(true); logic.beginScrub(videoPort!); } : null, onScrubEnd: () => _setScrubbing(false), - onSeek: ready + onSeek: videoReady ? (position) => logic.seekTo(context, videoPort!, position) : null, onLongPressCenter: () => _openMore(context, episode), @@ -768,7 +795,10 @@ class _PlayerPageState extends State controlsVisible: _chromeVisible, ), // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 - if (selected && !ready && logic.blockedEpisodeId == episode.id) + if (selected && + !theater && + !ready && + logic.blockedEpisodeId == episode.id) PlayerBlockedNotice( l10n: l10n, episode: episode, diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 171047a..e8af459 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -27,12 +27,14 @@ extension _PlayerViewActions on _PlayerPageState { state: PlayerMoreState( speed: _playbackSpeed, qualities: logic.qualities, + selectedQuality: logic.selectedQuality, ), actions: PlayerMoreActions( onSpeedChanged: (value) { _updatePlaybackSpeed(value); unawaited(logic.setPlaybackSpeed(value)); }, + onQualityChanged: (quality) => _switchQuality(context, l10n, quality), onClear: _hideChrome, onDownload: () => requestEpisodeDownload(context, episode.id), onPip: logic.pictureInPicture.isPlatformSupported @@ -43,6 +45,19 @@ extension _PlayerViewActions on _PlayerPageState { ); } + Future _switchQuality( + BuildContext context, + AppLocalizations l10n, + String quality, + ) async { + Navigator.of(context).pop(); + final switched = await logic.selectQuality(quality); + if (!context.mounted || !switched) return false; + final label = quality.toUpperCase() == 'AUTO' ? l10n.qualityAuto : quality; + showCenteredToast(context, l10n.qualitySwitched(label)); + return true; + } + Future _openUnlockSheet( BuildContext context, int index, diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 5f000f7..85f3d7e 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -401,11 +401,12 @@ class _CommentPostSuccessOverlay extends StatelessWidget { Widget build(BuildContext context) { return Positioned.fill( child: IgnorePointer( - child: SafeArea( - child: Align( - alignment: const Alignment(0, 0.55), - child: Semantics( - liveRegion: true, + child: Align( + alignment: const Alignment(0, -0.4), + child: Semantics( + liveRegion: true, + child: Material( + type: MaterialType.transparency, child: Container( key: const Key('comment-post-success-snackbar'), padding: const EdgeInsets.symmetric( @@ -422,6 +423,8 @@ class _CommentPostSuccessOverlay extends StatelessWidget { style: const TextStyle( color: AppColors.onPrimary, fontSize: 14, + decoration: TextDecoration.none, + decorationColor: AppColors.transparent, ), ), ), diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index f1f8eed..338004d 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -110,12 +110,16 @@ class HomeFeedOverlay extends StatefulWidget { } class _HomeFeedOverlayState extends State { - static const Duration _singleTapDelay = Duration(milliseconds: 320); + static const Duration _singleTapDelay = Duration(milliseconds: 400); bool _scrubbing = false; bool _wasVisibleBeforeScrub = true; Timer? _singleTapTimer; - DateTime? _lastTapAt; + DateTime? _lastDownAt; + DateTime? _blockSingleTapUntil; + bool _doubleTapDown = false; + bool _likedThisPair = false; + int _downSerial = 0; int _tapSeq = 0; int _nextHeartBurstId = 0; final List<({int id, Offset position})> _heartBursts = []; @@ -146,6 +150,7 @@ class _HomeFeedOverlayState extends State { builder: (context, constraints) => Listener( onPointerUp: (_) => widget.onBoostEnd(), onPointerCancel: (_) => widget.onBoostEnd(), + onPointerDown: _handlePointerDown, child: GestureDetector( key: const Key('home-feed-gesture'), behavior: HitTestBehavior.opaque, @@ -258,38 +263,66 @@ class _HomeFeedOverlayState extends State { widget.onScrubEnd?.call(wasVisible); } - void _handleTapUp(TapUpDetails details) { - final tapSeq = ++_tapSeq; - final now = widget.now?.call() ?? DateTime.now(); - final lastTap = _lastTapAt; - if (lastTap != null && now.difference(lastTap) <= _singleTapDelay) { - _tapSeq++; - _singleTapTimer?.cancel(); - _singleTapTimer = null; - _lastTapAt = null; - _showHearts(details.localPosition); + DateTime _now() => widget.now?.call() ?? DateTime.now(); + + void _handlePointerDown(PointerDownEvent event) { + final now = _now(); + final previous = _lastDownAt; + _downSerial++; + _lastDownAt = now; + final repeated = + previous != null && now.difference(previous) <= _singleTapDelay; + if (!repeated) { + if (_singleTapBlocked(now)) return; + _doubleTapDown = false; + _likedThisPair = false; return; } - _lastTapAt = now; + _doubleTapDown = true; + _blockSingleTapUntil = now.add(_singleTapDelay); + _cancelPendingSingleTap(); + } + + bool _singleTapBlocked(DateTime now) { + final blockedUntil = _blockSingleTapUntil; + return blockedUntil != null && now.isBefore(blockedUntil); + } + + void _cancelPendingSingleTap() { + _tapSeq++; + _singleTapTimer?.cancel(); + _singleTapTimer = null; + } + + void _handleTapUp(TapUpDetails details) { + final now = _now(); + if (_doubleTapDown || _singleTapBlocked(now)) { + _showDoubleTapLike(details.localPosition); + return; + } + final serial = _downSerial; + final tapSeq = ++_tapSeq; _singleTapTimer?.cancel(); late final Timer pending; pending = Timer(_singleTapDelay, () { - if (identical(_singleTapTimer, pending)) { - _singleTapTimer = null; - } - if (tapSeq != _tapSeq) return; - final latestTap = _lastTapAt; - if (latestTap != null && - (widget.now?.call() ?? DateTime.now()).difference(latestTap) < - _singleTapDelay) { - return; - } - _lastTapAt = null; - if (mounted) widget.onTogglePlay(); + if (!identical(_singleTapTimer, pending)) return; + _singleTapTimer = null; + if (!mounted || tapSeq != _tapSeq || serial != _downSerial) return; + if (_doubleTapDown || _singleTapBlocked(_now())) return; + _lastDownAt = null; + widget.onTogglePlay(); }); _singleTapTimer = pending; } + void _showDoubleTapLike(Offset position) { + if (_likedThisPair) return; + _likedThisPair = true; + _blockSingleTapUntil = _now().add(_singleTapDelay); + _cancelPendingSingleTap(); + _showHearts(position); + } + void _showHearts(Offset position) { final id = _nextHeartBurstId++; setState(() => _heartBursts.add((id: id, position: position))); @@ -302,11 +335,11 @@ class _HomeFeedOverlayState extends State { } void _handleLongPressStart(LongPressStartDetails details, double width) { - _tapSeq++; - _singleTapTimer?.cancel(); - _singleTapTimer = null; - _lastTapAt = null; final third = width / 3; + _tapSeq++; + _cancelPendingSingleTap(); + _doubleTapDown = false; + _lastDownAt = null; final dx = details.localPosition.dx; if (dx < third || dx > width - third) { widget.onBoostStart(); diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 33e8cb4..b6d42f3 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -316,9 +316,13 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { final dragging = _preview != null; final trackHeight = dragging ? _trackActiveHeight : widget.height; final size = dragging ? _thumbActiveSize : _thumbSize; - final thumbSlotLeft = (width * fraction - _thumbActiveSize / 2) - .clamp(0.0, width - _thumbActiveSize) - .toDouble(); + final finger = (width * fraction).clamp(0.0, width).toDouble(); + final thumbSlotLeft = dragging + ? finger - _thumbActiveSize / 2 + : (finger - _thumbActiveSize / 2) + .clamp(0.0, width - _thumbActiveSize) + .toDouble(); + final playedWidth = dragging ? finger : width * fraction; final trackSlotBottom = _lineCenterFromBottom - _trackActiveHeight / 2; final thumbSlotBottom = _lineCenterFromBottom - _thumbActiveSize / 2; return SizedBox( @@ -348,9 +352,10 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { Align( alignment: Alignment.centerLeft, child: AnimatedContainer( - duration: _trackResizeDuration, + key: const Key('home-feed-progress-played'), + duration: dragging ? Duration.zero : _trackResizeDuration, curve: Curves.easeOut, - width: width * fraction, + width: playedWidth, height: trackHeight, color: AppColors.blue, ), diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 2c42f55..b8c1b3a 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -132,17 +132,19 @@ class _HomeFeedInfoPanelState extends State { ), ), const SizedBox(width: 8), - Text( - l10n.favoriteEpisodeProgress( - widget.episode.number, - widget.drama.episodeCount, - ), - key: const Key('home-feed-episode-progress'), - maxLines: 1, - softWrap: false, - style: const TextStyle( - color: AppColors.overlayLabel, - fontSize: _tagFont, + Flexible( + child: Text( + l10n.favoriteEpisodeProgress( + widget.episode.number, + widget.drama.episodeCount, + ), + key: const Key('home-feed-episode-progress'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.overlayLabel, + fontSize: _tagFont, + ), ), ), ], diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 68de8b9..568c86e 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,11 +1,39 @@ // Author: fruit.com.cn -import 'dart:math'; - import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +const int _heartDurationMs = 680; +const double _heartPopPortion = 0.18; +const double _heartStartScale = 0.7; +const double _heartHoldPortion = 0.45; + +const List<_HeartParticle> _hearts = [ + _HeartParticle( + delay: 0, + travel: 108, + drift: 0, + size: 48, + color: AppColors.doubleTapHeartRed, + ), + _HeartParticle( + delay: 50, + travel: 96, + drift: -28, + size: 36, + color: AppColors.doubleTapHeartPink, + ), + _HeartParticle( + delay: 90, + travel: 118, + drift: 26, + size: 40, + color: AppColors.doubleTapHeartRed, + ), +]; + +/// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); @@ -19,30 +47,14 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; - late final List<_HeartParticle> _particles; bool _completed = false; @override void initState() { super.initState(); - final random = Random(); - var delay = 0; - _particles = List.generate(3 + random.nextInt(2), (index) { - if (index > 0) delay += 40 + random.nextInt(21); - return _HeartParticle( - delay: delay, - duration: 600 + random.nextInt(101), - travel: 90 + random.nextInt(31), - drift: -18 + random.nextInt(37), - size: (20 + random.nextInt(7)).toDouble(), - color: random.nextBool() - ? AppColors.doubleTapHeartRed - : AppColors.doubleTapHeartPink, - ); - }); - final total = _particles - .map((particle) => particle.delay + particle.duration) - .reduce(max); + final total = _hearts + .map((heart) => heart.delay + _heartDurationMs) + .reduce((left, right) => left > right ? left : right); _controller = AnimationController( vsync: this, duration: Duration(milliseconds: total), @@ -76,7 +88,8 @@ class _PlayerDoubleTapHeartBurstState extends State return Stack( clipBehavior: Clip.none, children: [ - for (final particle in _particles) _particle(particle, elapsed), + for (var index = 0; index < _hearts.length; index++) + _heart(_hearts[index], elapsed, index), ], ); }, @@ -85,27 +98,32 @@ class _PlayerDoubleTapHeartBurstState extends State ); } - Widget _particle(_HeartParticle particle, double elapsed) { - final progress = ((elapsed - particle.delay) / particle.duration).clamp( + Widget _heart(_HeartParticle heart, double elapsed, int index) { + final progress = ((elapsed - heart.delay) / _heartDurationMs).clamp( 0.0, 1.0, ); if (progress <= 0) return const SizedBox.shrink(); final move = Curves.easeOutCubic.transform(progress); - final scale = progress < 0.3 - ? 0.55 + (1.15 - 0.55) * (progress / 0.3) - : 1.15 - (1.15 - 0.2) * ((progress - 0.3) / 0.7); + final scale = progress < _heartPopPortion + ? _heartStartScale + + (1 - _heartStartScale) * (progress / _heartPopPortion) + : 1.0; + final opacity = progress < _heartHoldPortion + ? 1.0 + : 1 - ((progress - _heartHoldPortion) / (1 - _heartHoldPortion)); return Positioned( - left: particle.drift * move - particle.size / 2, - top: -particle.travel * move, + left: heart.drift * move - heart.size / 2, + top: -heart.size / 2 - heart.travel * move, child: Opacity( - opacity: 1 - Curves.easeIn.transform(progress), + opacity: opacity.clamp(0.0, 1.0), child: Transform.scale( scale: scale, child: Icon( + key: Key('double-tap-heart-$index'), Icons.favorite_rounded, - color: particle.color, - size: particle.size, + color: heart.color, + size: heart.size, ), ), ), @@ -116,7 +134,6 @@ class _PlayerDoubleTapHeartBurstState extends State class _HeartParticle { const _HeartParticle({ required this.delay, - required this.duration, required this.travel, required this.drift, required this.size, @@ -124,9 +141,8 @@ class _HeartParticle { }); final int delay; - final int duration; - final int travel; - final int drift; + final double travel; + final double drift; final double size; final Color color; } diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart index c59d828..f6fbdad 100644 --- a/lib/pages/player/widgets/player_more_sheet.dart +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -22,19 +22,25 @@ const double _segmentTrackRadius = 10; const double _segmentTrackPadding = 4; const double _segmentPadding = 10; 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'; +const List _qualityOptions = ['AUTO', '360P', '480P', '720P', '1080P']; /// 更多面板运行状态。 class PlayerMoreState { /// 创建更多面板状态。 - const PlayerMoreState({required this.speed, required this.qualities}); + const PlayerMoreState({ + required this.speed, + required this.qualities, + this.selectedQuality = 'AUTO', + }); /// 当前倍速。 final double speed; /// 后端下发的清晰度档位。 final List qualities; + + /// 当前选中的档位,如 AUTO / 720P。 + final String selectedQuality; } /// 更多面板动作集合。 @@ -44,6 +50,7 @@ class PlayerMoreActions { required this.onSpeedChanged, required this.onClear, required this.onDownload, + required this.onQualityChanged, this.onPip, }); @@ -56,6 +63,9 @@ class PlayerMoreActions { /// 离线缓存当前集。 final VoidCallback onDownload; + /// 切换清晰度。成功返回 true;失败时面板恢复到上一档。 + final Future Function(String quality) onQualityChanged; + /// 进入小窗播放;平台不支持时为 null。 final VoidCallback? onPip; } @@ -85,6 +95,7 @@ class PlayerMoreSheet extends StatefulWidget { class _PlayerMoreSheetState extends State { late double _speed = widget.state.speed; + late String _quality = widget.state.selectedQuality.toUpperCase(); @override Widget build(BuildContext context) { @@ -247,19 +258,31 @@ class _PlayerMoreSheetState extends State { ); } + Future _chooseQuality(String quality) async { + if (_quality == quality) return; + final previous = _quality; + setState(() => _quality = quality); + final switched = await widget.actions.onQualityChanged(quality); + if (!mounted || switched) return; + setState(() => _quality = previous); + } + + bool _hasQuality(String quality) { + final wanted = quality.toUpperCase(); + for (final item in widget.state.qualities) { + if (item.toUpperCase() == wanted) return true; + } + return false; + } + 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; + final enabled = _hasQuality(quality); return _SegmentButton( key: Key('player-more-quality-$quality'), label: enabled ? quality : '$quality · ${l10n.qualityUnavailable}', - selected: selected, + selected: enabled && _quality == quality, enabled: enabled, - onTap: enabled ? () {} : null, + onTap: enabled ? () => _chooseQuality(quality) : null, ); } diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index be69559..c9612b9 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -220,11 +220,12 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), if (data.boosting) _boostHint(), _bottomProgress(), - // 加速或拖动进度时只留进度条,避免挡住画面。 - Offstage( - offstage: data.boosting || scrubbing, - child: _bottomActions(), - ), + // 加速时收起底栏。拖动进度时底栏只隐藏,占位保留,进度条不挪位。 + scrubbing + ? IgnorePointer( + child: Opacity(opacity: 0, child: _bottomActions()), + ) + : Offstage(offstage: data.boosting, child: _bottomActions()), ], ), ); diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 0e26878..0e4cfd4 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -1,7 +1,6 @@ // Author: fruit.com.cn import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 513dda7..62bfb64 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -10,16 +10,18 @@ const Duration _videoFadeDuration = Duration(milliseconds: 200); const double _errorHorizontal = 32; const int _errorMaxLines = 3; -/// 黑色底层与视频层共用同一控制器,视频首帧就绪后再淡入。 +/// 黑色底层与视频层共用同一控制器。清晰度切换时用仍在播放的旧画面盖住新纹理的黑帧。 class PlayerVideoSurface extends StatefulWidget { const PlayerVideoSurface({ super.key, required this.controller, required this.videoUrl, required this.theater, + this.handoff, }); final VideoPlayerController? controller; + final VideoPlayerController? handoff; final String videoUrl; final bool theater; @@ -48,7 +50,8 @@ class _PlayerVideoSurfaceState extends State { Widget _frame(VideoPlayerValue? value) { final controller = widget.controller; - final reveal = _shouldReveal(value); + final handoff = _liveHandoff(controller); + final reveal = _shouldReveal(value, handoff != null); _logStateChange(value); return Stack( fit: StackFit.expand, @@ -60,7 +63,7 @@ class _PlayerVideoSurfaceState extends State { AnimatedOpacity( key: const Key('player-video-opacity'), opacity: reveal ? 1 : 0, - duration: _videoFadeDuration, + duration: handoff != null ? Duration.zero : _videoFadeDuration, curve: Curves.easeOut, child: reveal && controller != null ? _VideoLayer( @@ -70,6 +73,13 @@ class _PlayerVideoSurfaceState extends State { ) : const SizedBox.expand(), ), + if (handoff != null) + _VideoLayer( + key: const Key('player-video-handoff'), + controller: handoff, + value: handoff.value, + theater: widget.theater, + ), if (kDebugMode && value?.hasError == true) Center( child: Padding( @@ -88,12 +98,18 @@ class _PlayerVideoSurfaceState extends State { ); } - bool _shouldReveal(VideoPlayerValue? value) { - return value != null && - !value.hasError && - value.isInitialized && - value.size.width > 0 && - (value.position > Duration.zero || value.isPlaying); + bool _shouldReveal(VideoPlayerValue? value, bool covered) { + if (value == null || value.hasError || !value.isInitialized) return false; + if (value.size.width <= 0) return false; + if (covered) return true; + return value.position > Duration.zero || value.isPlaying; + } + + /// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。 + VideoPlayerController? _liveHandoff(VideoPlayerController? controller) { + final handoff = widget.handoff; + if (handoff == null || identical(handoff, controller)) return null; + return handoff; } void _logStateChange(VideoPlayerValue? value) { @@ -135,6 +151,7 @@ class _PlayerVideoSurfaceState extends State { class _VideoLayer extends StatelessWidget { const _VideoLayer({ + super.key, required this.controller, required this.value, required this.theater, diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 0e23564..0f91a2d 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -13,6 +13,8 @@ import 'loading_progress_track.dart'; import 'scrub_preview_card.dart'; const Key _inactiveTrackKey = Key('progress-inactive'); +const double _compactTrackSlot = 20; +const double _trackSlot = 44; /// Keeps seeks within the playable range so dragging to the edge does not /// trigger the player's completion callback before playback resumes. @@ -241,11 +243,7 @@ class _SeekProgressBarState extends State { widget.thumbnailService != null && (widget.episodeId?.isNotEmpty ?? false); final trackHeight = scrubbing ? 6.0 : 2.0; - final trackAreaHeight = loading - ? (widget.compact ? 20.0 : 44.0) - : widget.compact - ? (scrubbing ? 32.0 : 20.0) - : (scrubbing ? 48.0 : 44.0); + final trackAreaHeight = widget.compact ? _compactTrackSlot : _trackSlot; return LayoutBuilder( builder: (context, constraints) { diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 3142f0e..bf46206 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -25,12 +25,18 @@ class RecommendPage extends StatefulWidget { required this.feedController, required this.active, required this.onSearch, + this.videoOnly = false, + this.onImmersiveChanged, }); final HomeFeedController feedController; final bool active; final VoidCallback onSearch; + /// 画中画时只留视频,不画顶栏。 + final bool videoOnly; + final ValueChanged? onImmersiveChanged; + @override State createState() => _RecommendPageState(); } @@ -84,40 +90,42 @@ class _RecommendPageState extends State { ), ), ), - Positioned( - top: 0, - left: 0, - right: 0, - child: IgnorePointer( - child: SizedBox( - height: 72, - child: DecoratedBox( - key: const Key('recommend-top-gradient'), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [AppColors.mediaBlack, AppColors.transparent], + if (!widget.videoOnly) + Positioned( + top: 0, + left: 0, + right: 0, + child: IgnorePointer( + child: SizedBox( + height: 72, + child: DecoratedBox( + key: const Key('recommend-top-gradient'), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.mediaBlack, AppColors.transparent], + ), ), ), ), ), ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - bottom: false, - child: RecommendTopNav( - selected: _tab, - onVideo: _tab == RecommendInnerTab.feed, - onSelected: _selectTab, - onSearch: _handleSearch, + if (!widget.videoOnly) + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: RecommendTopNav( + selected: _tab, + onVideo: _tab == RecommendInnerTab.feed, + onSelected: _selectTab, + onSearch: _handleSearch, + ), ), ), - ), ], ), ); @@ -155,6 +163,7 @@ class _RecommendPageState extends State { _openDrama(drama, episodeId: episode.id), onRefreshFeed: widget.feedController.refreshCurrent, onLoadMore: widget.feedController.loadMore, + onImmersiveChanged: widget.onImmersiveChanged, scrubCancelRequest: _scrubCancelRequest, ); } diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 9e436bd..f7881da 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -75,13 +75,7 @@ class _AllDramasFilterGridState extends State { child: EmptyState( key: const Key('all-dramas-empty'), icon: Icons.movie_filter_outlined, - title: l10n.homeGridFilterEmpty, - actionLabel: l10n.homeGridClearFilter, - onAction: () => setState(() { - _region = 'all'; - _audio = 'all'; - _genre = null; - }), + title: l10n.emptyContent, ), ) else diff --git a/lib/pages/recommend/widgets/recommend_top_nav.dart b/lib/pages/recommend/widgets/recommend_top_nav.dart index 328b554..907bcca 100644 --- a/lib/pages/recommend/widgets/recommend_top_nav.dart +++ b/lib/pages/recommend/widgets/recommend_top_nav.dart @@ -12,6 +12,7 @@ const double _tabGap = 22; const double _tabFont = 17; const double _searchHitSize = 40; const double _searchIconSize = 26; +const double _minNavWidth = 280; class RecommendTopNav extends StatelessWidget { const RecommendTopNav({ @@ -40,6 +41,22 @@ class RecommendTopNav extends StatelessWidget { final shadows = onVideo ? const [Shadow(color: AppColors.mediaBlack, blurRadius: 6)] : null; + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < _minNavWidth) { + return const SizedBox(key: Key('recommend-top-nav'), height: height); + } + return _bar(context, items, shadows); + }, + ); + } + + Widget _bar( + BuildContext context, + List<(RecommendInnerTab, String, String)> items, + List? shadows, + ) { + final l10n = AppLocalizations.of(context)!; return SizedBox( key: const Key('recommend-top-nav'), height: height, @@ -50,20 +67,23 @@ class RecommendTopNav extends StatelessWidget { child: Stack( alignment: Alignment.center, children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (var index = 0; index < items.length; index++) ...[ - if (index > 0) const SizedBox(width: _tabGap), - _tab( - key: Key('recommend-tab-${items[index].$3}'), - label: items[index].$2, - selected: selected == items[index].$1, - shadows: shadows, - onTap: () => onSelected(items[index].$1), - ), + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < items.length; index++) ...[ + if (index > 0) const SizedBox(width: _tabGap), + _tab( + key: Key('recommend-tab-${items[index].$3}'), + label: items[index].$2, + selected: selected == items[index].$1, + shadows: shadows, + onTap: () => onSelected(items[index].$1), + ), + ], ], - ], + ), ), Align( alignment: Alignment.centerRight, diff --git a/lib/pages/settings/legal_links_card.dart b/lib/pages/settings/legal_links_card.dart new file mode 100644 index 0000000..020f592 --- /dev/null +++ b/lib/pages/settings/legal_links_card.dart @@ -0,0 +1,104 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../core/legal/legal_opener.dart'; +import '../../core/legal/legal_page.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../l10n/app_localizations.dart'; + +/// 设置页里的服务条款和隐私协议。点一行就打开当前语言的公开页。 +class LegalLinksCard extends StatelessWidget { + const LegalLinksCard({super.key, this.open}); + + final Future Function(String url)? open; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final rows = <_LegalRow>[ + _LegalRow( + key: const Key('settings-terms'), + title: l10n.legalTerms, + path: legalTermsPath, + ), + _LegalRow( + key: const Key('settings-privacy'), + title: l10n.legalPrivacy, + path: legalPrivacyPath, + ), + ]; + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (var index = 0; index < rows.length; index++) ...[ + if (index > 0) + const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.outlineVariantDark, + ), + _tile(context, rows[index]), + ], + ], + ), + ); + } + + Widget _tile(BuildContext context, _LegalRow row) { + return InkWell( + key: row.key, + onTap: () => _open(context, row.path), + child: SizedBox( + height: AppDimens.mineMoreRowHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), + child: Row( + children: [ + Expanded( + child: Text( + row.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.listTitle, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textSecondary, + size: AppDimens.accountIcon, + ), + ], + ), + ), + ), + ); + } + + void _open(BuildContext context, String path) { + final url = legalPageUrl(path, Localizations.localeOf(context)); + final custom = open; + if (custom != null) { + unawaited(custom(url)); + return; + } + unawaited(openLegalUrlOrNotify(context, url)); + } +} + +class _LegalRow { + const _LegalRow({required this.key, required this.title, required this.path}); + + final Key key; + final String title; + final String path; +} diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index ed5b20b..02c798d 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -6,16 +6,15 @@ 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_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/account_entry_card.dart'; -import '../notices/notices_page.dart'; +import 'legal_links_card.dart'; -/// 设置页:收纳账号、通知与登录态,不提供手动语言切换。 +/// 设置页:账号入口按列表展示,不提供通知和手动语言切换。 class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -34,10 +33,10 @@ class SettingsPage extends StatelessWidget { children: [ if (auth.isSignedIn) ...[ const AccountEntryCard(), - const SizedBox(height: 12), + const SizedBox(height: AppDimens.spaceXl), ], - _noticesTile(context, l10n), - const SizedBox(height: 24), + const LegalLinksCard(), + const SizedBox(height: AppDimens.spaceXl), _authAction(context, l10n, auth), ], ), @@ -45,29 +44,6 @@ class SettingsPage extends StatelessWidget { ); } - Widget _noticesTile(BuildContext context, AppLocalizations l10n) { - return Material( - color: AppColors.card, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - side: const BorderSide(color: AppColors.border), - ), - 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, diff --git a/pubspec.yaml b/pubspec.yaml index c6d36cc..0c1a46b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: flutter_localizations: sdk: flutter path_provider: 2.1.5 + html: ^0.15.6 dev_dependencies: flutter_test: diff --git a/test/account/account_pages_test.dart b/test/account/account_pages_test.dart index c73f6fc..88735ff 100644 --- a/test/account/account_pages_test.dart +++ b/test/account/account_pages_test.dart @@ -70,6 +70,24 @@ void main() { auth: auth, ); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + final titleTop = tester + .getTopLeft(find.text(l10n.accountDeactivateReason)) + .dy; + final fieldTop = tester + .getTopLeft(find.byKey(const Key('account-deactivate-reason'))) + .dy; + expect(fieldTop, greaterThan(titleTop)); + expect(find.text(l10n.accountDeactivateReasonUnused), findsOneWidget); + await tester.tap(find.byKey(const Key('account-deactivate-reason-0'))); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const Key('account-deactivate-reason'))) + .controller + ?.text, + l10n.accountDeactivateReasonUnused, + ); await tester.enterText( find.byKey(const Key('account-deactivate-reason')), '不再使用', diff --git a/test/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart index 11d17b6..061012a 100644 --- a/test/account/mine_account_entry_test.dart +++ b/test/account/mine_account_entry_test.dart @@ -61,6 +61,22 @@ void main() { expect(find.byKey(const Key('mine-account-devices')), findsOneWidget); expect(find.byKey(const Key('mine-account-export')), findsOneWidget); expect(find.byKey(const Key('mine-account-deactivate')), findsOneWidget); + expect(find.byKey(const Key('settings-notices')), findsNothing); + final profileTop = tester + .getTopLeft(find.byKey(const Key('mine-account-profile'))) + .dy; + final devicesTop = tester + .getTopLeft(find.byKey(const Key('mine-account-devices'))) + .dy; + final exportTop = tester + .getTopLeft(find.byKey(const Key('mine-account-export'))) + .dy; + final deactivateTop = tester + .getTopLeft(find.byKey(const Key('mine-account-deactivate'))) + .dy; + expect(devicesTop, greaterThan(profileTop)); + expect(exportTop, greaterThan(devicesTop)); + expect(deactivateTop, greaterThan(exportTop)); }); } diff --git a/test/auth/auth_controller_test.dart b/test/auth/auth_controller_test.dart index 630b2fa..56f0d52 100644 --- a/test/auth/auth_controller_test.dart +++ b/test/auth/auth_controller_test.dart @@ -53,70 +53,82 @@ void main() { ); } - test('should_store_session_and_expose_signed_in_state_when_register_succeeds', () async { - late AuthController auth; - final adapter = FakeHttpAdapter((options) async { - expect(options.uri.path, '/app/auth/register'); - expect(options.headers['X-Device-Id'], isNotEmpty); - expect(options.headers.containsKey('Authorization'), isFalse); - return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1')); - }); - final store = AuthSessionStore(); - auth = AuthController( - AuthApi(clientWith(() => auth, adapter: adapter)), - store, - ); - - expect(auth.isSignedIn, isFalse); - await auth.register(email: 'a@b.com', password: 'abcd1234'); - expect(auth.isSignedIn, isTrue); - expect(auth.accessToken, 'a1'); - expect(auth.user?.nickname, 'Tester'); - // 落盘:新建一个 store 也能读回来 - expect((await AuthSessionStore().read())?.refreshToken, 'r1'); - }); - - test('should_refresh_once_and_retry_with_new_token_when_response_code_is_401', () async { - var calls = 0; - late AuthController auth; - final adapter = FakeHttpAdapter((options) async { - calls++; - if (options.uri.path == '/app/auth/refresh') { - return FakeHttpResponse(tokenBody(accessToken: 'a2', refreshToken: 'r2')); - } - if (options.uri.path != '/app/me') { - // 注册等其它请求:直接发第一版令牌 - return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1')); - } - final token = options.headers['Authorization']; - if (token == 'Bearer a1') { + test( + 'should_store_session_and_expose_signed_in_state_when_register_succeeds', + () async { + late AuthController auth; + final adapter = FakeHttpAdapter((options) async { + expect(options.uri.path, '/app/auth/register'); + expect(options.headers['X-Device-Id'], isNotEmpty); + expect(options.headers.containsKey('Authorization'), isFalse); return FakeHttpResponse( - jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}), + tokenBody(accessToken: 'a1', refreshToken: 'r1'), ); - } - expect(token, 'Bearer a2'); - return FakeHttpResponse( - jsonEncode({ - 'code': 0, - 'message': 'ok', - 'data': {'hit': true}, - }), + }); + final store = AuthSessionStore(); + auth = AuthController( + AuthApi(clientWith(() => auth, adapter: adapter)), + store, ); - }); - final store = AuthSessionStore(); - auth = AuthController( - AuthApi(clientWith(() => auth, adapter: adapter)), - store, - ); - await auth.register(email: 'a@b.com', password: 'abcd1234'); - calls = 0; - final api = clientWith(() => auth, adapter: adapter); - final data = await api.get('/app/me'); - expect((data as Map)['hit'], isTrue); - expect(calls, 3); // 原请求(401) + 刷新 + 重试 - expect(auth.accessToken, 'a2'); - }); + expect(auth.isSignedIn, isFalse); + await auth.register(email: 'a@b.com', password: 'abcd1234'); + expect(auth.isSignedIn, isTrue); + expect(auth.accessToken, 'a1'); + expect(auth.user?.nickname, 'Tester'); + // 落盘:新建一个 store 也能读回来 + expect((await AuthSessionStore().read())?.refreshToken, 'r1'); + }, + ); + + test( + 'should_refresh_once_and_retry_with_new_token_when_response_code_is_401', + () async { + var calls = 0; + late AuthController auth; + final adapter = FakeHttpAdapter((options) async { + calls++; + if (options.uri.path == '/app/auth/refresh') { + return FakeHttpResponse( + tokenBody(accessToken: 'a2', refreshToken: 'r2'), + ); + } + if (options.uri.path != '/app/me') { + // 注册等其它请求:直接发第一版令牌 + return FakeHttpResponse( + tokenBody(accessToken: 'a1', refreshToken: 'r1'), + ); + } + final token = options.headers['Authorization']; + if (token == 'Bearer a1') { + return FakeHttpResponse( + jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}), + ); + } + expect(token, 'Bearer a2'); + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'hit': true}, + }), + ); + }); + final store = AuthSessionStore(); + auth = AuthController( + AuthApi(clientWith(() => auth, adapter: adapter)), + store, + ); + await auth.register(email: 'a@b.com', password: 'abcd1234'); + calls = 0; + + final api = clientWith(() => auth, adapter: adapter); + final data = await api.get('/app/me'); + expect((data as Map)['hit'], isTrue); + expect(calls, 3); // 原请求(401) + 刷新 + 重试 + expect(auth.accessToken, 'a2'); + }, + ); test('should_clear_session_when_refresh_itself_fails', () async { late AuthController auth; @@ -127,7 +139,9 @@ void main() { ); } if (options.uri.path == '/app/auth/register') { - return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1')); + return FakeHttpResponse( + tokenBody(accessToken: 'a1', refreshToken: 'r1'), + ); } return FakeHttpResponse( jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}), @@ -146,6 +160,40 @@ void main() { expect(await AuthSessionStore().read(), isNull); }); + test( + 'should_keep_session_when_refresh_fails_because_server_is_down', + () async { + late AuthController auth; + final adapter = FakeHttpAdapter((options) async { + if (options.uri.path == '/app/auth/refresh') { + return FakeHttpResponse( + jsonEncode({'code': 500, 'message': '服务重启中', 'data': null}), + ); + } + if (options.uri.path == '/app/auth/register') { + return FakeHttpResponse( + tokenBody(accessToken: 'a1', refreshToken: 'r1'), + ); + } + return FakeHttpResponse( + jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}), + ); + }); + final store = AuthSessionStore(); + auth = AuthController( + AuthApi(clientWith(() => auth, adapter: adapter)), + store, + ); + await auth.register(email: 'a@b.com', password: 'abcd1234'); + + final api = clientWith(() => auth, adapter: adapter); + await expectLater(api.get('/app/me'), throwsA(isA())); + expect(auth.isSignedIn, isTrue); + expect(auth.accessToken, 'a1'); + expect((await AuthSessionStore().read())?.refreshToken, 'r1'); + }, + ); + test('should_clear_local_session_when_sign_out_hits_server_error', () async { late AuthController auth; final adapter = FakeHttpAdapter((options) async { @@ -183,7 +231,9 @@ void main() { final body = (options.data as Map).cast(); expect(body['targetType'], 'PHONE'); expect(body['code'], '123456'); - return FakeHttpResponse(tokenBody(accessToken: 'p1', refreshToken: 'pr1')); + return FakeHttpResponse( + tokenBody(accessToken: 'p1', refreshToken: 'pr1'), + ); }); final store = AuthSessionStore(); auth = AuthController( @@ -205,7 +255,9 @@ void main() { final body = (options.data as Map).cast(); expect(body['targetType'], 'PHONE'); expect(body['code'], '654321'); - return FakeHttpResponse(tokenBody(accessToken: 'p2', refreshToken: 'pr2')); + return FakeHttpResponse( + tokenBody(accessToken: 'p2', refreshToken: 'pr2'), + ); }); auth = AuthController( AuthApi(clientWith(() => auth, adapter: adapter)), diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index 85a005b..f297ba0 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -461,6 +461,7 @@ class _FailingAuthApi extends AuthApi { required String password, required String deviceId, String platform = '', + String osVersion = '', String appVersion = '', }) async { loginCalls++; @@ -474,6 +475,7 @@ class _FailingAuthApi extends AuthApi { required String password, required String deviceId, String platform = '', + String osVersion = '', String appVersion = '', }) async { registerCalls++; @@ -501,6 +503,7 @@ class _PendingAuthApi extends AuthApi { required String password, required String deviceId, String platform = '', + String osVersion = '', String appVersion = '', }) async { loginCalls++; diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index 92481fd..a47e2b9 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -183,6 +183,32 @@ void main() { }, ); + testWidgets( + 'should_show_app_logo_lower_on_the_page_when_landing_opens', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await pumpAuthPage(tester, openEmail: false); + + final image = tester.widget( + find.descendant( + of: find.byKey(const Key('auth-brand-logo')), + matching: find.byType(Image), + ), + ); + expect( + (image.image as AssetImage).assetName, + 'assets/icon/fruitshorts.png', + ); + expect( + tester.getTopLeft(find.byKey(const Key('auth-brand-logo'))).dy, + 84, + ); + }, + ); + testWidgets('should_render_40px_back_button_when_page_is_pushed', ( tester, ) async { @@ -380,6 +406,7 @@ class _PendingAuthApi extends AuthApi { required String password, required String deviceId, String platform = '', + String osVersion = '', String appVersion = '', }) async { loginCalls++; diff --git a/test/auth/phone_auth_sheet_test.dart b/test/auth/phone_auth_sheet_test.dart index 6b08bab..28cc4a9 100644 --- a/test/auth/phone_auth_sheet_test.dart +++ b/test/auth/phone_auth_sheet_test.dart @@ -234,6 +234,7 @@ class _FailingPhoneAuthApi extends AuthApi { required String code, required String deviceId, String platform = '', + String osVersion = '', String appVersion = '', }) async { loginWithPhoneCalls++; diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index fdc9c1c..8de463c 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -431,6 +431,11 @@ void main() { final successText = tester.widget(find.text('发表成功')); expect(successText.style?.color, Colors.white); expect(successText.style?.fontSize, 14); + expect(successText.style?.decoration, TextDecoration.none); + final screen = tester.view.physicalSize / tester.view.devicePixelRatio; + final toast = tester.getRect(successOverlay); + expect(toast.center.dy, lessThan(screen.height * 0.4)); + expect(toast.center.dy, greaterThan(screen.height * 0.2)); final placeholderRect = tester.getRect( find.byKey(const Key('comment-input-placeholder')), ); diff --git a/test/core/legal_page_test.dart b/test/core/legal_page_test.dart new file mode 100644 index 0000000..d85e112 --- /dev/null +++ b/test/core/legal_page_test.dart @@ -0,0 +1,27 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/config/app_config.dart'; +import 'package:fruitshorts/core/legal/legal_page.dart'; + +void main() { + tearDown(AppConfig.clearRuntimeBaseUrl); + + test('should_build_language_page_when_locale_is_supported', () { + AppConfig.applyRuntimeBaseUrl('http://192.168.31.243:8081'); + + expect( + legalPageUrl(legalPrivacyPath, const Locale('zh')), + 'http://192.168.31.243:8081/privacy/zh', + ); + expect( + legalPageUrl(legalTermsPath, const Locale('id')), + 'http://192.168.31.243:8081/terms/id', + ); + expect( + legalPageUrl(legalPrivacyPath, const Locale('fr')), + 'http://192.168.31.243:8081/privacy/en', + ); + }); +} diff --git a/test/core/legal_web_page_test.dart b/test/core/legal_web_page_test.dart new file mode 100644 index 0000000..5f4040b --- /dev/null +++ b/test/core/legal_web_page_test.dart @@ -0,0 +1,33 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/legal/legal_html.dart'; +import 'package:fruitshorts/core/legal/legal_web_page.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; + +void main() { + test('should_keep_article_body_when_page_wraps_html', () { + const html = '

第一行

'; + + expect(legalArticle(html), '

第一行

'); + }); + + testWidgets('should_show_agreement_text_when_page_loads', (tester) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('zh'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: LegalWebPage( + url: 'http://example.test/privacy/zh', + load: (_) async => '

协议正文

', + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('协议正文'), findsOneWidget); + expect(find.byKey(const Key('legal-web-loading')), findsNothing); + }); +} diff --git a/test/core/subpage_header_test.dart b/test/core/subpage_header_test.dart index 16b0024..459d6a8 100644 --- a/test/core/subpage_header_test.dart +++ b/test/core/subpage_header_test.dart @@ -97,8 +97,8 @@ void main() { ); expect(material.color, AppColors.headerScrim); expect(icon.color, AppColors.onSurface); - expect(icon.size, 22); - expect(icon.weight, 600); + expect(icon.size, 34); + expect(icon.weight, 400); final shadow = tester .widget( diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index 0132852..be4b8e3 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -129,13 +129,16 @@ void main() { await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); expect( - find.byKey(const Key('following-remove-the-signal-demo')), + find.byKey(const Key('following-select-the-signal-demo')), findsOneWidget, ); + expect(find.byKey(const Key('following-cancel-follow')), findsOneWidget); - await tester.tap(find.byKey(const Key('following-remove-the-signal-demo'))); + await tester.tap(find.byKey(const Key('following-card-the-signal-demo'))); await tester.pumpAndSettle(); - expect(find.byType(AlertDialog), findsOneWidget); + await tester.tap(find.byKey(const Key('following-cancel-follow'))); + await tester.pumpAndSettle(); + expect(find.text('Unfollow the selected dramas?'), findsOneWidget); await tester.tap( find.descendant( of: find.byType(AlertDialog), @@ -145,7 +148,7 @@ void main() { await tester.pumpAndSettle(); expect( - find.byKey(const Key('following-remove-the-signal-demo')), + find.byKey(const Key('following-select-the-signal-demo')), findsNothing, ); expect(find.byIcon(Icons.edit_outlined), findsOneWidget); @@ -159,6 +162,47 @@ void main() { expect(find.byKey(const Key('following-history-empty')), findsOneWidget); }); + testWidgets('should_unfollow_every_selected_drama_when_edit_confirms', ( + tester, + ) async { + _signIn(); + final catalog = FakeCatalogRepository(); + final dramas = catalog.dramas.take(2).toList(); + final favorites = InMemoryFavoriteRepository( + cards: dramas, + saved: dramas.map((drama) => drama.id).toSet(), + ); + await tester.pumpWidget(_app(catalog: catalog, favorites: favorites)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('following-edit'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('following-select-all'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('following-cancel-follow'))); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(FilledButton), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('following-card-the-signal-demo')), + findsNothing, + ); + expect( + find.byKey(const Key('following-card-city-lights-demo')), + findsNothing, + ); + expect(find.byIcon(Icons.edit_outlined), findsOneWidget); + expect(favorites.removeManyCalls, 1); + }); + testWidgets('should_show_auth_gate_when_signed_out', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); diff --git a/test/library/favorite_controller_test.dart b/test/library/favorite_controller_test.dart index b02b89d..0b56819 100644 --- a/test/library/favorite_controller_test.dart +++ b/test/library/favorite_controller_test.dart @@ -176,6 +176,9 @@ class _PagedFavoriteRepository implements FavoriteRepository { @override Future remove(String dramaId) async {} + @override + Future removeMany(List dramaIds) async {} + @override Future toggle(String dramaId) async => true; } diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 53cebb9..4172c1f 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -28,7 +28,7 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_visitor_user_vip_and_seven_grid_entries', ( + testWidgets('should_render_visitor_user_vip_and_eight_grid_entries', ( tester, ) async { await _pumpApp(tester, signedIn: false); @@ -118,8 +118,9 @@ void main() { Key('mine-help'), Key('mine-language'), Key('mine-settings'), + Key('mine-notices'), ]; - expect(entryKeys, hasLength(7)); + expect(entryKeys, hasLength(8)); for (final entryKey in entryKeys) { expect(find.byKey(entryKey), findsOneWidget); } @@ -155,6 +156,11 @@ void main() { Icons.settings_outlined, AppColors.mineIconSettings, ), + ( + Key('mine-notices'), + Icons.notifications_none_rounded, + AppColors.mineIconNotices, + ), ]; for (final entry in coloredIcons) { final icon = tester.widget( @@ -172,9 +178,9 @@ void main() { expect( find.descendant( of: find.byKey(const Key('mine-menu-cell-7')), - matching: find.byType(Icon), + matching: find.byIcon(Icons.notifications_none_rounded), ), - findsNothing, + findsOneWidget, ); expect( find.descendant( @@ -280,15 +286,12 @@ void main() { ]; expect(signedInOrder, [...signedInOrder]..sort()); + expect(find.byKey(const Key('mine-notices')), findsOneWidget); await tester.tap(find.byKey(const Key('mine-edit'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('mine-edit-profile')), findsOneWidget); - expect(find.byKey(const Key('mine-edit-works')), findsOneWidget); - expect(find.byKey(const Key('mine-edit-creator')), findsOneWidget); - expect(find.byKey(const Key('mine-edit-notices')), findsOneWidget); - await tester.tap(find.byKey(const Key('mine-edit-works'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('mine-placeholder-page')), findsOneWidget); + expect(find.byKey(const Key('account-profile-page')), findsOneWidget); + expect(find.byKey(const Key('mine-edit-works')), findsNothing); + expect(find.byKey(const Key('mine-edit-creator')), findsNothing); expect(tester.takeException(), isNull); }); @@ -353,7 +356,7 @@ void main() { expect(find.byType(FloatingActionButton), findsNothing); }); - testWidgets('should_expose_seven_menu_destinations_in_order', (tester) async { + testWidgets('should_expose_eight_menu_destinations_in_order', (tester) async { tester.view.physicalSize = const Size(390, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -384,6 +387,7 @@ void main() { (Key('mine-history'), MineDestination.history), (Key('mine-watch-preferences'), MineDestination.preferences), (Key('mine-help'), MineDestination.help), + (Key('mine-notices'), MineDestination.notices), ]; for (final row in rows) { await tester.tap(find.byKey(row.$1)); diff --git a/test/mine/settings_page_test.dart b/test/mine/settings_page_test.dart index 3e1a8f0..051003a 100644 --- a/test/mine/settings_page_test.dart +++ b/test/mine/settings_page_test.dart @@ -59,7 +59,8 @@ void main() { 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('settings-notices')), findsNothing); + expect(find.byKey(const Key('mine-account-card')), findsNothing); expect(find.byKey(const Key('server-endpoint-setting')), findsNothing); expect(find.text('Server address'), findsNothing); expect(find.text('English'), findsNothing); diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index f9de1bf..34826d8 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.ensureVisible(find.byKey(const Key('mine-settings'))); + await tester.ensureVisible(find.byKey(const Key('mine-notices'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mine-settings'))); - await tester.pumpAndSettle(); - 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); diff --git a/test/playback/play_ticket_quality_test.dart b/test/playback/play_ticket_quality_test.dart index 14e0267..6f40095 100644 --- a/test/playback/play_ticket_quality_test.dart +++ b/test/playback/play_ticket_quality_test.dart @@ -10,14 +10,30 @@ void main() { 'accessible': true, 'accessType': 'FREE', 'playUrl': '/files/episode/12.m3u8?e=1&s=abc', - 'qualities': ['AUTO', '720P', '1080P'], + 'qualities': ['AUTO', '720p', '1080p'], + 'tracks': [ + {'quality': 'AUTO', 'playUrl': '/files/episode/12.m3u8?e=1&s=abc'}, + {'quality': '720p', 'playUrl': '/files/transcoded/12/720p/index.m3u8?s=1'}, + ], 'likeCount': '17', 'commentCount': 5, }); - expect(ticket.qualities, ['AUTO', '720P', '1080P']); + expect(ticket.qualities, ['AUTO', '720p', '1080p']); + expect(ticket.tracks[1].playUrl, contains('/720p/index.m3u8')); expect(ticket.likeCount, 17); expect(ticket.commentCount, 5); + expect( + siblingRenditionUrl(ticket.playUrl, '720P'), + isNull, + ); + expect( + siblingRenditionUrl( + '/files/transcoded/9/master.m3u8?e=1&s=abc', + '720P', + ), + '/files/transcoded/9/720p/index.m3u8?e=1&s=abc', + ); }); test('should_default_to_empty_qualities_when_field_is_missing', () { diff --git a/test/playback/watch_history_test.dart b/test/playback/watch_history_test.dart index fb7a9c6..415c5ca 100644 --- a/test/playback/watch_history_test.dart +++ b/test/playback/watch_history_test.dart @@ -93,6 +93,7 @@ void main() { 'durationMs': '180000', 'finished': false, 'playCount': '2', + 'viewCount': '1800', }); expect(record.dramaId, '2111012753262120962'); diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index b1f5ad6..a977bbc 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -12,8 +12,8 @@ import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; void main() { - testWidgets('double tap within 320ms never pauses', (tester) async { - for (final delay in [120, 250, 300]) { + testWidgets('double tap within 400ms never pauses', (tester) async { + for (final delay in [120, 250, 300, 380]) { final repository = FakeInteractionRepository(); final haptics = FakeHaptics(); final key = GlobalKey<_HarnessState>(); diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart new file mode 100644 index 0000000..420e3f4 --- /dev/null +++ b/test/player/player_double_tap_hearts_test.dart @@ -0,0 +1,29 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart'; + +void main() { + testWidgets('should_show_three_medium_hearts_when_double_tap_burst_starts', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)), + ), + ); + await tester.pump(const Duration(milliseconds: 160)); + + expect(find.byIcon(Icons.favorite_rounded), findsNWidgets(3)); + expect(_size(tester, 0), 48); + expect(_size(tester, 1), 36); + expect(_size(tester, 2), 40); + }); +} + +double _size(WidgetTester tester, int index) { + return tester.widget(find.byKey(Key('double-tap-heart-$index'))).size!; +} + +void _noop() {} diff --git a/test/player/player_more_sheet_test.dart b/test/player/player_more_sheet_test.dart index 8bfd01a..db3fe27 100644 --- a/test/player/player_more_sheet_test.dart +++ b/test/player/player_more_sheet_test.dart @@ -118,6 +118,75 @@ void main() { expect(disabledText.style?.color, AppColors.subtle); }); + testWidgets('should_enable_quality_when_ticket_uses_lowercase_codes', ( + tester, + ) async { + await _openSheet( + tester, + qualities: const ['AUTO', '360p', '480p', '720p', '1080p'], + ); + + expect(find.text('720P'), findsOneWidget); + expect(find.text('1080P'), findsOneWidget); + expect(find.textContaining('暂不可用'), findsNothing); + final enabled = tester.widget( + find.descendant( + of: find.byKey(const Key('player-more-quality-720P')), + matching: find.byType(Text), + ), + ); + expect(enabled.style?.color, isNot(AppColors.subtle)); + }); + + testWidgets('should_report_selected_quality_when_a_rendition_is_tapped', ( + tester, + ) async { + String? picked; + await _openSheet( + tester, + qualities: const ['AUTO', '720p'], + onQuality: (quality) async { + picked = quality; + return true; + }, + ); + + await tester.ensureVisible(find.byKey(const Key('player-more-quality-720P'))); + await tester.tap(find.byKey(const Key('player-more-quality-720P'))); + await tester.pump(); + + expect(picked, '720P'); + final selected = tester.widget( + find.descendant( + of: find.byKey(const Key('player-more-quality-720P')), + matching: find.byType(Text), + ), + ); + expect(selected.style?.color, AppColors.onSurface); + }); + + testWidgets('should_restore_previous_quality_when_switch_fails', ( + tester, + ) async { + await _openSheet( + tester, + qualities: const ['AUTO', '720p'], + onQuality: (_) async => false, + ); + + await tester.ensureVisible(find.byKey(const Key('player-more-quality-720P'))); + await tester.tap(find.byKey(const Key('player-more-quality-720P'))); + await tester.pumpAndSettle(); + + final restored = tester.widget( + find.descendant( + of: find.byKey(const Key('player-more-quality-AUTO')), + matching: find.byType(Text), + ), + ); + expect(restored.style?.color, AppColors.onSurface); + }); + testWidgets('should_trigger_pip_and_close_sheet_when_tapped', (tester) async { var pipCalls = 0; await _openSheet(tester, onPip: () => pipCalls++); @@ -142,7 +211,12 @@ void main() { }); } -Future _openSheet(WidgetTester tester, {VoidCallback? onPip}) async { +Future _openSheet( + WidgetTester tester, { + VoidCallback? onPip, + Future Function(String quality)? onQuality, + List qualities = const ['AUTO'], +}) async { await tester.pumpWidget( MaterialApp( supportedLocales: AppLocalizations.supportedLocales, @@ -158,11 +232,12 @@ Future _openSheet(WidgetTester tester, {VoidCallback? onPip}) async { isScrollControlled: true, builder: (_) => PlayerMoreSheet( l10n: AppLocalizations.of(context)!, - state: const PlayerMoreState(speed: 1.0, qualities: ['AUTO']), + state: PlayerMoreState(speed: 1.0, qualities: qualities), actions: PlayerMoreActions( onSpeedChanged: (_) {}, onClear: () {}, onDownload: () {}, + onQualityChanged: onQuality ?? (_) async => true, onPip: onPip, ), ), diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index d9357bf..6c647f2 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -69,6 +69,51 @@ void main() { expect(_opacity(tester), 0); expect(find.byKey(const Key('player-debug-video')), findsNothing); }); + + testWidgets('should_cover_new_video_only_while_handoff_is_held', ( + tester, + ) async { + final current = VideoPlayerController.networkUrl( + Uri.parse('http://192.168.31.187:8081/files/720.m3u8'), + ); + final previous = VideoPlayerController.networkUrl( + Uri.parse('http://192.168.31.187:8081/files/360.m3u8'), + ); + addTearDown(current.dispose); + addTearDown(previous.dispose); + const ready = VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration(seconds: 3), + isInitialized: true, + isPlaying: true, + ); + current.value = ready; + previous.value = ready; + + Future pump(VideoPlayerController? handoff) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PlayerVideoSurface( + controller: current, + handoff: handoff, + videoUrl: current.dataSource, + theater: false, + ), + ), + ), + ); + } + + await pump(previous); + expect(find.byKey(const Key('player-video-handoff')), findsOneWidget); + expect(_opacity(tester), 1); + + await pump(null); + expect(find.byKey(const Key('player-video-handoff')), findsNothing); + expect(find.byType(VideoPlayer), findsOneWidget); + }); } Future _pumpSurface( diff --git a/test/player/seek_scrub_visibility_test.dart b/test/player/seek_scrub_visibility_test.dart index d1f0dfa..f670ee1 100644 --- a/test/player/seek_scrub_visibility_test.dart +++ b/test/player/seek_scrub_visibility_test.dart @@ -154,12 +154,37 @@ void main() { await tester.pumpAndSettle(); }); + testWidgets('feed progress thumb stays on the finger when dragged right', ( + tester, + ) async { + await _pumpFeedOverlay(tester); + final bar = find.byKey(const Key('home-feed-progress')); + final gesture = await tester.startGesture(tester.getCenter(bar)); + + await gesture.moveBy(const Offset(800, 0)); + await tester.pump(); + + final thumbCenter = tester + .getRect(find.byKey(const Key('home-feed-progress-thumb'))) + .center + .dx; + final playedRight = tester + .getRect(find.byKey(const Key('home-feed-progress-played'))) + .right; + expect(thumbCenter, closeTo(tester.getRect(bar).right, 0.5)); + expect(playedRight, closeTo(thumbCenter, 0.5)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + testWidgets('full player drag clears chrome and release restores it', ( tester, ) async { await _pumpFullPlayerOverlay(tester); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); + final progressTop = tester.getTopLeft(find.byType(Slider)).dy; final gesture = await tester.startGesture( tester.getCenter(find.byKey(const Key('player-progress'))), @@ -170,8 +195,9 @@ void main() { expect(_opacity(tester, const Key('player-controls-top')), 0); expect(_opacity(tester, const Key('player-interaction-rail')), 0); expect(_offstage(tester, const Key('player-controls-bottom')), isTrue); - expect(_offstage(tester, const Key('player-bottom-actions')), isTrue); + expect(_opacity(tester, const Key('player-bottom-actions')), 0); expect(find.byKey(const Key('player-progress')), findsOneWidget); + expect(tester.getTopLeft(find.byType(Slider)).dy, progressTop); await gesture.up(); await tester.pumpAndSettle(); @@ -179,6 +205,7 @@ void main() { expect(_opacity(tester, const Key('player-controls-top')), 1); expect(_opacity(tester, const Key('player-interaction-rail')), 1); expect(_offstage(tester, const Key('player-bottom-actions')), isFalse); + expect(tester.getTopLeft(find.byType(Slider)).dy, progressTop); }); } diff --git a/test/settings/legal_links_card_test.dart b/test/settings/legal_links_card_test.dart new file mode 100644 index 0000000..c292d07 --- /dev/null +++ b/test/settings/legal_links_card_test.dart @@ -0,0 +1,37 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/config/app_config.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/settings/legal_links_card.dart'; + +void main() { + tearDown(AppConfig.clearRuntimeBaseUrl); + + testWidgets('should_open_terms_and_privacy_for_the_current_language', ( + tester, + ) async { + AppConfig.applyRuntimeBaseUrl('http://192.168.31.243:8081/'); + final opened = []; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('id'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: LegalLinksCard(open: (url) async => opened.add(url)), + ), + ), + ); + + await tester.tap(find.byKey(const Key('settings-terms'))); + await tester.tap(find.byKey(const Key('settings-privacy'))); + await tester.pump(); + + expect(opened, [ + 'http://192.168.31.243:8081/terms/id', + 'http://192.168.31.243:8081/privacy/id', + ]); + }); +} diff --git a/test/support/widget_player.dart b/test/support/widget_player.dart index 4aef63d..f33e059 100644 --- a/test/support/widget_player.dart +++ b/test/support/widget_player.dart @@ -1,10 +1,9 @@ // Author: fruit.com.cn + import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; -// Widget tests exercise navigation and scheduling without a native decoder. -PlayerPort createWidgetPlayer(Episode episode) => WidgetPlayer(); - +/// 组件测试用播放器,不创建原生解码器。 class WidgetPlayer implements PlayerPort { @override Future initialize() async {} @@ -18,3 +17,6 @@ class WidgetPlayer implements PlayerPort { @override Future dispose() async {} } + +/// 组件测试的播放器工厂。 +PlayerPort createWidgetPlayer(Episode episode) => WidgetPlayer(); From b4d38f8e10e4c7387d7b5809393e6f37f479bd09 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 12:45:30 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor(app):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=E6=B3=A8=E9=94=80=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0=E7=BB=84=E4=BB=B6?= 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 | 6 - lib/l10n/app_localizations.dart | 36 ------ lib/l10n/app_localizations_en.dart | 18 --- lib/l10n/app_localizations_id.dart | 18 --- lib/l10n/app_localizations_zh.dart | 18 --- lib/l10n/app_zh.arb | 6 - .../account/account_deactivate_reason.dart | 106 ------------------ test/account/account_pages_test.dart | 18 +-- 9 files changed, 1 insertion(+), 231 deletions(-) delete mode 100644 lib/pages/account/account_deactivate_reason.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 634d00e..1156300 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -595,10 +595,6 @@ "accountDeactivateWarning": "Requests enter a 15-day cooling-off period. All devices are signed out immediately.", "accountDeactivateReason": "Reason (optional)", "accountDeactivateReasonHint": "Add more detail here", - "accountDeactivateReasonUnused": "Not using it anymore", - "accountDeactivateReasonContent": "Not interested in the content", - "accountDeactivateReasonPrivacy": "Privacy concerns", - "accountDeactivateReasonOther": "Other", "accountRequestDeactivate": "Request account deletion", "accountConfirmDeactivateTitle": "Request account deletion?", "accountConfirmDeactivateMessage": "All devices will be signed out immediately. You can cancel during the 15-day cooling-off period after signing in again.", @@ -722,8 +718,6 @@ "homeBadgeHot": "Hot", "homeBadgeNew": "New", "homeBadgeAnime": "Anime", - "homeGridFilterEmpty": "No dramas match these filters", - "homeGridClearFilter": "Clear filters", "emptyContent": "No content yet", "recommendTopTab": "Recommend", "playlistTopTab": "Playlists", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d3e45e9..4e28505 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -559,10 +559,6 @@ "accountDeactivateWarning": "Permintaan masuk masa tenang 15 hari. Semua perangkat langsung keluar.", "accountDeactivateReason": "Alasan (opsional)", "accountDeactivateReasonHint": "Tambahkan penjelasan di sini", - "accountDeactivateReasonUnused": "Tidak lagi digunakan", - "accountDeactivateReasonContent": "Konten tidak menarik", - "accountDeactivateReasonPrivacy": "Masalah privasi", - "accountDeactivateReasonOther": "Lainnya", "accountRequestDeactivate": "Ajukan penghapusan akun", "accountConfirmDeactivateTitle": "Ajukan penghapusan akun?", "accountConfirmDeactivateMessage": "Semua perangkat akan langsung keluar. Setelah masuk lagi, kamu dapat membatalkannya selama masa tenang 15 hari.", @@ -679,8 +675,6 @@ "homeBadgeHot": "Populer", "homeBadgeNew": "Baru", "homeBadgeAnime": "Anime", - "homeGridFilterEmpty": "Tidak ada drama yang cocok dengan filter", - "homeGridClearFilter": "Hapus filter", "emptyContent": "Belum ada konten", "recommendTopTab": "Rekomendasi", "playlistTopTab": "Daftar Putar", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 13fbdd1..f364161 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2488,30 +2488,6 @@ abstract class AppLocalizations { /// **'Add more detail here'** String get accountDeactivateReasonHint; - /// No description provided for @accountDeactivateReasonUnused. - /// - /// In en, this message translates to: - /// **'Not using it anymore'** - String get accountDeactivateReasonUnused; - - /// No description provided for @accountDeactivateReasonContent. - /// - /// In en, this message translates to: - /// **'Not interested in the content'** - String get accountDeactivateReasonContent; - - /// No description provided for @accountDeactivateReasonPrivacy. - /// - /// In en, this message translates to: - /// **'Privacy concerns'** - String get accountDeactivateReasonPrivacy; - - /// No description provided for @accountDeactivateReasonOther. - /// - /// In en, this message translates to: - /// **'Other'** - String get accountDeactivateReasonOther; - /// No description provided for @accountRequestDeactivate. /// /// In en, this message translates to: @@ -3136,18 +3112,6 @@ abstract class AppLocalizations { /// **'Anime'** String get homeBadgeAnime; - /// No description provided for @homeGridFilterEmpty. - /// - /// In en, this message translates to: - /// **'No dramas match these filters'** - String get homeGridFilterEmpty; - - /// No description provided for @homeGridClearFilter. - /// - /// In en, this message translates to: - /// **'Clear filters'** - String get homeGridClearFilter; - /// No description provided for @emptyContent. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 46ee7ee..8a1fe40 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1289,18 +1289,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get accountDeactivateReasonHint => 'Add more detail here'; - @override - String get accountDeactivateReasonUnused => 'Not using it anymore'; - - @override - String get accountDeactivateReasonContent => 'Not interested in the content'; - - @override - String get accountDeactivateReasonPrivacy => 'Privacy concerns'; - - @override - String get accountDeactivateReasonOther => 'Other'; - @override String get accountRequestDeactivate => 'Request account deletion'; @@ -1638,12 +1626,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeBadgeAnime => 'Anime'; - @override - String get homeGridFilterEmpty => 'No dramas match these filters'; - - @override - String get homeGridClearFilter => 'Clear filters'; - @override String get emptyContent => 'No content yet'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 80b23c5..088b6fb 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1288,18 +1288,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get accountDeactivateReasonHint => 'Tambahkan penjelasan di sini'; - @override - String get accountDeactivateReasonUnused => 'Tidak lagi digunakan'; - - @override - String get accountDeactivateReasonContent => 'Konten tidak menarik'; - - @override - String get accountDeactivateReasonPrivacy => 'Masalah privasi'; - - @override - String get accountDeactivateReasonOther => 'Lainnya'; - @override String get accountRequestDeactivate => 'Ajukan penghapusan akun'; @@ -1638,12 +1626,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeBadgeAnime => 'Anime'; - @override - String get homeGridFilterEmpty => 'Tidak ada drama yang cocok dengan filter'; - - @override - String get homeGridClearFilter => 'Hapus filter'; - @override String get emptyContent => 'Belum ada konten'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d8d11f5..08006ac 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1266,18 +1266,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get accountDeactivateReasonHint => '也可以在这里补充说明'; - @override - String get accountDeactivateReasonUnused => '不再使用'; - - @override - String get accountDeactivateReasonContent => '内容不感兴趣'; - - @override - String get accountDeactivateReasonPrivacy => '隐私顾虑'; - - @override - String get accountDeactivateReasonOther => '其他'; - @override String get accountRequestDeactivate => '申请注销账号'; @@ -1613,12 +1601,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeBadgeAnime => '漫剧'; - @override - String get homeGridFilterEmpty => '没有符合筛选条件的短剧'; - - @override - String get homeGridClearFilter => '清除筛选'; - @override String get emptyContent => '暂无内容'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index af96143..1b02daa 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -521,10 +521,6 @@ "accountDeactivateWarning": "提交后进入 15 天冷静期,所有设备会立即退出登录。", "accountDeactivateReason": "注销原因(选填)", "accountDeactivateReasonHint": "也可以在这里补充说明", - "accountDeactivateReasonUnused": "不再使用", - "accountDeactivateReasonContent": "内容不感兴趣", - "accountDeactivateReasonPrivacy": "隐私顾虑", - "accountDeactivateReasonOther": "其他", "accountRequestDeactivate": "申请注销账号", "accountConfirmDeactivateTitle": "确认申请注销?", "accountConfirmDeactivateMessage": "所有设备会立即退出登录。重新登录后,可在 15 天冷静期内撤销申请。", @@ -641,8 +637,6 @@ "homeBadgeHot": "热剧", "homeBadgeNew": "新剧", "homeBadgeAnime": "漫剧", - "homeGridFilterEmpty": "没有符合筛选条件的短剧", - "homeGridClearFilter": "清除筛选", "emptyContent": "暂无内容", "recommendTopTab": "推荐", "playlistTopTab": "剧单", diff --git a/lib/pages/account/account_deactivate_reason.dart b/lib/pages/account/account_deactivate_reason.dart deleted file mode 100644 index 475b364..0000000 --- a/lib/pages/account/account_deactivate_reason.dart +++ /dev/null @@ -1,106 +0,0 @@ -// 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'; -import 'account_deactivate_logic.dart'; - -/// 注销原因:固定选项在标题下换行排列,补充说明留在输入框内。 -class DeactivateReasonField extends StatelessWidget { - const DeactivateReasonField({ - super.key, - required this.controller, - required this.onChanged, - }); - - final TextEditingController controller; - final VoidCallback onChanged; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final options = _options(l10n); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.accountDeactivateReason, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: AppDimens.spaceSm), - Wrap( - spacing: AppDimens.spaceSm, - runSpacing: AppDimens.spaceSm, - children: [ - for (var index = 0; index < options.length; index++) - _chip(options[index], index, options), - ], - ), - const SizedBox(height: AppDimens.spaceMd), - TextField( - key: const Key('account-deactivate-reason'), - controller: controller, - maxLength: AccountDeactivateLogic.maxReasonLength, - minLines: 3, - maxLines: 4, - textAlignVertical: TextAlignVertical.top, - onChanged: (_) => onChanged(), - decoration: InputDecoration( - hintText: l10n.accountDeactivateReasonHint, - floatingLabelBehavior: FloatingLabelBehavior.never, - alignLabelWithHint: true, - contentPadding: const EdgeInsets.all(AppDimens.cardPadding), - ), - ), - ], - ); - } - - Widget _chip(_ReasonOption option, int index, List<_ReasonOption> options) { - final selected = option.custom - ? _customSelected(options) - : controller.text == option.label; - return ChoiceChip( - key: Key('account-deactivate-reason-$index'), - label: Text(option.label), - selected: selected, - onSelected: (_) => _select(option), - ); - } - - bool _customSelected(List<_ReasonOption> options) { - final text = controller.text.trim(); - if (text.isEmpty) return false; - return options - .where((item) => !item.custom) - .every((item) => item.label != text); - } - - void _select(_ReasonOption option) { - controller.text = option.custom ? '' : option.label; - controller.selection = TextSelection.collapsed( - offset: controller.text.length, - ); - onChanged(); - } - - List<_ReasonOption> _options(AppLocalizations l10n) => [ - _ReasonOption(l10n.accountDeactivateReasonUnused), - _ReasonOption(l10n.accountDeactivateReasonContent), - _ReasonOption(l10n.accountDeactivateReasonPrivacy), - _ReasonOption(l10n.accountDeactivateReasonOther, custom: true), - ]; -} - -class _ReasonOption { - const _ReasonOption(this.label, {this.custom = false}); - - final String label; - final bool custom; -} diff --git a/test/account/account_pages_test.dart b/test/account/account_pages_test.dart index 88735ff..d633e5f 100644 --- a/test/account/account_pages_test.dart +++ b/test/account/account_pages_test.dart @@ -71,23 +71,7 @@ void main() { ); final l10n = await AppLocalizations.delegate.load(const Locale('en')); - final titleTop = tester - .getTopLeft(find.text(l10n.accountDeactivateReason)) - .dy; - final fieldTop = tester - .getTopLeft(find.byKey(const Key('account-deactivate-reason'))) - .dy; - expect(fieldTop, greaterThan(titleTop)); - expect(find.text(l10n.accountDeactivateReasonUnused), findsOneWidget); - await tester.tap(find.byKey(const Key('account-deactivate-reason-0'))); - await tester.pumpAndSettle(); - expect( - tester - .widget(find.byKey(const Key('account-deactivate-reason'))) - .controller - ?.text, - l10n.accountDeactivateReasonUnused, - ); + expect(find.text(l10n.accountDeactivateReason), findsOneWidget); await tester.enterText( find.byKey(const Key('account-deactivate-reason')), '不再使用', From b1e95b370c3a41af1a670766ac8ed1bc4dfdcf40 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 13:05:29 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(app):=20=E6=8C=89=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=A0=81=E5=B1=95=E7=A4=BA=E4=B8=AD=E8=8B=B1=E5=8D=B0=E5=B0=BC?= =?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/core/formatting/error_text.dart | 31 ++++++++-- lib/l10n/app_en.arb | 15 +++++ lib/l10n/app_id.arb | 15 +++++ lib/l10n/app_localizations.dart | 90 ++++++++++++++++++++++++++++ lib/l10n/app_localizations_en.dart | 46 ++++++++++++++ lib/l10n/app_localizations_id.dart | 46 ++++++++++++++ lib/l10n/app_localizations_zh.dart | 45 ++++++++++++++ lib/l10n/app_zh.arb | 15 +++++ lib/pages/auth/phone_auth_sheet.dart | 5 +- test/core/error_text_test.dart | 29 +++++++++ 10 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 test/core/error_text_test.dart diff --git a/lib/core/formatting/error_text.dart b/lib/core/formatting/error_text.dart index 8b1876d..ad29a63 100644 --- a/lib/core/formatting/error_text.dart +++ b/lib/core/formatting/error_text.dart @@ -2,14 +2,37 @@ import '../../l10n/app_localizations.dart'; -const int _serverErrorCode = 500; - -/// 用户端不展示内部故障文案。500 与空提示改成可理解的说明,业务提示原样保留。 +/// 按后端 `ErrorCode` 取当前语言文案。未收录的业务码回退服务端 message。 String userFacingApiMessage(AppLocalizations l10n, int code, String message) { - if (code >= _serverErrorCode || message.isEmpty) return l10n.authServerError; + final localized = _apiMessage(l10n, code); + if (localized != null) return localized; + if (message.isEmpty) return l10n.authServerError; return message; } +/// 已定义错误码。500 用服务繁忙,不展示「系统异常」。 +String? _apiMessage(AppLocalizations l10n, int code) { + return switch (code) { + 400 => l10n.apiErrorBadRequest, + 401 => l10n.apiErrorUnauthorized, + 403 => l10n.apiErrorForbidden, + 404 => l10n.apiErrorNotFound, + 429 => l10n.apiErrorTooManyRequests, + 500 => l10n.authServerError, + 100000 => l10n.apiErrorLoginFailed, + 100001 => l10n.apiErrorAccountDisabled, + 100002 => l10n.apiErrorCoinNotEnough, + 100003 => l10n.apiErrorEpisodeUnavailable, + 100004 => l10n.apiErrorEpisodeUnlocked, + 100005 => l10n.apiErrorOrderInvalid, + 100006 => l10n.apiErrorVipExpired, + 200001 => l10n.apiErrorDataExists, + 200002 => l10n.apiErrorDataNotFound, + 200003 => l10n.apiErrorNotAllowed, + _ => null, + }; +} + /// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与 /// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。 String errorText(AppLocalizations l10n, String? code, String fallback) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1156300..2adaab3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -438,6 +438,21 @@ "authPasswordWeak": "Password needs both letters and numbers", "authNetworkError": "Network error, please try again", "authServerError": "The service is busy. Please try again shortly.", + "apiErrorBadRequest": "Invalid request", + "apiErrorUnauthorized": "Sign in to continue", + "apiErrorForbidden": "You don't have permission", + "apiErrorNotFound": "Not found", + "apiErrorTooManyRequests": "Too many requests. Please try again later.", + "apiErrorLoginFailed": "Incorrect account or password", + "apiErrorAccountDisabled": "This account has been disabled", + "apiErrorCoinNotEnough": "Not enough coins", + "apiErrorEpisodeUnavailable": "This episode is unavailable", + "apiErrorEpisodeUnlocked": "This episode is already unlocked", + "apiErrorOrderInvalid": "This order is already paid or closed", + "apiErrorVipExpired": "Membership has expired", + "apiErrorDataExists": "This record already exists", + "apiErrorDataNotFound": "Not found", + "apiErrorNotAllowed": "This action isn't available right now", "authSwitchToSignUp": "New here? Create an account", "authSwitchToSignIn": "Already have an account? Sign in", "authGateTitle": "Sign in to continue", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4e28505..359a0f4 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -412,6 +412,21 @@ "authPasswordWeak": "Kata sandi perlu huruf dan angka", "authNetworkError": "Jaringan bermasalah, coba lagi", "authServerError": "Layanan sedang sibuk. Coba lagi sebentar.", + "apiErrorBadRequest": "Permintaan tidak valid", + "apiErrorUnauthorized": "Masuk untuk melanjutkan", + "apiErrorForbidden": "Anda tidak memiliki izin", + "apiErrorNotFound": "Tidak ditemukan", + "apiErrorTooManyRequests": "Terlalu banyak permintaan. Coba lagi nanti.", + "apiErrorLoginFailed": "Akun atau kata sandi salah", + "apiErrorAccountDisabled": "Akun ini telah dinonaktifkan", + "apiErrorCoinNotEnough": "Koin tidak cukup", + "apiErrorEpisodeUnavailable": "Episode ini tidak tersedia", + "apiErrorEpisodeUnlocked": "Episode ini sudah terbuka", + "apiErrorOrderInvalid": "Pesanan ini sudah dibayar atau ditutup", + "apiErrorVipExpired": "Keanggotaan telah berakhir", + "apiErrorDataExists": "Data sudah ada", + "apiErrorDataNotFound": "Data tidak ditemukan", + "apiErrorNotAllowed": "Tindakan ini tidak tersedia saat ini", "authSwitchToSignUp": "Baru di sini? Buat akun", "authSwitchToSignIn": "Sudah punya akun? Masuk", "authGateTitle": "Masuk untuk melanjutkan", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f364161..3d3e44a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1906,6 +1906,96 @@ abstract class AppLocalizations { /// **'The service is busy. Please try again shortly.'** String get authServerError; + /// No description provided for @apiErrorBadRequest. + /// + /// In en, this message translates to: + /// **'Invalid request'** + String get apiErrorBadRequest; + + /// No description provided for @apiErrorUnauthorized. + /// + /// In en, this message translates to: + /// **'Sign in to continue'** + String get apiErrorUnauthorized; + + /// No description provided for @apiErrorForbidden. + /// + /// In en, this message translates to: + /// **'You don\'t have permission'** + String get apiErrorForbidden; + + /// No description provided for @apiErrorNotFound. + /// + /// In en, this message translates to: + /// **'Not found'** + String get apiErrorNotFound; + + /// No description provided for @apiErrorTooManyRequests. + /// + /// In en, this message translates to: + /// **'Too many requests. Please try again later.'** + String get apiErrorTooManyRequests; + + /// No description provided for @apiErrorLoginFailed. + /// + /// In en, this message translates to: + /// **'Incorrect account or password'** + String get apiErrorLoginFailed; + + /// No description provided for @apiErrorAccountDisabled. + /// + /// In en, this message translates to: + /// **'This account has been disabled'** + String get apiErrorAccountDisabled; + + /// No description provided for @apiErrorCoinNotEnough. + /// + /// In en, this message translates to: + /// **'Not enough coins'** + String get apiErrorCoinNotEnough; + + /// No description provided for @apiErrorEpisodeUnavailable. + /// + /// In en, this message translates to: + /// **'This episode is unavailable'** + String get apiErrorEpisodeUnavailable; + + /// No description provided for @apiErrorEpisodeUnlocked. + /// + /// In en, this message translates to: + /// **'This episode is already unlocked'** + String get apiErrorEpisodeUnlocked; + + /// No description provided for @apiErrorOrderInvalid. + /// + /// In en, this message translates to: + /// **'This order is already paid or closed'** + String get apiErrorOrderInvalid; + + /// No description provided for @apiErrorVipExpired. + /// + /// In en, this message translates to: + /// **'Membership has expired'** + String get apiErrorVipExpired; + + /// No description provided for @apiErrorDataExists. + /// + /// In en, this message translates to: + /// **'This record already exists'** + String get apiErrorDataExists; + + /// No description provided for @apiErrorDataNotFound. + /// + /// In en, this message translates to: + /// **'Not found'** + String get apiErrorDataNotFound; + + /// No description provided for @apiErrorNotAllowed. + /// + /// In en, this message translates to: + /// **'This action isn\'t available right now'** + String get apiErrorNotAllowed; + /// No description provided for @authSwitchToSignUp. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8a1fe40..51e1345 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -968,6 +968,52 @@ class AppLocalizationsEn extends AppLocalizations { String get authServerError => 'The service is busy. Please try again shortly.'; + @override + String get apiErrorBadRequest => 'Invalid request'; + + @override + String get apiErrorUnauthorized => 'Sign in to continue'; + + @override + String get apiErrorForbidden => 'You don\'t have permission'; + + @override + String get apiErrorNotFound => 'Not found'; + + @override + String get apiErrorTooManyRequests => + 'Too many requests. Please try again later.'; + + @override + String get apiErrorLoginFailed => 'Incorrect account or password'; + + @override + String get apiErrorAccountDisabled => 'This account has been disabled'; + + @override + String get apiErrorCoinNotEnough => 'Not enough coins'; + + @override + String get apiErrorEpisodeUnavailable => 'This episode is unavailable'; + + @override + String get apiErrorEpisodeUnlocked => 'This episode is already unlocked'; + + @override + String get apiErrorOrderInvalid => 'This order is already paid or closed'; + + @override + String get apiErrorVipExpired => 'Membership has expired'; + + @override + String get apiErrorDataExists => 'This record already exists'; + + @override + String get apiErrorDataNotFound => 'Not found'; + + @override + String get apiErrorNotAllowed => 'This action isn\'t available right now'; + @override String get authSwitchToSignUp => 'New here? Create an account'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 088b6fb..25b53d9 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -971,6 +971,52 @@ class AppLocalizationsId extends AppLocalizations { @override String get authServerError => 'Layanan sedang sibuk. Coba lagi sebentar.'; + @override + String get apiErrorBadRequest => 'Permintaan tidak valid'; + + @override + String get apiErrorUnauthorized => 'Masuk untuk melanjutkan'; + + @override + String get apiErrorForbidden => 'Anda tidak memiliki izin'; + + @override + String get apiErrorNotFound => 'Tidak ditemukan'; + + @override + String get apiErrorTooManyRequests => + 'Terlalu banyak permintaan. Coba lagi nanti.'; + + @override + String get apiErrorLoginFailed => 'Akun atau kata sandi salah'; + + @override + String get apiErrorAccountDisabled => 'Akun ini telah dinonaktifkan'; + + @override + String get apiErrorCoinNotEnough => 'Koin tidak cukup'; + + @override + String get apiErrorEpisodeUnavailable => 'Episode ini tidak tersedia'; + + @override + String get apiErrorEpisodeUnlocked => 'Episode ini sudah terbuka'; + + @override + String get apiErrorOrderInvalid => 'Pesanan ini sudah dibayar atau ditutup'; + + @override + String get apiErrorVipExpired => 'Keanggotaan telah berakhir'; + + @override + String get apiErrorDataExists => 'Data sudah ada'; + + @override + String get apiErrorDataNotFound => 'Data tidak ditemukan'; + + @override + String get apiErrorNotAllowed => 'Tindakan ini tidak tersedia saat ini'; + @override String get authSwitchToSignUp => 'Baru di sini? Buat akun'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 08006ac..4e82207 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -955,6 +955,51 @@ class AppLocalizationsZh extends AppLocalizations { @override String get authServerError => '服务繁忙,请稍后再试'; + @override + String get apiErrorBadRequest => '参数校验失败'; + + @override + String get apiErrorUnauthorized => '未登录或登录已过期'; + + @override + String get apiErrorForbidden => '无权限'; + + @override + String get apiErrorNotFound => '资源不存在'; + + @override + String get apiErrorTooManyRequests => '请求过于频繁,请稍后再试'; + + @override + String get apiErrorLoginFailed => '账号或密码错误'; + + @override + String get apiErrorAccountDisabled => '账号已停用'; + + @override + String get apiErrorCoinNotEnough => '金币余额不足'; + + @override + String get apiErrorEpisodeUnavailable => '剧集未发布或不存在'; + + @override + String get apiErrorEpisodeUnlocked => '剧集已解锁'; + + @override + String get apiErrorOrderInvalid => '订单已支付或已关闭'; + + @override + String get apiErrorVipExpired => '会员已过期'; + + @override + String get apiErrorDataExists => '数据已存在'; + + @override + String get apiErrorDataNotFound => '数据不存在'; + + @override + String get apiErrorNotAllowed => '当前状态不允许该操作'; + @override String get authSwitchToSignUp => '还没有账号?去注册'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1b02daa..ee4d692 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -374,6 +374,21 @@ "authPasswordWeak": "密码需同时包含字母和数字", "authNetworkError": "网络异常,请稍后重试", "authServerError": "服务繁忙,请稍后再试", + "apiErrorBadRequest": "参数校验失败", + "apiErrorUnauthorized": "未登录或登录已过期", + "apiErrorForbidden": "无权限", + "apiErrorNotFound": "资源不存在", + "apiErrorTooManyRequests": "请求过于频繁,请稍后再试", + "apiErrorLoginFailed": "账号或密码错误", + "apiErrorAccountDisabled": "账号已停用", + "apiErrorCoinNotEnough": "金币余额不足", + "apiErrorEpisodeUnavailable": "剧集未发布或不存在", + "apiErrorEpisodeUnlocked": "剧集已解锁", + "apiErrorOrderInvalid": "订单已支付或已关闭", + "apiErrorVipExpired": "会员已过期", + "apiErrorDataExists": "数据已存在", + "apiErrorDataNotFound": "数据不存在", + "apiErrorNotAllowed": "当前状态不允许该操作", "authSwitchToSignUp": "还没有账号?去注册", "authSwitchToSignIn": "已有账号?去登录", "authGateTitle": "登录后可继续", diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart index 9fe651b..9b96588 100644 --- a/lib/pages/auth/phone_auth_sheet.dart +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/formatting/error_text.dart'; import '../../core/platform/haptics.dart'; import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; @@ -180,7 +181,7 @@ class _PhoneAuthSheetState extends State { ).showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); _codeFocus.requestFocus(); } on ApiException catch (error) { - failureMessage = error.message; + failureMessage = userFacingApiMessage(l10n, error.code, error.message); } catch (_) { failureMessage = l10n.authNetworkError; } finally { @@ -228,7 +229,7 @@ class _PhoneAuthSheetState extends State { } if (mounted) Navigator.of(context).pop(true); } on ApiException catch (error) { - failureMessage = error.message; + failureMessage = userFacingApiMessage(l10n, error.code, error.message); } catch (_) { failureMessage = l10n.authNetworkError; } finally { diff --git a/test/core/error_text_test.dart b/test/core/error_text_test.dart new file mode 100644 index 0000000..52083a6 --- /dev/null +++ b/test/core/error_text_test.dart @@ -0,0 +1,29 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/formatting/error_text.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; + +void main() { + test('should_keep_login_failure_message_when_code_is_business', () async { + final l10n = await AppLocalizations.delegate.load(const Locale('zh')); + + expect( + userFacingApiMessage(l10n, 100000, '账号或密码错误'), + '账号或密码错误', + ); + expect(userFacingApiMessage(l10n, 500, '系统异常'), l10n.authServerError); + + final english = await AppLocalizations.delegate.load(const Locale('en')); + expect( + userFacingApiMessage(english, 100000, '账号或密码错误'), + 'Incorrect account or password', + ); + final indonesian = await AppLocalizations.delegate.load(const Locale('id')); + expect( + userFacingApiMessage(indonesian, 100000, '账号或密码错误'), + 'Akun atau kata sandi salah', + ); + }); +}