Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84a1f58a0a | ||
|
|
d7eff4ca59 | ||
|
|
94ff9f1177 | ||
|
|
61454e36b1 | ||
|
|
0e5d550b50 | ||
|
|
1853e1845c | ||
|
|
ae49b10407 | ||
|
|
feecc349a0 | ||
|
|
e1b98df509 | ||
|
|
77d5748dbb |
No files matched your search
@@ -5,13 +5,46 @@
|
||||
- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。
|
||||
- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。
|
||||
- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。
|
||||
- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。
|
||||
- **互动与多语言**:本地演示评论、系统分享,以及英文、简体中文和印尼语界面。
|
||||
- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。
|
||||
|
||||
## 技术实现
|
||||
|
||||
项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。
|
||||
|
||||
## 对接后端
|
||||
|
||||
目录数据来自短剧服务的客户端接口(`FruitServer` 的 `fruitshorts-app-api`,默认 `http://127.0.0.1:8081`):
|
||||
|
||||
- `GET /app/home/feed`、`GET /app/discover/feed`:轮播 + 分区,一次请求渲染整页。
|
||||
每张卡片带 `preview`(首个可播免费集),首页竖屏播放可以直接起播;
|
||||
没有片源的剧不下发 `preview`,也不会出现在首页的滑动队列里
|
||||
- `GET /app/dramas/{id}`:详情(含剧集列表;可播放的剧集直接带播放地址)
|
||||
- `GET /app/episodes/{id}/play`:播放票据,返回可访问性与地址
|
||||
|
||||
启动后端后直接 `flutter run` 或 Xcode 里 Run 即可,接口地址按运行环境自动选择
|
||||
(见 `lib/core/config/app_config.dart`):
|
||||
|
||||
| 运行环境 | 默认地址 |
|
||||
| --- | --- |
|
||||
| iOS 模拟器 / 桌面端 | `http://127.0.0.1:8081` |
|
||||
| iPhone 真机 | [`devHostBaseUrl`],即电脑当前的局域网地址(`ipconfig getifaddr en0`) |
|
||||
| Android 模拟器 | 需手动指定 `--dart-define=API_BASE_URL=http://10.0.2.2:8081` |
|
||||
|
||||
任何环境都可以用 `--dart-define=API_BASE_URL=http://x.x.x.x:8081` 覆盖默认值。
|
||||
电脑换网络后局域网地址会变,改 `devHostBaseUrl` 即可;也可以换成 Bonjour 名字
|
||||
(`http://$(scutil --get LocalHostName).local:8081`),换网络也不用改。
|
||||
|
||||
真机调试还需注意:手机和电脑在同一个 Wi-Fi;首次启动时 iPhone 会弹「本地网络」权限,
|
||||
必须允许;iOS 的明文 HTTP 与局域网访问已在 `ios/Runner/Info.plist` 里放行
|
||||
(`NSAllowsLocalNetworking`、`NSLocalNetworkUsageDescription`),改动这些配置后
|
||||
要重新构建安装,热重载不生效;如果仍连不上,到「系统设置 → 网络 → 防火墙 → 选项」
|
||||
把 `java` 设为允许传入连接。
|
||||
|
||||
封面与视频都走 `http://<host>:8081/files/**`,本地不再打包演示素材
|
||||
(原先 `assets/media` 里的演示视频有 316MB,已从打包清单移除)。
|
||||
|
||||
## 当前说明
|
||||
|
||||
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
|
||||
收藏、点赞、评论与观看进度仍保存在本机(后端互动接口尚未开发);
|
||||
金币与会员相关界面仍是占位数据。
|
||||
@@ -4,4 +4,6 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
@@ -1,6 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="Nontonki"
|
||||
android:label="FruitShorts"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -4,4 +4,6 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,74 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/config/app_config.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
/// 设备侧集成测试:**在手机进程里**真实请求开发机上的 app-api。
|
||||
///
|
||||
/// 跑法(红线第 10 条要求的"设备侧取证"):
|
||||
/// ```
|
||||
/// IP=$(ipconfig getifaddr en0)
|
||||
/// flutter test integration_test -d <device-id> --dart-define=API_BASE_URL=http://$IP:8081
|
||||
/// ```
|
||||
/// 断言失败时会把实际使用的地址一起打出来,便于区分"地址不对"还是"设备连不上"。
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final baseUrl = AppConfig.apiBaseUrl;
|
||||
|
||||
testWidgets('should_fetch_home_feed_and_drama_detail_when_device_reaches_backend', (
|
||||
tester,
|
||||
) async {
|
||||
debugPrint('[device] API_BASE_URL=$baseUrl');
|
||||
final client = ApiClient(baseUrl: baseUrl);
|
||||
addTearDown(client.close);
|
||||
final api = CatalogApi(client);
|
||||
|
||||
final feed = await api.homeFeed();
|
||||
expect(
|
||||
feed.dramas,
|
||||
isNotEmpty,
|
||||
reason: '设备侧拿不到首页列表(API_BASE_URL=$baseUrl)',
|
||||
);
|
||||
debugPrint('[device] 首页分区=${feed.sections.length} 剧目=${feed.dramas.length}');
|
||||
|
||||
final card = feed.dramas.first;
|
||||
expect(card.id, isNotEmpty, reason: '设备侧首页卡片 id 为空');
|
||||
|
||||
final detail = await api.dramaDetail(card.id);
|
||||
expect(detail.id, card.id, reason: '详情返回的 id 与卡片不一致');
|
||||
expect(
|
||||
detail.episodes,
|
||||
isNotEmpty,
|
||||
reason: '设备侧拿到的详情没有剧集(dramaId=${card.id})',
|
||||
);
|
||||
debugPrint('[device] 详情 id=${detail.id} 剧集=${detail.episodes.length}');
|
||||
});
|
||||
|
||||
testWidgets('should_render_home_drama_list_when_app_boots_on_device', (
|
||||
tester,
|
||||
) async {
|
||||
// 用真 App(真网络、真目录控制器):首页底部标签栏出现即代表"剧目列表加载成功",
|
||||
// 也就是用户在真机上遇到的那个「剧目列表加载失败」已经消失。
|
||||
await tester.pumpWidget(const LumaDramaApp());
|
||||
|
||||
var loaded = false;
|
||||
for (var attempt = 0; attempt < 60 && !loaded; attempt++) {
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
loaded = find.byKey(const Key('home-tabs-bar')).evaluate().isNotEmpty;
|
||||
}
|
||||
|
||||
expect(
|
||||
loaded,
|
||||
isTrue,
|
||||
reason: '首页 30 秒内没渲染出剧目列表(API_BASE_URL=$baseUrl)',
|
||||
);
|
||||
debugPrint('[device] 首页剧目列表已渲染:API_BASE_URL=$baseUrl');
|
||||
});
|
||||
}
|
||||
@@ -360,7 +360,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -487,7 +487,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -538,7 +538,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Nontonki</string>
|
||||
<string>FruitShorts</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Nontonki</string>
|
||||
<string>FruitShorts</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -26,6 +26,13 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
+107
-6
@@ -1,36 +1,137 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'core/config/locale_controller.dart';
|
||||
import 'core/data/remote/api_client.dart';
|
||||
import 'core/data/remote/auth_api.dart';
|
||||
import 'core/data/remote/catalog_api.dart';
|
||||
import 'core/data/remote/library_api.dart';
|
||||
import 'core/data/remote/playback_api.dart';
|
||||
import 'core/data/remote/watch_api.dart';
|
||||
import 'core/data/repositories/catalog_repository.dart';
|
||||
import 'core/data/repositories/engagement_repository.dart';
|
||||
import 'core/data/repositories/favorite_repository.dart';
|
||||
import 'core/data/repositories/playback_repository.dart';
|
||||
import 'core/data/repositories/watch_history_repository.dart';
|
||||
import 'core/data/services/auth_controller.dart';
|
||||
import 'core/data/services/auth_session_store.dart';
|
||||
import 'core/data/services/catalog_controller.dart';
|
||||
import 'core/data/services/engagement_controller.dart';
|
||||
import 'core/data/services/favorite_controller.dart';
|
||||
import 'core/data/services/playback_session.dart';
|
||||
import 'core/data/services/video_player_port.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'pages/home/home_view.dart';
|
||||
|
||||
class LumaDramaApp extends StatelessWidget {
|
||||
const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new});
|
||||
class LumaDramaApp extends StatefulWidget {
|
||||
const LumaDramaApp({
|
||||
super.key,
|
||||
this.playerFactory = VideoPlayerPort.new,
|
||||
this.catalogRepository,
|
||||
this.watchHistoryRepository,
|
||||
this.favoriteRepository,
|
||||
this.playbackRepository,
|
||||
});
|
||||
|
||||
final PlayerFactory playerFactory;
|
||||
|
||||
/// 测试注入用;生产走 HTTP 仓库。
|
||||
final CatalogRepository? catalogRepository;
|
||||
final WatchHistoryRepository? watchHistoryRepository;
|
||||
final FavoriteRepository? favoriteRepository;
|
||||
final PlaybackRepository? playbackRepository;
|
||||
|
||||
@override
|
||||
State<LumaDramaApp> createState() => _LumaDramaAppState();
|
||||
}
|
||||
|
||||
class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
late final AuthSessionStore _sessionStore = AuthSessionStore();
|
||||
|
||||
/// 全 App 共用一个 ApiClient:这样目录、账号、后续的互动/进度请求
|
||||
/// 都自动带 `Authorization` 与 `X-Device-Id`,401 的刷新重试也只有一处实现。
|
||||
late final ApiClient _apiClient = ApiClient(
|
||||
accessTokenReader: () => _auth.accessToken,
|
||||
tokenRefresher: () => _auth.refresh(),
|
||||
onUnauthorized: () => _auth.clear(),
|
||||
deviceIdReader: _sessionStore.deviceId,
|
||||
);
|
||||
|
||||
late final AuthController _auth = AuthController(AuthApi(_apiClient), _sessionStore);
|
||||
|
||||
late final WatchHistoryRepository _watchHistory =
|
||||
widget.watchHistoryRepository ??
|
||||
HttpWatchHistoryRepository(
|
||||
WatchApi(_apiClient),
|
||||
isSignedIn: () => _auth.isSignedIn,
|
||||
);
|
||||
|
||||
late final FavoriteRepository _favorites =
|
||||
widget.favoriteRepository ??
|
||||
HttpFavoriteRepository(
|
||||
LibraryApi(_apiClient),
|
||||
isSignedIn: () => _auth.isSignedIn,
|
||||
);
|
||||
|
||||
late final PlaybackRepository _playback =
|
||||
widget.playbackRepository ?? HttpPlaybackRepository(PlaybackApi(_apiClient));
|
||||
|
||||
late final FavoriteController _favoriteController = FavoriteController(_favorites);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
|
||||
_auth.load();
|
||||
// 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联
|
||||
_auth.addListener(_onAuthChanged);
|
||||
if (_auth.isSignedIn) unawaited(_favoriteController.load());
|
||||
}
|
||||
|
||||
bool _wasSignedIn = false;
|
||||
|
||||
void _onAuthChanged() {
|
||||
if (_auth.isSignedIn == _wasSignedIn) return;
|
||||
_wasSignedIn = _auth.isSignedIn;
|
||||
if (_auth.isSignedIn) {
|
||||
unawaited(_favoriteController.load());
|
||||
} else {
|
||||
_favoriteController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_auth.removeListener(_onAuthChanged);
|
||||
_favoriteController.dispose();
|
||||
_apiClient.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider(create: (_) => PlaybackCoordinator()),
|
||||
Provider<PlayerFactory>(create: (_) => playerFactory),
|
||||
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
|
||||
Provider<PlaybackRepository>.value(value: _playback),
|
||||
Provider<WatchHistoryRepository>.value(value: _watchHistory),
|
||||
Provider<FavoriteRepository>.value(value: _favorites),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => CatalogController(DemoCatalogRepository())..load(),
|
||||
create: (_) => CatalogController(
|
||||
widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)),
|
||||
)..load(),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
|
||||
ChangeNotifierProvider.value(value: _favoriteController),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) =>
|
||||
EngagementController(SharedPreferencesEngagementRepository())
|
||||
..load(),
|
||||
),
|
||||
ChangeNotifierProvider.value(value: _auth),
|
||||
],
|
||||
child: Consumer<LocaleController>(
|
||||
builder: (context, locale, _) => MaterialApp(
|
||||
@@ -51,14 +152,14 @@ class LumaDramaApp extends StatelessWidget {
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF6D4AFF),
|
||||
seedColor: const Color(0xFF2F7BFF),
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFFF8F7FC),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF9B86FF),
|
||||
seedColor: const Color(0xFF2F7BFF),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF101019),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 客户端与后端约定的运行时配置。
|
||||
///
|
||||
/// 接口地址的优先级:`--dart-define=API_BASE_URL=...` > 按运行环境判断的默认值。
|
||||
/// 开发机的局域网地址会随网络变化,换网络后改 [devHostBaseUrl] 即可。
|
||||
class AppConfig {
|
||||
const AppConfig._();
|
||||
|
||||
/// 开发机的局域网地址。真机上的 `127.0.0.1` 指向手机自己,只能走这个地址。
|
||||
///
|
||||
/// 这个地址跟着电脑所在的网络走,换网络后要用
|
||||
/// `ipconfig getifaddr en0` 重新确认并改这里;
|
||||
/// 也可以换成 Bonjour 名字 `http://<LocalHostName>.local:8081`(本机是
|
||||
/// `jiangdeMacBook-Air.local`),换网络不用改,但部分办公网会拦组播解析。
|
||||
static const String devHostBaseUrl =
|
||||
'http://jiangdeMacBook-Air.local:8081';
|
||||
|
||||
static const String _loopbackBaseUrl = 'http://127.0.0.1:8081';
|
||||
|
||||
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
|
||||
|
||||
static String get apiBaseUrl =>
|
||||
_overrideBaseUrl.isNotEmpty ? _overrideBaseUrl : _defaultBaseUrl;
|
||||
|
||||
static final String _defaultBaseUrl =
|
||||
_runsOnDevMachine ? _loopbackBaseUrl : devHostBaseUrl;
|
||||
|
||||
/// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。
|
||||
/// Android 没有等价的探测方式,统一按真机处理(模拟器用
|
||||
/// `--dart-define=API_BASE_URL=http://10.0.2.2:8081` 覆盖)。
|
||||
static final bool _runsOnDevMachine =
|
||||
kIsWeb ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.windows ||
|
||||
defaultTargetPlatform == TargetPlatform.linux ||
|
||||
(defaultTargetPlatform == TargetPlatform.iOS &&
|
||||
Platform.environment.containsKey('SIMULATOR_DEVICE_NAME'));
|
||||
|
||||
static const Duration requestTimeout = Duration(seconds: 8);
|
||||
|
||||
/// 后端返回的是相对路径(/files/...),这里补上服务地址。
|
||||
static String mediaUrl(String path) {
|
||||
if (path.isEmpty) return '';
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||
return '$apiBaseUrl$path';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 登录态:服务端下发的令牌与用户摘要。
|
||||
///
|
||||
/// 只保留客户端真正要用的字段(uid / 昵称 / 头像),不做整份 AppUserRespVO 的映射 ——
|
||||
/// 首页卡片、个人页需要的都在这里,避免为用不到的字段维护一份平行模型。
|
||||
class AuthUser {
|
||||
const AuthUser({required this.uid, required this.nickname, this.avatarUrl = ''});
|
||||
|
||||
final String uid;
|
||||
final String nickname;
|
||||
final String avatarUrl;
|
||||
|
||||
factory AuthUser.fromJson(Map<String, dynamic> json) => AuthUser(
|
||||
uid: json['uid'] as String? ?? '',
|
||||
nickname: json['nickname'] as String? ?? '',
|
||||
avatarUrl: json['avatarUrl'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'uid': uid,
|
||||
'nickname': nickname,
|
||||
'avatarUrl': avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
class AuthSession {
|
||||
const AuthSession({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
this.expiresIn = 0,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int expiresIn;
|
||||
final AuthUser user;
|
||||
|
||||
factory AuthSession.fromJson(Map<String, dynamic> json) => AuthSession(
|
||||
accessToken: json['accessToken'] as String? ?? '',
|
||||
refreshToken: json['refreshToken'] as String? ?? '',
|
||||
// expiresIn 后端是 long,按字符串下发(见 json_numbers.dart)
|
||||
expiresIn: jsonToInt(json['expiresIn']),
|
||||
user: AuthUser.fromJson((json['user'] as Map?)?.cast<String, dynamic>() ?? const {}),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'accessToken': accessToken,
|
||||
'refreshToken': refreshToken,
|
||||
'expiresIn': expiresIn,
|
||||
'user': user.toJson(),
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,53 @@
|
||||
enum DramaFormat { animated, liveAction }
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。
|
||||
///
|
||||
/// 封面与播放地址都是后端返回的相对路径(`/files/...`),
|
||||
/// 渲染前用 `AppConfig.mediaUrl` 补上服务地址。
|
||||
class Episode {
|
||||
const Episode({
|
||||
required this.id,
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.assetPath,
|
||||
required this.isFree,
|
||||
this.videoUrl = '',
|
||||
this.durationSec = 0,
|
||||
this.locked = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final int number;
|
||||
final String title;
|
||||
final String assetPath;
|
||||
final bool isFree;
|
||||
|
||||
/// 播放地址:详情接口不下发,由 `/app/episodes/{id}/play` 返回后写入。
|
||||
final String videoUrl;
|
||||
final int durationSec;
|
||||
|
||||
/// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。
|
||||
final bool locked;
|
||||
|
||||
Episode withVideoUrl(String url) => Episode(
|
||||
id: id,
|
||||
number: number,
|
||||
title: title,
|
||||
isFree: isFree,
|
||||
videoUrl: url,
|
||||
durationSec: durationSec,
|
||||
locked: locked,
|
||||
);
|
||||
|
||||
factory Episode.fromJson(Map<String, dynamic> json) {
|
||||
return Episode(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
number: (json['number'] as num?)?.toInt() ?? 0,
|
||||
title: json['title'] as String? ?? '',
|
||||
isFree: json['isFree'] == true,
|
||||
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
|
||||
locked: json['locked'] == true,
|
||||
videoUrl: json['playUrl'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Drama {
|
||||
@@ -21,15 +55,74 @@ class Drama {
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.synopsis,
|
||||
required this.posterAsset,
|
||||
required this.episodes,
|
||||
this.format = DramaFormat.liveAction,
|
||||
required this.posterUrl,
|
||||
this.episodes = const [],
|
||||
this.category = '',
|
||||
this.genre = '',
|
||||
this.totalEpisodes = 0,
|
||||
this.freeEpisodes = 0,
|
||||
this.isHot = false,
|
||||
this.viewCount = 0,
|
||||
this.detailLoaded = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String synopsis;
|
||||
final String posterAsset;
|
||||
final String posterUrl;
|
||||
final List<Episode> episodes;
|
||||
final DramaFormat format;
|
||||
final String category;
|
||||
final String genre;
|
||||
final int totalEpisodes;
|
||||
final int freeEpisodes;
|
||||
final bool isHot;
|
||||
final int viewCount;
|
||||
|
||||
/// 详情接口是否已经取过完整剧集列表。
|
||||
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
|
||||
final bool detailLoaded;
|
||||
|
||||
/// 卡片只带一集预览,集数以后端给的总集数为准。
|
||||
int get episodeCount => totalEpisodes > 0 ? totalEpisodes : episodes.length;
|
||||
|
||||
/// 列表卡片:带一集可播预览(首页直接起播),完整剧集进详情再取。
|
||||
factory Drama.fromCardJson(Map<String, dynamic> json) {
|
||||
final preview = json['preview'];
|
||||
return Drama(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
title: json['title'] as String? ?? '',
|
||||
synopsis: json['synopsis'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
episodes: preview is Map<String, dynamic>
|
||||
? [Episode.fromJson(preview)]
|
||||
: const [],
|
||||
category: json['category'] as String? ?? '',
|
||||
genre: json['genre'] as String? ?? '',
|
||||
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
|
||||
isHot: json['isHot'] == true,
|
||||
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
|
||||
viewCount: jsonToInt(json['viewCount']),
|
||||
);
|
||||
}
|
||||
|
||||
/// 详情:带剧集列表。
|
||||
factory Drama.fromDetailJson(Map<String, dynamic> json) {
|
||||
return Drama(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
title: json['title'] as String? ?? '',
|
||||
synopsis: json['synopsis'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
category: json['category'] as String? ?? '',
|
||||
genre: json['genre'] as String? ?? '',
|
||||
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
|
||||
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
||||
isHot: json['isHot'] == true,
|
||||
viewCount: jsonToInt(json['viewCount']),
|
||||
detailLoaded: true,
|
||||
episodes: (json['episodes'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Episode.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'drama.dart';
|
||||
|
||||
/// 首页/发现页聚合数据,一次请求拿到轮播与分区。
|
||||
class HomeFeed {
|
||||
const HomeFeed({this.banners = const [], this.sections = const []});
|
||||
|
||||
final List<FeedBanner> banners;
|
||||
final List<FeedSection> sections;
|
||||
|
||||
bool get isEmpty => sections.every((section) => section.dramas.isEmpty);
|
||||
|
||||
/// 分区打平后的短剧列表,供现有 UI 的横滑/竖滑复用;按 id 去重。
|
||||
List<Drama> get dramas {
|
||||
final seen = <String>{};
|
||||
final result = <Drama>[];
|
||||
for (final section in sections) {
|
||||
for (final drama in section.dramas) {
|
||||
if (seen.add(drama.id)) result.add(drama);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
factory HomeFeed.fromJson(Map<String, dynamic> json) {
|
||||
final banners = (json['banners'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(FeedBanner.fromJson)
|
||||
.toList();
|
||||
final sections = (json['sections'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(FeedSection.fromJson)
|
||||
.toList();
|
||||
return HomeFeed(banners: banners, sections: sections);
|
||||
}
|
||||
}
|
||||
|
||||
class FeedSection {
|
||||
const FeedSection({
|
||||
required this.code,
|
||||
required this.title,
|
||||
this.dramas = const [],
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String title;
|
||||
final List<Drama> dramas;
|
||||
|
||||
factory FeedSection.fromJson(Map<String, dynamic> json) {
|
||||
return FeedSection(
|
||||
code: json['code'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
dramas: (json['dramas'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Drama.fromCardJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FeedBanner {
|
||||
const FeedBanner({
|
||||
required this.id,
|
||||
required this.imageUrl,
|
||||
this.linkType = 'NONE',
|
||||
this.linkValue = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String imageUrl;
|
||||
final String linkType;
|
||||
final String linkValue;
|
||||
|
||||
/// 点击轮播要打开的短剧 id,非短剧跳转返回 null。
|
||||
String? get dramaId => linkType == 'DRAMA' && linkValue.isNotEmpty
|
||||
? linkValue
|
||||
: null;
|
||||
|
||||
factory FeedBanner.fromJson(Map<String, dynamic> json) {
|
||||
return FeedBanner(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
imageUrl: json['imageUrl'] as String? ?? '',
|
||||
linkType: json['linkType'] as String? ?? 'NONE',
|
||||
linkValue: json['linkValue'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 数值字段的兼容解析。
|
||||
///
|
||||
/// 后端契约(FruitServer/docs/api-phase1.md §0):`Long`/`long` 字段**一律以字符串下发**
|
||||
/// (雪花 id 与计数都算),`Integer`/`int` 仍是数字。所以这里既接受数字也接受数字字符串,
|
||||
/// 否则一个 `"12345"` 就能让 `as num?` 直接抛异常、整页解析失败。
|
||||
int jsonToInt(dynamic value, {int fallback = 0}) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value.trim()) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。
|
||||
class PlayTicket {
|
||||
const PlayTicket({
|
||||
required this.episodeId,
|
||||
required this.accessible,
|
||||
required this.accessType,
|
||||
this.dramaId = '',
|
||||
this.number = 0,
|
||||
this.title = '',
|
||||
this.playUrl = '',
|
||||
this.durationSec = 0,
|
||||
this.unlockCoin = 0,
|
||||
this.previewSeconds = 0,
|
||||
});
|
||||
|
||||
static const String free = 'FREE';
|
||||
static const String coinRequired = 'COIN_REQUIRED';
|
||||
static const String vipRequired = 'VIP_REQUIRED';
|
||||
static const String noMedia = 'NO_MEDIA';
|
||||
|
||||
final String episodeId;
|
||||
final String dramaId;
|
||||
final int number;
|
||||
final String title;
|
||||
final bool accessible;
|
||||
final String accessType;
|
||||
|
||||
/// 签名播放地址:**原样使用**(含 `?e=&s=`),客户端不拼接、不改写。
|
||||
final String playUrl;
|
||||
final int durationSec;
|
||||
final int unlockCoin;
|
||||
|
||||
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
|
||||
final int previewSeconds;
|
||||
|
||||
bool get needsUnlock =>
|
||||
accessType == coinRequired || accessType == vipRequired;
|
||||
|
||||
factory PlayTicket.fromJson(Map<String, dynamic> json) {
|
||||
return PlayTicket(
|
||||
episodeId: (json['episodeId'] ?? '').toString(),
|
||||
dramaId: (json['dramaId'] ?? '').toString(),
|
||||
number: jsonToInt(json['number']),
|
||||
title: json['title'] as String? ?? '',
|
||||
accessible: json['accessible'] == true,
|
||||
accessType: json['accessType'] as String? ?? noMedia,
|
||||
playUrl: json['playUrl'] as String? ?? '',
|
||||
durationSec: jsonToInt(json['durationSec']),
|
||||
unlockCoin: jsonToInt(json['unlockCoin']),
|
||||
previewSeconds: jsonToInt(json['previewSeconds']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 服务端续看记录:`GET /app/watch/progress?dramaId=` 与 `/app/watch/recent` 的下发结构。
|
||||
///
|
||||
/// 服务端按 `(user, drama)` 只留一条,所以它是"这部剧看到哪一集的哪一秒",
|
||||
/// 不是逐集进度。ID 与毫秒都是字符串(见 api-phase1.md §0),统一走 jsonToInt 解析。
|
||||
class WatchProgress {
|
||||
const WatchProgress({
|
||||
required this.dramaId,
|
||||
required this.episodeId,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.finished = false,
|
||||
this.playCount = 0,
|
||||
});
|
||||
|
||||
final String dramaId;
|
||||
final String episodeId;
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final bool finished;
|
||||
final int playCount;
|
||||
|
||||
bool get hasPosition => position > Duration.zero;
|
||||
|
||||
/// 进度比例 0~1,时长未知时为 0,避免 UI 出现 NaN。
|
||||
double get fraction {
|
||||
if (duration <= Duration.zero) return 0;
|
||||
return (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
factory WatchProgress.fromJson(Map<String, dynamic> json) {
|
||||
return WatchProgress(
|
||||
dramaId: (json['dramaId'] ?? '').toString(),
|
||||
episodeId: (json['episodeId'] ?? '').toString(),
|
||||
position: Duration(milliseconds: jsonToInt(json['positionMs'])),
|
||||
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
|
||||
finished: json['finished'] == true,
|
||||
playCount: jsonToInt(json['playCount']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'dramaId': dramaId,
|
||||
'episodeId': episodeId,
|
||||
'positionMs': position.inMilliseconds,
|
||||
'durationMs': duration.inMilliseconds,
|
||||
'finished': finished,
|
||||
'playCount': playCount,
|
||||
};
|
||||
}
|
||||
|
||||
/// 续看点:从哪一集的哪个位置继续看。
|
||||
class ResumePoint {
|
||||
const ResumePoint({required this.episodeId, required this.position});
|
||||
|
||||
final String episodeId;
|
||||
final Duration position;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'api_interceptors.dart';
|
||||
|
||||
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
|
||||
export 'api_exception.dart';
|
||||
|
||||
/// 后端统一响应壳 `{code, message, data}` 的解析入口。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 用 dio 发请求(`AuthHeaderInterceptor` 注入令牌与设备号,`EnvelopeInterceptor` 解包与映射错误码);
|
||||
/// - 编排「401 → 刷新令牌 → 重发一次」——**刻意不放在拦截器里重发**,因为 dio 的排队拦截器
|
||||
/// 在回调里再发起请求会把自己排到队尾,造成自锁。
|
||||
class ApiClient {
|
||||
ApiClient({
|
||||
String? baseUrl,
|
||||
HttpClientAdapter? adapter,
|
||||
this.accessTokenReader,
|
||||
this.tokenRefresher,
|
||||
this.onUnauthorized,
|
||||
this.deviceIdReader,
|
||||
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: this.baseUrl,
|
||||
connectTimeout: AppConfig.requestTimeout,
|
||||
receiveTimeout: AppConfig.requestTimeout,
|
||||
// 拿原始字节自己解码:后端消息是 UTF-8,且"不是 JSON 的响应体"要能被识别出来
|
||||
responseType: ResponseType.bytes,
|
||||
// 非 2xx 不抛异常:业务结果由响应体里的 code 表达(与旧实现一致)
|
||||
validateStatus: (_) => true,
|
||||
),
|
||||
);
|
||||
_dio.interceptors.addAll([
|
||||
AuthHeaderInterceptor(
|
||||
accessTokenReader: accessTokenReader,
|
||||
deviceIdReader: deviceIdReader,
|
||||
),
|
||||
const EnvelopeInterceptor(),
|
||||
]);
|
||||
if (adapter != null) {
|
||||
_dio.httpClientAdapter = adapter;
|
||||
}
|
||||
}
|
||||
|
||||
final String baseUrl;
|
||||
late final Dio _dio;
|
||||
|
||||
/// 取当前 access token(未登录返回 null)
|
||||
final String? Function()? accessTokenReader;
|
||||
|
||||
/// 刷新令牌:返回 true 表示刷新成功,可以重试原请求
|
||||
final Future<bool> Function()? tokenRefresher;
|
||||
|
||||
/// 刷新也失败时回调(清本地登录态、回收 UI)
|
||||
final void Function()? onUnauthorized;
|
||||
|
||||
/// 设备号(`X-Device-Id`):注册风控、后续下载绑定都要用
|
||||
final Future<String> Function()? deviceIdReader;
|
||||
|
||||
Future<Object?> get(String path, {Map<String, String>? query}) {
|
||||
return _request('GET', path, query: query);
|
||||
}
|
||||
|
||||
Future<Object?> post(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? query,
|
||||
bool skipAuthRetry = false,
|
||||
}) {
|
||||
return _request(
|
||||
'POST',
|
||||
path,
|
||||
body: body,
|
||||
query: query,
|
||||
skipAuthRetry: skipAuthRetry,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Object?> delete(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? query,
|
||||
}) {
|
||||
return _request('DELETE', path, body: body, query: query);
|
||||
}
|
||||
|
||||
Future<Object?> _request(
|
||||
String method,
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? query,
|
||||
bool retried = false,
|
||||
bool skipAuthRetry = false,
|
||||
}) async {
|
||||
final disableAuthRetry = skipAuthRetry || tokenRefresher == null;
|
||||
try {
|
||||
final response = await _dio.request<Object?>(
|
||||
path,
|
||||
data: body,
|
||||
queryParameters: (query?.isEmpty ?? true) ? null : query,
|
||||
options: Options(
|
||||
method: method,
|
||||
contentType: body == null ? null : Headers.jsonContentType,
|
||||
extra: {
|
||||
ApiRequestFlags.disableAuthRetry: disableAuthRetry,
|
||||
ApiRequestFlags.retried: retried,
|
||||
},
|
||||
),
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (error) {
|
||||
final cause = error.error;
|
||||
if (cause is TokenExpiredException) {
|
||||
final refreshed = await tokenRefresher?.call() ?? false;
|
||||
if (refreshed) {
|
||||
return _request(
|
||||
method,
|
||||
path,
|
||||
body: body,
|
||||
query: query,
|
||||
retried: true,
|
||||
skipAuthRetry: skipAuthRetry,
|
||||
);
|
||||
}
|
||||
onUnauthorized?.call();
|
||||
throw ApiException(401, cause.message ?? '登录已过期');
|
||||
}
|
||||
if (cause is ApiException) {
|
||||
throw cause;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void close() => _dio.close(force: true);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 接口异常:code 为后端业务码,message 可直接展示。
|
||||
///
|
||||
/// 单独成文件是为了让 [ApiClient] 与拦截器都能引用它而不互相 import
|
||||
/// (`api_client.dart` 会 `export` 本文件,历史调用方继续 `import 'api_client.dart'` 即可拿到它)。
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.code, this.message);
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($code, $message)';
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
|
||||
class ApiRequestFlags {
|
||||
const ApiRequestFlags._();
|
||||
|
||||
/// 本次请求不参与「401 → 刷新 → 重试」:刷新令牌自身、登出,以及没有刷新回调时。
|
||||
static const String disableAuthRetry = 'app.auth.disableRetry';
|
||||
|
||||
/// 本次请求已经是刷新后的重试,见到它就不再刷新,避免无限循环。
|
||||
static const String retried = 'app.auth.retried';
|
||||
}
|
||||
|
||||
/// 内部信号:响应体里 `code == 401`,应当由 `ApiClient` 刷新令牌后重发一次。
|
||||
///
|
||||
/// 只在本层流转,不会漏给调用方 —— 调用方拿到的始终是 `ApiException`。
|
||||
class TokenExpiredException implements Exception {
|
||||
const TokenExpiredException(this.message);
|
||||
|
||||
/// 后端返回的提示语(可能为空),用于刷新失败时拼 `ApiException` 的消息。
|
||||
final String? message;
|
||||
}
|
||||
|
||||
/// 注入鉴权与设备标识请求头。
|
||||
///
|
||||
/// 每个请求都重新取一次令牌,所以刷新成功后重发的那次会自动带上新令牌。
|
||||
class AuthHeaderInterceptor extends Interceptor {
|
||||
AuthHeaderInterceptor({this.accessTokenReader, this.deviceIdReader});
|
||||
|
||||
/// 当前 access token;未登录返回 null 或空串。
|
||||
final String? Function()? accessTokenReader;
|
||||
|
||||
/// 设备号(`X-Device-Id`):注册风控、下载绑定都用它。
|
||||
final Future<String> Function()? deviceIdReader;
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
options.headers['Accept'] = 'application/json';
|
||||
final token = accessTokenReader?.call();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final deviceId = await deviceIdReader?.call();
|
||||
if (deviceId != null && deviceId.isNotEmpty) {
|
||||
options.headers['X-Device-Id'] = deviceId;
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
/// 解包后端统一响应壳 `{code,message,data}`,并把业务码映射成异常。
|
||||
///
|
||||
/// 判定口径与旧的 `http` 实现逐条对齐:
|
||||
/// - body 不是合法 JSON → 视为 `code = -1`,抛 `ApiException(状态码, 'HTTP <状态码>')`
|
||||
/// - `code != 0` → 抛 `ApiException(code, message ?? '请求失败')`
|
||||
/// - `code == 401` 且允许刷新 → 抛 [TokenExpiredException] 作为信号(由 `ApiClient` 刷新后重发)
|
||||
class EnvelopeInterceptor extends Interceptor {
|
||||
const EnvelopeInterceptor();
|
||||
|
||||
@override
|
||||
void onResponse(
|
||||
Response<dynamic> response,
|
||||
ResponseInterceptorHandler handler,
|
||||
) {
|
||||
final options = response.requestOptions;
|
||||
final shell = _decodeShell(response.data);
|
||||
final code = shell['code'] as int? ?? -1;
|
||||
final message = shell['message'] as String?;
|
||||
final retried = options.extra[ApiRequestFlags.retried] == true;
|
||||
final disableRetry =
|
||||
options.extra[ApiRequestFlags.disableAuthRetry] == true;
|
||||
|
||||
if (code == 401 && !retried && !disableRetry) {
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
response: response,
|
||||
type: DioExceptionType.badResponse,
|
||||
error: TokenExpiredException(message),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (code == -1) {
|
||||
final status = response.statusCode ?? -1;
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
response: response,
|
||||
type: DioExceptionType.badResponse,
|
||||
error: ApiException(status, 'HTTP $status'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (code != 0) {
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
response: response,
|
||||
type: DioExceptionType.badResponse,
|
||||
error: ApiException(code, message ?? '请求失败'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
response.data = shell['data'];
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
/// 统一按 UTF-8 解码:后端的中文提示语不能因为缺 charset 而乱码。
|
||||
Map<String, dynamic> _decodeShell(Object? body) {
|
||||
final text = switch (body) {
|
||||
List<int> bytes => utf8.decode(bytes, allowMalformed: true),
|
||||
String text => text,
|
||||
_ => '',
|
||||
};
|
||||
try {
|
||||
return (jsonDecode(text) as Map).cast<String, dynamic>();
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import '../models/auth_session.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 账号相关接口(/app/auth/**)。
|
||||
///
|
||||
/// 注册/登录都用「邮箱 + 密码」;邮箱注册不需要验证码(后端按 targetType 放开),
|
||||
/// 手机号注册仍要验证码 —— 客户端这一批只开放邮箱入口。
|
||||
class AuthApi {
|
||||
const AuthApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<AuthSession> register({
|
||||
required String email,
|
||||
required String password,
|
||||
required String deviceId,
|
||||
String platform = '',
|
||||
String appVersion = '',
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
'/app/auth/register',
|
||||
body: {
|
||||
'targetType': 'EMAIL',
|
||||
'target': email,
|
||||
'password': password,
|
||||
'countryCode': 'ID',
|
||||
'deviceId': deviceId,
|
||||
'platform': platform,
|
||||
'appVersion': appVersion,
|
||||
},
|
||||
);
|
||||
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
Future<AuthSession> login({
|
||||
required String email,
|
||||
required String password,
|
||||
required String deviceId,
|
||||
String platform = '',
|
||||
String appVersion = '',
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
'/app/auth/login',
|
||||
body: {
|
||||
'targetType': 'EMAIL',
|
||||
'target': email,
|
||||
'password': password,
|
||||
'deviceId': deviceId,
|
||||
'platform': platform,
|
||||
'appVersion': appVersion,
|
||||
},
|
||||
);
|
||||
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
Future<AuthSession> refresh(String refreshToken) async {
|
||||
final data = await _client.post(
|
||||
'/app/auth/refresh',
|
||||
body: {'refreshToken': refreshToken},
|
||||
// 刷新请求本身不带旧令牌重试,失败就是失败
|
||||
skipAuthRetry: true,
|
||||
);
|
||||
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
Future<void> logout(String refreshToken) async {
|
||||
await _client.post(
|
||||
'/app/auth/logout',
|
||||
body: {'refreshToken': refreshToken},
|
||||
skipAuthRetry: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 客户端目录接口,对应后端 `/app/**`。
|
||||
class CatalogApi {
|
||||
const CatalogApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<HomeFeed> homeFeed() async {
|
||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/home/feed')));
|
||||
}
|
||||
|
||||
Future<HomeFeed> discoverFeed() async {
|
||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
||||
}
|
||||
|
||||
Future<Drama> dramaDetail(String dramaId) async {
|
||||
return Drama.fromDetailJson(
|
||||
_asMap(await _client.get('/app/dramas/$dramaId')),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _asMap(Object? data) {
|
||||
if (data is Map<String, dynamic>) return data;
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/drama.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
|
||||
///
|
||||
/// 列表返回的是与首页一致的卡片结构,所以直接用 `Drama.fromCardJson` 解析。
|
||||
class LibraryApi {
|
||||
const LibraryApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 切换收藏,返回切换后的状态(true = 已收藏),客户端以它为唯一真相。
|
||||
Future<bool> toggleFavorite(String dramaId) async {
|
||||
final data = await _client.post(
|
||||
'/app/favorites/toggle',
|
||||
body: {'dramaId': dramaId},
|
||||
);
|
||||
return data == true;
|
||||
}
|
||||
|
||||
Future<List<Drama>> favorites() async {
|
||||
final data = await _client.get('/app/favorites');
|
||||
if (data is! List) return const [];
|
||||
return [
|
||||
for (final item in data)
|
||||
if (item is Map<String, dynamic>) Drama.fromCardJson(item),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> removeFavorite(String dramaId) async {
|
||||
await _client.delete('/app/favorites/$dramaId');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/play_ticket.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。
|
||||
///
|
||||
/// **签名地址原样使用**:后端下发的 `playUrl` 带 `?e=过期时间&s=签名`,
|
||||
/// 客户端只做「相对路径补 host」,不拼接、不改写、不丢 query —— 否则签名校验直接失败。
|
||||
class PlaybackApi {
|
||||
const PlaybackApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<PlayTicket> ticket(String episodeId) async {
|
||||
final data = await _client.get('/app/episodes/$episodeId/play');
|
||||
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data);
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/watch_progress.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 观看进度接口:`/app/watch/*`(契约 §1.5)。全部需要登录态。
|
||||
class WatchApi {
|
||||
const WatchApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 上报进度。服务端从 `episodeId` 推出所属剧集,并保证「同集只前进不后退」。
|
||||
Future<WatchProgress?> saveProgress({
|
||||
required String episodeId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
String source = 'app',
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
'/app/watch/progress',
|
||||
body: {
|
||||
'episodeId': episodeId,
|
||||
'positionMs': position.inMilliseconds,
|
||||
if (duration != null) 'durationMs': duration.inMilliseconds,
|
||||
'finished': finished,
|
||||
'source': source,
|
||||
},
|
||||
);
|
||||
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
|
||||
}
|
||||
|
||||
/// 某部剧的续看位置;没有记录时后端返回空 data。
|
||||
Future<WatchProgress?> progressOfDrama(String dramaId) async {
|
||||
final data = await _client.get(
|
||||
'/app/watch/progress',
|
||||
query: {'dramaId': dramaId},
|
||||
);
|
||||
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
|
||||
}
|
||||
|
||||
/// 最近观看(服务端按下标返回,已按更新时间倒序)。
|
||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
||||
final data = await _client.get(
|
||||
'/app/watch/recent',
|
||||
query: {'limit': '$limit'},
|
||||
);
|
||||
if (data is! List) return const [];
|
||||
return [
|
||||
for (final item in data)
|
||||
if (item is Map<String, dynamic>) WatchProgress.fromJson(item),
|
||||
];
|
||||
}
|
||||
|
||||
/// 删除某部剧的观看记录(记录页清单条用)。
|
||||
Future<void> removeProgress(String dramaId) async {
|
||||
await _client.delete('/app/watch/progress/$dramaId');
|
||||
}
|
||||
}
|
||||
@@ -1,104 +1,31 @@
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../remote/catalog_api.dart';
|
||||
|
||||
/// 目录数据来源:首页/发现页聚合、详情。
|
||||
///
|
||||
/// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。
|
||||
/// **播放票据不在这里**:它属于 [PlaybackRepository](票据有有效期,需要单独缓存策略)。
|
||||
abstract interface class CatalogRepository {
|
||||
Future<List<Drama>> fetchDramas();
|
||||
Future<HomeFeed> fetchHomeFeed();
|
||||
|
||||
Future<HomeFeed> fetchDiscoverFeed();
|
||||
|
||||
Future<Drama> fetchDrama(String dramaId);
|
||||
}
|
||||
|
||||
class DemoCatalogRepository implements CatalogRepository {
|
||||
class HttpCatalogRepository implements CatalogRepository {
|
||||
HttpCatalogRepository(this._api);
|
||||
|
||||
final CatalogApi _api;
|
||||
|
||||
@override
|
||||
Future<List<Drama>> fetchDramas() async => const [
|
||||
Drama(
|
||||
id: 'the-signal-demo',
|
||||
title: 'The Signal',
|
||||
synopsis:
|
||||
'A mock series for testing vertical episode playback with one reused local clip.',
|
||||
posterAsset: 'assets/images/stitch_signal_poster_clean.png',
|
||||
format: DramaFormat.animated,
|
||||
episodes: [
|
||||
Episode(
|
||||
id: 'signal-01',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'signal-02',
|
||||
number: 2,
|
||||
title: 'Episode 2',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'signal-03',
|
||||
number: 3,
|
||||
title: 'Episode 3',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
Drama(
|
||||
id: 'city-lights-demo',
|
||||
title: 'City Lights & Velvet Nights',
|
||||
synopsis:
|
||||
'A mock romance series using the same local video for navigation testing.',
|
||||
posterAsset: 'assets/images/stitch_city_poster_clean.png',
|
||||
format: DramaFormat.liveAction,
|
||||
episodes: [
|
||||
Episode(
|
||||
id: 'city-01',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'city-02',
|
||||
number: 2,
|
||||
title: 'Episode 2',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'city-03',
|
||||
number: 3,
|
||||
title: 'Episode 3',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
Drama(
|
||||
id: 'vengeance-demo',
|
||||
title: 'Vengeance Rising',
|
||||
synopsis:
|
||||
'A mock action series using the same local video for navigation testing.',
|
||||
posterAsset: 'assets/images/stitch_vengeance_poster_clean.png',
|
||||
format: DramaFormat.liveAction,
|
||||
episodes: [
|
||||
Episode(
|
||||
id: 'vengeance-01',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'vengeance-02',
|
||||
number: 2,
|
||||
title: 'Episode 2',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'vengeance-03',
|
||||
number: 3,
|
||||
title: 'Episode 3',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
Future<HomeFeed> fetchHomeFeed() => _api.homeFeed();
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() => _api.discoverFeed();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../remote/library_api.dart';
|
||||
|
||||
/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。
|
||||
abstract interface class FavoriteRepository {
|
||||
/// 切换收藏,返回切换后的状态(true = 已收藏)。
|
||||
Future<bool> toggle(String dramaId);
|
||||
|
||||
/// 收藏列表(卡片结构与首页一致)。
|
||||
Future<List<Drama>> list();
|
||||
|
||||
Future<void> remove(String dramaId);
|
||||
}
|
||||
|
||||
class HttpFavoriteRepository implements FavoriteRepository {
|
||||
const HttpFavoriteRepository(this._api, {this.isSignedIn});
|
||||
|
||||
final LibraryApi _api;
|
||||
|
||||
/// 未登录时不请求:收藏属于账号,匿名浏览时列表按空处理(点收藏按钮会先引导登录)。
|
||||
final bool Function()? isSignedIn;
|
||||
|
||||
bool get _signedIn => isSignedIn?.call() ?? true;
|
||||
|
||||
@override
|
||||
Future<bool> toggle(String dramaId) => _api.toggleFavorite(dramaId);
|
||||
|
||||
@override
|
||||
Future<List<Drama>> list() async {
|
||||
if (!_signedIn) return const [];
|
||||
return _api.favorites();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String dramaId) => _api.removeFavorite(dramaId);
|
||||
}
|
||||
|
||||
/// 内存实现:单测与 widget 测试注入用。
|
||||
class InMemoryFavoriteRepository implements FavoriteRepository {
|
||||
InMemoryFavoriteRepository({Set<String>? saved, List<Drama>? cards})
|
||||
: _saved = {...?saved},
|
||||
_cards = [...?cards];
|
||||
|
||||
final Set<String> _saved;
|
||||
final List<Drama> _cards;
|
||||
bool failToggle = false;
|
||||
bool failList = false;
|
||||
int toggleCalls = 0;
|
||||
|
||||
@override
|
||||
Future<bool> toggle(String dramaId) async {
|
||||
toggleCalls += 1;
|
||||
if (failToggle) throw StateError('favorite unavailable');
|
||||
if (!_saved.add(dramaId)) _saved.remove(dramaId);
|
||||
return _saved.contains(dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Drama>> list() async {
|
||||
if (failList) throw StateError('favorite unavailable');
|
||||
return _cards.where((card) => _saved.contains(card.id)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String dramaId) async => _saved.remove(dramaId);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/play_ticket.dart';
|
||||
import '../remote/playback_api.dart';
|
||||
|
||||
/// 播放票据来源。
|
||||
///
|
||||
/// 票据里的签名地址有有效期(契约 §1.4:签名默认 15 分钟),所以**不能一直缓存**:
|
||||
/// [ticketFor] 命中未过期的缓存就用缓存,过期或 [forceRefresh] 时重新向后端要一张。
|
||||
abstract interface class PlaybackRepository {
|
||||
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false});
|
||||
|
||||
/// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。
|
||||
bool isFresh(String episodeId);
|
||||
}
|
||||
|
||||
class HttpPlaybackRepository implements PlaybackRepository {
|
||||
HttpPlaybackRepository(
|
||||
this._api, {
|
||||
this.ttl = const Duration(minutes: 10),
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now;
|
||||
|
||||
final PlaybackApi _api;
|
||||
|
||||
/// 缓存时长:比后端 15 分钟签名短 5 分钟,避免"地址刚好在播放中途失效"。
|
||||
final Duration ttl;
|
||||
final DateTime Function() _clock;
|
||||
final Map<String, _CachedTicket> _cache = {};
|
||||
|
||||
@override
|
||||
Future<PlayTicket> ticketFor(
|
||||
String episodeId, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final cached = _cache[episodeId];
|
||||
if (!forceRefresh && cached != null && !cached.isExpired(_clock(), ttl)) {
|
||||
return cached.ticket;
|
||||
}
|
||||
final ticket = await _api.ticket(episodeId);
|
||||
_cache[episodeId] = _CachedTicket(ticket, _clock());
|
||||
return ticket;
|
||||
}
|
||||
|
||||
@override
|
||||
bool isFresh(String episodeId) {
|
||||
final cached = _cache[episodeId];
|
||||
if (cached == null) return false;
|
||||
return !cached.isExpired(_clock(), ttl);
|
||||
}
|
||||
|
||||
/// 供测试与"退出播放页"清理用。
|
||||
void clear() => _cache.clear();
|
||||
}
|
||||
|
||||
class _CachedTicket {
|
||||
const _CachedTicket(this.ticket, this.fetchedAt);
|
||||
|
||||
final PlayTicket ticket;
|
||||
final DateTime fetchedAt;
|
||||
|
||||
bool isExpired(DateTime now, Duration ttl) => now.difference(fetchedAt) >= ttl;
|
||||
}
|
||||
|
||||
/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。
|
||||
class FakePlaybackRepository implements PlaybackRepository {
|
||||
FakePlaybackRepository({this.ticket, this.error, this.fresh = true});
|
||||
|
||||
PlayTicket? ticket;
|
||||
Object? error;
|
||||
final List<String> requests = [];
|
||||
final List<bool> forced = [];
|
||||
|
||||
/// 模拟"票据是否还有效":置 false 可验证回前台会重新取。
|
||||
bool fresh;
|
||||
|
||||
@override
|
||||
bool isFresh(String episodeId) => fresh;
|
||||
|
||||
@override
|
||||
Future<PlayTicket> ticketFor(
|
||||
String episodeId, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
requests.add(episodeId);
|
||||
forced.add(forceRefresh);
|
||||
if (error != null) throw error!;
|
||||
return ticket ??
|
||||
PlayTicket(
|
||||
episodeId: episodeId,
|
||||
accessible: true,
|
||||
accessType: PlayTicket.free,
|
||||
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,142 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
// Author: fruit.com.cn
|
||||
|
||||
const demoProfileId = 'demo-local-v1';
|
||||
import '../models/watch_progress.dart';
|
||||
import '../remote/watch_api.dart';
|
||||
|
||||
/// 观看进度来源。
|
||||
///
|
||||
/// **服务端按 `(user, drama)` 只留一条续看记录**(契约 §1.5),所以这里不再是
|
||||
/// "本地按 profile+剧+集存位置",而是"这部剧看到哪一集、哪一秒"。
|
||||
/// 本地实现已删除:进度以服务端为准,换设备也能续看。
|
||||
abstract interface class WatchHistoryRepository {
|
||||
Future<Duration?> positionFor(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
);
|
||||
Future<String?> lastEpisodeId(String profileId, String dramaId);
|
||||
Future<void> save(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
Duration position,
|
||||
);
|
||||
/// 某部剧的续看记录;没有记录返回 null(不是异常)。
|
||||
Future<WatchProgress?> progressFor(String dramaId);
|
||||
|
||||
/// 上报进度(客户端每 10 秒一次,切集/退出/切后台各补一次)。
|
||||
Future<WatchProgress?> save({
|
||||
required String episodeId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
});
|
||||
|
||||
/// 最近观看(默认 20 条)。
|
||||
Future<List<WatchProgress>> recent({int limit = 20});
|
||||
|
||||
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
|
||||
Future<void> remove(String dramaId);
|
||||
}
|
||||
|
||||
class SharedPreferencesWatchHistoryRepository
|
||||
implements WatchHistoryRepository {
|
||||
SharedPreferencesWatchHistoryRepository({SharedPreferencesAsync? preferences})
|
||||
: _preferences = preferences ?? SharedPreferencesAsync();
|
||||
class HttpWatchHistoryRepository implements WatchHistoryRepository {
|
||||
const HttpWatchHistoryRepository(this._api, {this.isSignedIn});
|
||||
|
||||
final SharedPreferencesAsync _preferences;
|
||||
final WatchApi _api;
|
||||
|
||||
String _positionKey(String profile, String drama, String episode) =>
|
||||
'watch.v1.$profile.$drama.$episode.positionMs';
|
||||
String _lastKey(String profile, String drama) =>
|
||||
'watch.v1.$profile.$drama.lastEpisode';
|
||||
/// 未登录时这些接口必然 401:直接短路成"没有记录",让浏览/试看保持匿名可用。
|
||||
final bool Function()? isSignedIn;
|
||||
|
||||
bool get _signedIn => isSignedIn?.call() ?? true;
|
||||
|
||||
@override
|
||||
Future<Duration?> positionFor(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
) async {
|
||||
final milliseconds = await _preferences.getInt(
|
||||
_positionKey(profileId, dramaId, episodeId),
|
||||
);
|
||||
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
|
||||
Future<WatchProgress?> progressFor(String dramaId) async {
|
||||
if (!_signedIn) return null;
|
||||
return _api.progressOfDrama(dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> lastEpisodeId(String profileId, String dramaId) =>
|
||||
_preferences.getString(_lastKey(profileId, dramaId));
|
||||
Future<WatchProgress?> save({
|
||||
required String episodeId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
}) async {
|
||||
if (!_signedIn) return null;
|
||||
return _api.saveProgress(
|
||||
episodeId: episodeId,
|
||||
position: position,
|
||||
duration: duration,
|
||||
finished: finished,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
Duration position,
|
||||
) async {
|
||||
await _preferences.setInt(
|
||||
_positionKey(profileId, dramaId, episodeId),
|
||||
position.inMilliseconds,
|
||||
);
|
||||
await _preferences.setString(_lastKey(profileId, dramaId), episodeId);
|
||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
||||
if (!_signedIn) return const [];
|
||||
return _api.recent(limit: limit);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String dramaId) async {
|
||||
if (!_signedIn) return;
|
||||
await _api.removeProgress(dramaId);
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存实现:单测与 widget 测试注入用(不依赖网络与偏好存储)。
|
||||
class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
|
||||
InMemoryWatchHistoryRepository({List<WatchProgress>? seed})
|
||||
: _records = {for (final item in seed ?? const <WatchProgress>[]) item.dramaId: item};
|
||||
|
||||
final Map<String, WatchProgress> _records;
|
||||
int failReads = 0;
|
||||
bool failWrites = false;
|
||||
final List<String> writes = [];
|
||||
|
||||
@override
|
||||
Future<WatchProgress?> progressFor(String dramaId) async {
|
||||
if (failReads > 0) {
|
||||
failReads -= 1;
|
||||
throw StateError('progress unavailable');
|
||||
}
|
||||
return _records[dramaId];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WatchProgress?> save({
|
||||
required String episodeId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
}) async {
|
||||
if (failWrites) throw StateError('progress unavailable');
|
||||
writes.add('$episodeId|${position.inMilliseconds}|$finished');
|
||||
final existing = _records[episodeId];
|
||||
final record = WatchProgress(
|
||||
dramaId: existing?.dramaId ?? '',
|
||||
episodeId: episodeId,
|
||||
position: position,
|
||||
duration: duration ?? Duration.zero,
|
||||
finished: finished,
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
||||
if (failReads > 0) {
|
||||
failReads -= 1;
|
||||
throw StateError('progress unavailable');
|
||||
}
|
||||
return _records.values.take(limit).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String dramaId) async => _records.remove(dramaId);
|
||||
}
|
||||
|
||||
/// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。
|
||||
extension WatchProgressResume on WatchHistoryRepository {
|
||||
Future<ResumePoint?> resumePointFor(String dramaId) async {
|
||||
final progress = await progressFor(dramaId);
|
||||
if (progress == null || progress.episodeId.isEmpty) return null;
|
||||
return ResumePoint(
|
||||
episodeId: progress.episodeId,
|
||||
position: progress.position,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 续看位置钳位:片尾附近与异常值一律从 0 开始,避免"一进来就播完"。
|
||||
Duration clampResumePosition(Duration saved, Duration duration) {
|
||||
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
|
||||
if (saved <= Duration.zero) return Duration.zero;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/auth_session.dart';
|
||||
import '../remote/api_client.dart';
|
||||
import '../remote/auth_api.dart';
|
||||
import 'auth_session_store.dart';
|
||||
|
||||
/// 登录态的唯一来源。
|
||||
///
|
||||
/// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新;
|
||||
/// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 ——
|
||||
/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。
|
||||
class AuthController extends ChangeNotifier {
|
||||
AuthController(this._api, this._store);
|
||||
|
||||
final AuthApi _api;
|
||||
final AuthSessionStore _store;
|
||||
|
||||
AuthSession? _session;
|
||||
String _deviceId = '';
|
||||
bool _loaded = false;
|
||||
|
||||
bool get isLoaded => _loaded;
|
||||
bool get isSignedIn => _session != null;
|
||||
AuthUser? get user => _session?.user;
|
||||
String? get accessToken => _session?.accessToken;
|
||||
String get deviceId => _deviceId;
|
||||
|
||||
/// App 启动时调用:恢复本地令牌与设备号。
|
||||
Future<void> load() async {
|
||||
_deviceId = await _store.deviceId();
|
||||
_session = await _store.read();
|
||||
_loaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> register({required String email, required String password}) async {
|
||||
final session = await _api.register(
|
||||
email: email,
|
||||
password: password,
|
||||
deviceId: await _store.deviceId(),
|
||||
platform: _platform,
|
||||
);
|
||||
await _apply(session);
|
||||
}
|
||||
|
||||
Future<void> signIn({required String email, required String password}) async {
|
||||
final session = await _api.login(
|
||||
email: email,
|
||||
password: password,
|
||||
deviceId: await _store.deviceId(),
|
||||
platform: _platform,
|
||||
);
|
||||
await _apply(session);
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
final refreshToken = _session?.refreshToken ?? '';
|
||||
_session = null;
|
||||
await _store.clear();
|
||||
notifyListeners();
|
||||
if (refreshToken.isNotEmpty) {
|
||||
// 服务端吊销失败不影响本地已登出,异常直接吞掉
|
||||
try {
|
||||
await _api.logout(refreshToken);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 供 [ApiClient] 调用:刷新令牌并落盘。失败时清本地会话。
|
||||
Future<bool> refresh() async {
|
||||
final refreshToken = _session?.refreshToken ?? '';
|
||||
if (refreshToken.isEmpty) {
|
||||
await clear();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await _apply(await _api.refresh(refreshToken));
|
||||
return true;
|
||||
} catch (_) {
|
||||
await clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
if (_session == null) return;
|
||||
_session = null;
|
||||
await _store.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _apply(AuthSession session) async {
|
||||
_session = session;
|
||||
await _store.save(session);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String get _platform {
|
||||
if (kIsWeb) return 'WEB';
|
||||
return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/auth_session.dart';
|
||||
|
||||
/// 令牌与设备号的本地持久化。
|
||||
///
|
||||
/// 用 `SharedPreferencesAsync`(与项目里 engagement 仓库一致):旧的
|
||||
/// `SharedPreferences.getInstance()` 在新版本里已标注弃用,两套混用还会让
|
||||
/// widget 测试只 mock 到其中一套。
|
||||
///
|
||||
/// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**;
|
||||
/// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。
|
||||
class AuthSessionStore {
|
||||
static const _kAccessToken = 'auth.accessToken';
|
||||
static const _kRefreshToken = 'auth.refreshToken';
|
||||
static const _kUser = 'auth.user';
|
||||
static const _kDeviceId = 'device.id';
|
||||
|
||||
String? _cachedDeviceId;
|
||||
|
||||
Future<String> deviceId() async {
|
||||
if (_cachedDeviceId != null) return _cachedDeviceId!;
|
||||
final prefs = SharedPreferencesAsync();
|
||||
var value = await prefs.getString(_kDeviceId) ?? '';
|
||||
if (value.isEmpty) {
|
||||
final random = Random();
|
||||
value =
|
||||
'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-'
|
||||
'${random.nextInt(1 << 32).toRadixString(36)}';
|
||||
await prefs.setString(_kDeviceId, value);
|
||||
}
|
||||
_cachedDeviceId = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<AuthSession?> read() async {
|
||||
final prefs = SharedPreferencesAsync();
|
||||
final accessToken = await prefs.getString(_kAccessToken) ?? '';
|
||||
final refreshToken = await prefs.getString(_kRefreshToken) ?? '';
|
||||
if (accessToken.isEmpty || refreshToken.isEmpty) return null;
|
||||
final rawUser = await prefs.getString(_kUser);
|
||||
Map<String, dynamic> user = const {};
|
||||
if (rawUser != null && rawUser.isNotEmpty) {
|
||||
try {
|
||||
user = (jsonDecode(rawUser) as Map).cast<String, dynamic>();
|
||||
} catch (_) {
|
||||
user = const {};
|
||||
}
|
||||
}
|
||||
return AuthSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
user: AuthUser.fromJson(user),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> save(AuthSession session) async {
|
||||
final prefs = SharedPreferencesAsync();
|
||||
await prefs.setString(_kAccessToken, session.accessToken);
|
||||
await prefs.setString(_kRefreshToken, session.refreshToken);
|
||||
await prefs.setString(_kUser, jsonEncode(session.user.toJson()));
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = SharedPreferencesAsync();
|
||||
await prefs.remove(_kAccessToken);
|
||||
await prefs.remove(_kRefreshToken);
|
||||
await prefs.remove(_kUser);
|
||||
// 设备号不清:它代表"这台设备",与登录态无关
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,65 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/drama.dart';
|
||||
import '../repositories/catalog_repository.dart';
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../repositories/catalog_repository.dart';
|
||||
import '../remote/api_client.dart';
|
||||
|
||||
/// 目录状态:首页/发现页聚合数据 + 详情按需加载。
|
||||
///
|
||||
/// 列表卡片不含剧集,只有进详情或播放时才取一次详情,
|
||||
/// 这样首页接口能保持很小的响应体。
|
||||
class CatalogController extends ChangeNotifier {
|
||||
CatalogController(this.repository);
|
||||
|
||||
final CatalogRepository repository;
|
||||
|
||||
List<Drama> dramas = const [];
|
||||
HomeFeed feed = const HomeFeed();
|
||||
bool loading = false;
|
||||
String? error;
|
||||
|
||||
List<Drama> get dramas => feed.dramas;
|
||||
List<FeedBanner> get banners => feed.banners;
|
||||
List<FeedSection> get sections => feed.sections;
|
||||
|
||||
Future<void> load() async {
|
||||
loading = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
dramas = await repository.fetchDramas();
|
||||
feed = await repository.fetchHomeFeed();
|
||||
} on ApiException catch (exception) {
|
||||
error = exception.message;
|
||||
} catch (_) {
|
||||
error = 'The catalog could not be loaded.';
|
||||
error = 'catalogError';
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 需要完整剧集时补一次详情;已加载过则直接返回,避免重复请求。
|
||||
Future<Drama> requireDetail(Drama drama) async {
|
||||
if (drama.detailLoaded) return drama;
|
||||
final detail = await repository.fetchDrama(drama.id);
|
||||
_replace(detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
void _replace(Drama detail) {
|
||||
final sections = feed.sections
|
||||
.map(
|
||||
(section) => FeedSection(
|
||||
code: section.code,
|
||||
title: section.title,
|
||||
dramas: [
|
||||
for (final drama in section.dramas)
|
||||
if (drama.id == detail.id) detail else drama,
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
feed = HomeFeed(banners: feed.banners, sections: sections);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
|
||||
/// 收藏状态:**以服务端为准**。
|
||||
///
|
||||
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
|
||||
/// "点了没反应"或"看起来收藏了其实没有"。
|
||||
class FavoriteController extends ChangeNotifier {
|
||||
FavoriteController(this._repository);
|
||||
|
||||
final FavoriteRepository _repository;
|
||||
final Set<String> _saved = {};
|
||||
List<Drama> cards = const [];
|
||||
bool loading = false;
|
||||
String? error;
|
||||
bool _disposed = false;
|
||||
|
||||
Set<String> get savedIds => Set.unmodifiable(_saved);
|
||||
|
||||
bool isSaved(String dramaId) => _saved.contains(dramaId);
|
||||
|
||||
void notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
|
||||
Future<void> load() async {
|
||||
loading = true;
|
||||
error = null;
|
||||
notify();
|
||||
try {
|
||||
final list = await _repository.list();
|
||||
cards = list;
|
||||
_saved
|
||||
..clear()
|
||||
..addAll(list.map((drama) => drama.id));
|
||||
} catch (_) {
|
||||
error = 'favoritesError';
|
||||
} finally {
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 未登录时不要调用(服务端要求登录态)。
|
||||
///
|
||||
/// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
|
||||
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
|
||||
Future<bool> toggle(String dramaId, {Drama? card}) async {
|
||||
final wasSaved = _saved.contains(dramaId);
|
||||
// 乐观更新
|
||||
if (wasSaved) {
|
||||
_saved.remove(dramaId);
|
||||
cards = cards.where((drama) => drama.id != dramaId).toList();
|
||||
} else {
|
||||
_saved.add(dramaId);
|
||||
}
|
||||
notify();
|
||||
try {
|
||||
final saved = await _repository.toggle(dramaId);
|
||||
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
|
||||
if (saved) {
|
||||
_saved.add(dramaId);
|
||||
if (card != null && !cards.any((drama) => drama.id == dramaId)) {
|
||||
cards = [card, ...cards];
|
||||
}
|
||||
} else {
|
||||
_saved.remove(dramaId);
|
||||
cards = cards.where((drama) => drama.id != dramaId).toList();
|
||||
}
|
||||
notify();
|
||||
return true;
|
||||
} catch (_) {
|
||||
// 回滚
|
||||
if (wasSaved) {
|
||||
_saved.add(dramaId);
|
||||
} else {
|
||||
_saved.remove(dramaId);
|
||||
}
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消收藏("我的"页列表里删除某条)。
|
||||
Future<bool> remove(String dramaId) async {
|
||||
final removed = cards.firstWhere(
|
||||
(drama) => drama.id == dramaId,
|
||||
orElse: () => const Drama(
|
||||
id: '',
|
||||
title: '',
|
||||
synopsis: '',
|
||||
posterUrl: '',
|
||||
),
|
||||
);
|
||||
final hadCard = removed.id.isNotEmpty;
|
||||
_saved.remove(dramaId);
|
||||
cards = cards.where((drama) => drama.id != dramaId).toList();
|
||||
notify();
|
||||
try {
|
||||
await _repository.remove(dramaId);
|
||||
return true;
|
||||
} catch (_) {
|
||||
if (hadCard) cards = [...cards, removed];
|
||||
_saved.add(dramaId);
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
|
||||
void clear() {
|
||||
_saved.clear();
|
||||
cards = const [];
|
||||
error = null;
|
||||
notify();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,9 @@ class PlaybackSession extends ChangeNotifier {
|
||||
if (!slot.retiring && !_closed) slot.prepared = true;
|
||||
} catch (error) {
|
||||
slot.failure = error;
|
||||
if (kDebugMode) {
|
||||
debugPrint('片源准备失败 ${episodes[index].videoUrl}: $error');
|
||||
}
|
||||
}
|
||||
}();
|
||||
return slot;
|
||||
@@ -149,14 +152,23 @@ class PlaybackSession extends ChangeNotifier {
|
||||
/// use exactly the same mechanism without warming an unrelated episode.
|
||||
Future<void> select(int index, {int? preloadIndex}) async {
|
||||
if (_closed || index < 0 || index >= episodes.length) return;
|
||||
// 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。
|
||||
// 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。
|
||||
if (!_selected || index != _currentIndex) {
|
||||
_userWantsPlay = true;
|
||||
}
|
||||
final request = ++_generation;
|
||||
_selected = true;
|
||||
_currentIndex = index;
|
||||
_nextIndex = preloadIndex;
|
||||
_error = null;
|
||||
if (!_visible) {
|
||||
_loading = false;
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
_loading = preparedPlayerAt(index) == null && !isLocked;
|
||||
_notify();
|
||||
if (!_visible) return;
|
||||
final acquired = await _coordinator._acquire(
|
||||
this,
|
||||
() => _visible && request == _generation,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import '../models/drama.dart';
|
||||
import '../models/player_port.dart';
|
||||
|
||||
@@ -7,11 +8,25 @@ class VideoPlayerPort implements PlayerPort {
|
||||
VideoPlayerPort(
|
||||
Episode episode, {
|
||||
Future<ClosedCaptionFile>? closedCaptionFile,
|
||||
}) : controller = VideoPlayerController.asset(
|
||||
episode.assetPath,
|
||||
closedCaptionFile: closedCaptionFile,
|
||||
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false),
|
||||
);
|
||||
}) : controller = _open(episode, closedCaptionFile);
|
||||
|
||||
/// 后端下发的是相对路径(`/files/...`),交给播放器前补上服务地址。
|
||||
/// 否则 Uri 没有 scheme/host,AVPlayer 和 ExoPlayer 都加载不了。
|
||||
static Uri resolveUri(Episode episode) =>
|
||||
Uri.parse(AppConfig.mediaUrl(episode.videoUrl));
|
||||
|
||||
static VideoPlayerController _open(
|
||||
Episode episode,
|
||||
Future<ClosedCaptionFile>? closedCaptionFile,
|
||||
) {
|
||||
final options = VideoPlayerOptions(mixWithOthers: false);
|
||||
// 片源统一由后端下发(详情里的可播放地址 / 播放票据)
|
||||
return VideoPlayerController.networkUrl(
|
||||
resolveUri(episode),
|
||||
closedCaptionFile: closedCaptionFile,
|
||||
videoPlayerOptions: options,
|
||||
);
|
||||
}
|
||||
|
||||
final VideoPlayerController controller;
|
||||
Future<void>? _initialization;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与
|
||||
/// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。
|
||||
String errorText(AppLocalizations l10n, String? code, String fallback) {
|
||||
if (code == null || code.isEmpty) return fallback;
|
||||
return switch (code) {
|
||||
'networkError' => l10n.networkError,
|
||||
'progressError' => l10n.progressError,
|
||||
'favoritesError' => l10n.favoritesError,
|
||||
'ticketError' => l10n.playbackError,
|
||||
final value => value,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared colors. Blue is the primary action color.
|
||||
abstract final class AppColors {
|
||||
static const blue = Color(0xFF2F7BFF);
|
||||
static const blueSoft = Color(0xFF8FB4FF);
|
||||
static const ink = Color(0xFF0B0C10);
|
||||
static const bar = Color(0xFF101114);
|
||||
static const card = Color(0xFF17191F);
|
||||
static const muted = Color(0xFFB7BDC9);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../pages/auth/auth_view.dart';
|
||||
import '../data/services/auth_controller.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库)
|
||||
Future<void> openAuthPage(BuildContext context, {bool signUpMode = false}) {
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => AuthPage(signUpMode: signUpMode),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 受限动作的登录闸:已登录返回 true;未登录弹底部提示并引导登录,返回 false。
|
||||
///
|
||||
/// 首页/发现/详情保持匿名可看,只有收藏、点赞、评论这类"要对到具体账号"的动作才走这里。
|
||||
Future<bool> ensureSignedIn(BuildContext context) async {
|
||||
final auth = context.read<AuthController>();
|
||||
if (auth.isSignedIn) return true;
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final go = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.card,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.authGateTitle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.authGateMessage,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
),
|
||||
onPressed: () => Navigator.of(sheetContext).pop(true),
|
||||
child: Text(l10n.authGateAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (go == true && context.mounted) {
|
||||
await openAuthPage(context);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。
|
||||
class RemoteImage extends StatelessWidget {
|
||||
const RemoteImage(
|
||||
this.path, {
|
||||
super.key,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = AppConfig.mediaUrl(path);
|
||||
if (url.isEmpty) return _fallback();
|
||||
return Image.network(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
errorBuilder: (_, _, _) => _fallback(),
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return _fallback();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fallback() {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
color: const Color(0xFF1C1F26),
|
||||
);
|
||||
}
|
||||
}
|
||||
+119
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"appName": "Nontonki",
|
||||
"appName": "FruitShorts",
|
||||
"demo": "DEMO",
|
||||
"homeHeadline": "Watch now",
|
||||
"homeSubtitle": "Free short drama preview.",
|
||||
@@ -34,7 +34,13 @@
|
||||
"localDemoProfile": "Local demo profile",
|
||||
"continueWatching": "Continue Watching",
|
||||
"watchedPercent": "Watched {percent}%",
|
||||
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
|
||||
"@watchedPercent": {
|
||||
"placeholders": {
|
||||
"percent": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resume": "Resume",
|
||||
"history": "History",
|
||||
"watchlist": "Watchlist",
|
||||
@@ -72,16 +78,40 @@
|
||||
"originalTag": "Original",
|
||||
"dramaTag": "Short drama",
|
||||
"episodeLabel": "Episode {number}",
|
||||
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
|
||||
"@episodeLabel": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exploreSeries": "Explore series",
|
||||
"episodeCount": "{count} EPISODES",
|
||||
"@episodeCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"@episodeCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"technicalDemoCount": "FREE PREVIEW · {count} EPISODES",
|
||||
"@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"@technicalDemoCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"episodes": "Episodes",
|
||||
"watchEpisodeOne": "Watch episode 1",
|
||||
"continueEpisode": "Continue episode {number}",
|
||||
"@continueEpisode": {"placeholders": {"number": {"type": "int"}}},
|
||||
"@continueEpisode": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"freeTestMedia": "Same demo clip · free",
|
||||
"lockedStoreUnavailable": "Locked · store unavailable",
|
||||
"freeTestClip": "Free test clip",
|
||||
@@ -89,13 +119,30 @@
|
||||
"freeSamplesNotice": "Two free samples · store setup pending",
|
||||
"demoTestMedia": "FREE · SAME DEMO CLIP",
|
||||
"episodeProgress": "EPISODE {number} / {total}",
|
||||
"@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}},
|
||||
"@episodeProgress": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"testVideoNotice": "Self-generated technical test video",
|
||||
"lockedTitle": "Episode {number} is locked",
|
||||
"@lockedTitle": {"placeholders": {"number": {"type": "int"}}},
|
||||
"@lockedTitle": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.",
|
||||
"playbackError": "This clip could not be played.",
|
||||
"catalogError": "The catalog could not be loaded.",
|
||||
"detailError": "The episode list could not be loaded.",
|
||||
"noMediaNotice": "This episode has no playable media yet.",
|
||||
"noStories": "No stories available yet.",
|
||||
"retry": "Retry",
|
||||
"backToSeries": "Back to series",
|
||||
@@ -129,6 +176,68 @@
|
||||
"genreDramaMystery": "Drama · Mystery",
|
||||
"continueWatchingHeading": "CONTINUE WATCHING",
|
||||
"preferencesHeading": "PREFERENCES",
|
||||
"brandMark": "NONTONKI · DEMO",
|
||||
"shareFooter": "Nontonki demo"
|
||||
"brandMark": "FRUITSHORTS",
|
||||
"shareFooter": "FruitShorts",
|
||||
"shortsTab": "Shorts",
|
||||
"myListTab": "My List",
|
||||
"watchFull": "Watch Full",
|
||||
"swipeForNext": "Swipe up for next",
|
||||
"clearScreen": "Clear screen",
|
||||
"exitClearScreen": "Exit clear screen",
|
||||
"logIn": "Log in",
|
||||
"logInHint": "Keep your coins and list",
|
||||
"rewards": "Rewards",
|
||||
"forYou": "For You",
|
||||
"hot": "Hot",
|
||||
"newest": "New",
|
||||
"favorite": "Favorite",
|
||||
"myAccount": "My account",
|
||||
"coins": "Coins",
|
||||
"topUpWallet": "Top Up Wallet",
|
||||
"vipMembership": "VIP Membership",
|
||||
"notActive": "Not Active",
|
||||
"joinVip": "Join VIP",
|
||||
"earnRewards": "Earn Rewards",
|
||||
"freeBonus": "Get Free Bonus",
|
||||
"watchHistory": "Watch History",
|
||||
"settings": "Settings",
|
||||
"signOut": "Sign Out",
|
||||
"mockUserName": "Vicky sheng",
|
||||
"mockUserId": "ID: M1CZWVYE",
|
||||
"mockNotice": "Sample data only.",
|
||||
"topBadge": "TOP 1",
|
||||
"signIn": "Sign In",
|
||||
"signUp": "Sign Up",
|
||||
"authSignInTitle": "Welcome back",
|
||||
"authSignUpTitle": "Create your account",
|
||||
"authSubtitle": "Sign in with your email and password.",
|
||||
"authEmailLabel": "Email",
|
||||
"authPasswordLabel": "Password",
|
||||
"authPasswordHint": "At least 8 characters, letters and numbers",
|
||||
"authEmailRequired": "Enter your email",
|
||||
"authEmailInvalid": "That email doesn't look right",
|
||||
"authPasswordRequired": "Enter your password",
|
||||
"authPasswordLength": "Password must be 8-64 characters",
|
||||
"authPasswordWeak": "Password needs both letters and numbers",
|
||||
"authNetworkError": "Network error, please try again",
|
||||
"authSwitchToSignUp": "New here? Create an account",
|
||||
"authSwitchToSignIn": "Already have an account? Sign in",
|
||||
"authGateTitle": "Sign in to continue",
|
||||
"authGateMessage": "Favorites and comments need an account.",
|
||||
"authGateAction": "Sign In",
|
||||
"cancel": "Not now",
|
||||
"noEpisodesYet": "No playable episodes yet.",
|
||||
"networkError": "Network is unreachable. Please check your connection and retry.",
|
||||
"progressError": "Your watch position could not be loaded.",
|
||||
"favoritesError": "Your favorites could not be loaded.",
|
||||
"favoriteToggleFailed": "Could not update favorites. Please retry.",
|
||||
"remove": "Remove",
|
||||
"unlockComingSoon": "Unlocking with coins is coming soon.",
|
||||
"resumeProgress": "Resume at {position} / {total}",
|
||||
"@resumeProgress": {
|
||||
"placeholders": {
|
||||
"position": {},
|
||||
"total": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
{
|
||||
"@@locale": "id",
|
||||
"appName": "FruitShorts",
|
||||
"demo": "Demo",
|
||||
"homeHeadline": "Tonton sekarang",
|
||||
"homeSubtitle": "Pratinjau drama pendek gratis.",
|
||||
"mediaNotice": "Video demo lokal dari pengguna · Pratinjau gratis",
|
||||
"featuredDemo": "UNGGULAN · DEMO TEKNIS",
|
||||
"demoTitle": "Sinyal",
|
||||
"demoSynopsis": "Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.",
|
||||
"homeTab": "Beranda",
|
||||
"openQuickMenu": "Buka menu cepat",
|
||||
"quickAccess": "Akses cepat",
|
||||
"currentSeries": "Serial saat ini",
|
||||
"discoverTab": "Jelajahi",
|
||||
"searchDiscover": "Cari drama, aktor, genre...",
|
||||
"featured": "Unggulan",
|
||||
"suspense": "Thriller",
|
||||
"romance": "Romantis",
|
||||
"actionGenre": "Aksi",
|
||||
"filter": "Filter",
|
||||
"topCharts": "Peringkat Teratas",
|
||||
"newReleases": "Rilisan Baru",
|
||||
"trendingMicroDramas": "Drama Mini Populer",
|
||||
"seeAll": "Lihat Semua",
|
||||
"artworkOnly": "Gambar",
|
||||
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
|
||||
"videoPending": "Video belum siap",
|
||||
"watchNow": "Tonton gratis",
|
||||
"noSearchResults": "Tidak ada drama yang cocok.",
|
||||
"demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.",
|
||||
"myTab": "Saya",
|
||||
"localViewer": "Penonton Lokal",
|
||||
"localDemoProfile": "Profil demo lokal",
|
||||
"continueWatching": "Lanjut Tonton",
|
||||
"watchedPercent": "Sudah ditonton {percent}%",
|
||||
"@watchedPercent": {
|
||||
"placeholders": {
|
||||
"percent": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resume": "Lanjutkan",
|
||||
"history": "Riwayat",
|
||||
"watchlist": "Daftar Tonton",
|
||||
"likedTab": "Disukai",
|
||||
"all": "Semua",
|
||||
"inProgress": "Sedang Berjalan",
|
||||
"completed": "Selesai",
|
||||
"noSavedShows": "Belum ada tontonan di sini.",
|
||||
"preferences": "Preferensi",
|
||||
"appLanguage": "Bahasa Aplikasi",
|
||||
"followingTab": "Mengikuti",
|
||||
"animatedTab": "Animasi",
|
||||
"recommendedTab": "Rekomendasi",
|
||||
"noCategoryDramas": "Belum ada drama di kategori ini.",
|
||||
"trendingTab": "Sedang Tren",
|
||||
"liveActionTab": "Live Action",
|
||||
"search": "Cari",
|
||||
"free": "Gratis",
|
||||
"mute": "Bisukan",
|
||||
"unmute": "Nyalakan suara",
|
||||
"like": "Suka",
|
||||
"save": "Simpan",
|
||||
"comments": "Komentar",
|
||||
"share": "Bagikan",
|
||||
"localOnlyComments": "Hanya terlihat di perangkat ini",
|
||||
"noComments": "Belum ada komentar.",
|
||||
"writeComment": "Tulis komentar",
|
||||
"postComment": "Kirim",
|
||||
"shareFailed": "Tidak bisa membuka menu berbagi.",
|
||||
"nowPlaying": "Sedang diputar",
|
||||
"watchFullscreen": "Tonton mode lanskap",
|
||||
"exitFullscreen": "Keluar dari layar penuh",
|
||||
"pictureInPicture": "Gambar dalam gambar",
|
||||
"pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini",
|
||||
"originalTag": "Original",
|
||||
"dramaTag": "Drama pendek",
|
||||
"episodeLabel": "Episode {number}",
|
||||
"@episodeLabel": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exploreSeries": "Lihat serial",
|
||||
"episodeCount": "{count} EPISODE",
|
||||
"@episodeCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"technicalDemoCount": "PRATINJAU GRATIS · {count} EPISODE",
|
||||
"@technicalDemoCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"episodes": "Episode",
|
||||
"watchEpisodeOne": "Tonton episode 1",
|
||||
"continueEpisode": "Lanjut episode {number}",
|
||||
"@continueEpisode": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"freeTestMedia": "Klip demo sama · gratis",
|
||||
"lockedStoreUnavailable": "Terkunci · toko belum tersedia",
|
||||
"freeTestClip": "Klip uji gratis",
|
||||
"locked": "Terkunci",
|
||||
"freeSamplesNotice": "Dua sampel gratis · pengaturan toko belum selesai",
|
||||
"demoTestMedia": "GRATIS · KLIP DEMO SAMA",
|
||||
"episodeProgress": "EPISODE {number} / {total}",
|
||||
"@episodeProgress": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"testVideoNotice": "Video uji teknis buatan sendiri",
|
||||
"lockedTitle": "Episode {number} terkunci",
|
||||
"@lockedTitle": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lockedBody": "Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.",
|
||||
"playbackError": "Klip ini tidak bisa diputar.",
|
||||
"catalogError": "Katalog gagal dimuat.",
|
||||
"detailError": "Daftar episode gagal dimuat.",
|
||||
"noMediaNotice": "Episode ini belum punya media yang bisa diputar.",
|
||||
"noStories": "Belum ada cerita.",
|
||||
"retry": "Coba lagi",
|
||||
"backToSeries": "Kembali ke serial",
|
||||
"nextEpisode": "Episode berikutnya",
|
||||
"pause": "Jeda",
|
||||
"play": "Putar",
|
||||
"seekVideo": "Geser posisi video",
|
||||
"seekFailed": "Gagal mengubah posisi putar.",
|
||||
"captions": "Subtitle",
|
||||
"captionsOff": "Subtitle nonaktif",
|
||||
"englishCaptions": "Subtitle bahasa Inggris",
|
||||
"chineseCaptions": "Subtitle bahasa Mandarin",
|
||||
"language": "Bahasa",
|
||||
"systemLanguage": "Sistem",
|
||||
"english": "English",
|
||||
"simplifiedChinese": "简体中文",
|
||||
"episodeOneTitle": "Cahaya Pertama",
|
||||
"episodeTwoTitle": "Frekuensi Baru",
|
||||
"episodeThreeTitle": "Menyeberang",
|
||||
"episodeFourTitle": "Setelah Gema",
|
||||
"dramaSignalTitle": "Sinyal",
|
||||
"dramaSignalSynopsis": "Serial demo untuk tontonan vertikal. Setiap episode memakai satu klip lokal yang sama.",
|
||||
"dramaCityTitle": "Cahaya Kota & Malam Beludru",
|
||||
"dramaCitySynopsis": "Serial romansa demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.",
|
||||
"dramaVengeanceTitle": "Badai Pembalasan",
|
||||
"dramaVengeanceSynopsis": "Serial aksi demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.",
|
||||
"dramaEchoesTitle": "Gema Takdir",
|
||||
"genreSuspenseMystery": "Thriller · Misteri",
|
||||
"genreUrbanRomance": "Romansa Kota",
|
||||
"genreActionThriller": "Aksi · Thriller",
|
||||
"genreDramaMystery": "Drama · Misteri",
|
||||
"continueWatchingHeading": "LANJUT TONTON",
|
||||
"preferencesHeading": "PREFERENSI",
|
||||
"brandMark": "FRUITSHORTS",
|
||||
"shareFooter": "FruitShorts",
|
||||
"shortsTab": "Drama Pendek",
|
||||
"myListTab": "Daftar Saya",
|
||||
"watchFull": "Tonton Penuh",
|
||||
"swipeForNext": "Geser ke atas untuk lanjut",
|
||||
"clearScreen": "Bersihkan layar",
|
||||
"exitClearScreen": "Keluar dari mode bersih",
|
||||
"logIn": "Masuk",
|
||||
"logInHint": "Simpan koin dan daftar kamu",
|
||||
"rewards": "Hadiah",
|
||||
"forYou": "Untuk Kamu",
|
||||
"hot": "Populer",
|
||||
"newest": "Terbaru",
|
||||
"favorite": "Favorit",
|
||||
"myAccount": "Akun saya",
|
||||
"coins": "Koin",
|
||||
"topUpWallet": "Isi Ulang Dompet",
|
||||
"vipMembership": "Keanggotaan VIP",
|
||||
"notActive": "Tidak Aktif",
|
||||
"joinVip": "Gabung VIP",
|
||||
"earnRewards": "Dapatkan Hadiah",
|
||||
"freeBonus": "Bonus Gratis",
|
||||
"watchHistory": "Riwayat Tontonan",
|
||||
"settings": "Pengaturan",
|
||||
"signOut": "Keluar",
|
||||
"mockUserName": "Vicky sheng",
|
||||
"mockUserId": "ID: M1CZWVYE",
|
||||
"mockNotice": "Hanya data contoh.",
|
||||
"topBadge": "TOP 1",
|
||||
"signIn": "Masuk",
|
||||
"signUp": "Daftar",
|
||||
"authSignInTitle": "Selamat datang kembali",
|
||||
"authSignUpTitle": "Buat akun kamu",
|
||||
"authSubtitle": "Masuk dengan email dan kata sandi.",
|
||||
"authEmailLabel": "Email",
|
||||
"authPasswordLabel": "Kata sandi",
|
||||
"authPasswordHint": "Minimal 8 karakter, berisi huruf dan angka",
|
||||
"authEmailRequired": "Masukkan email kamu",
|
||||
"authEmailInvalid": "Email itu sepertinya tidak benar",
|
||||
"authPasswordRequired": "Masukkan kata sandi kamu",
|
||||
"authPasswordLength": "Kata sandi harus 8-64 karakter",
|
||||
"authPasswordWeak": "Kata sandi perlu huruf dan angka",
|
||||
"authNetworkError": "Jaringan bermasalah, coba lagi",
|
||||
"authSwitchToSignUp": "Baru di sini? Buat akun",
|
||||
"authSwitchToSignIn": "Sudah punya akun? Masuk",
|
||||
"authGateTitle": "Masuk untuk melanjutkan",
|
||||
"authGateMessage": "Favorit dan komentar perlu akun.",
|
||||
"authGateAction": "Masuk",
|
||||
"cancel": "Nanti saja",
|
||||
"noEpisodesYet": "Belum ada episode yang bisa diputar.",
|
||||
"networkError": "Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.",
|
||||
"progressError": "Posisi tonton gagal dimuat.",
|
||||
"favoritesError": "Daftar favorit gagal dimuat.",
|
||||
"favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.",
|
||||
"remove": "Hapus",
|
||||
"unlockComingSoon": "Pembukaan dengan koin segera hadir.",
|
||||
"resumeProgress": "Lanjut di {position} / {total}",
|
||||
"@resumeProgress": {
|
||||
"placeholders": {
|
||||
"position": {},
|
||||
"total": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations_en.dart';
|
||||
import 'app_localizations_id.dart';
|
||||
import 'app_localizations_zh.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -95,13 +96,14 @@ abstract class AppLocalizations {
|
||||
/// A list of this localizations delegate's supported locales.
|
||||
static const List<Locale> supportedLocales = <Locale>[
|
||||
Locale('en'),
|
||||
Locale('id'),
|
||||
Locale('zh'),
|
||||
];
|
||||
|
||||
/// No description provided for @appName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nontonki'**
|
||||
/// **'FruitShorts'**
|
||||
String get appName;
|
||||
|
||||
/// No description provided for @demo.
|
||||
@@ -632,6 +634,18 @@ abstract class AppLocalizations {
|
||||
/// **'The catalog could not be loaded.'**
|
||||
String get catalogError;
|
||||
|
||||
/// No description provided for @detailError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The episode list could not be loaded.'**
|
||||
String get detailError;
|
||||
|
||||
/// No description provided for @noMediaNotice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This episode has no playable media yet.'**
|
||||
String get noMediaNotice;
|
||||
|
||||
/// No description provided for @noStories.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -833,14 +847,350 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @brandMark.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'NONTONKI · DEMO'**
|
||||
/// **'FRUITSHORTS'**
|
||||
String get brandMark;
|
||||
|
||||
/// No description provided for @shareFooter.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nontonki demo'**
|
||||
/// **'FruitShorts'**
|
||||
String get shareFooter;
|
||||
|
||||
/// No description provided for @shortsTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shorts'**
|
||||
String get shortsTab;
|
||||
|
||||
/// No description provided for @myListTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'My List'**
|
||||
String get myListTab;
|
||||
|
||||
/// No description provided for @watchFull.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Watch Full'**
|
||||
String get watchFull;
|
||||
|
||||
/// No description provided for @swipeForNext.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Swipe up for next'**
|
||||
String get swipeForNext;
|
||||
|
||||
/// No description provided for @clearScreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear screen'**
|
||||
String get clearScreen;
|
||||
|
||||
/// No description provided for @exitClearScreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exit clear screen'**
|
||||
String get exitClearScreen;
|
||||
|
||||
/// No description provided for @logIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Log in'**
|
||||
String get logIn;
|
||||
|
||||
/// No description provided for @logInHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep your coins and list'**
|
||||
String get logInHint;
|
||||
|
||||
/// No description provided for @rewards.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rewards'**
|
||||
String get rewards;
|
||||
|
||||
/// No description provided for @forYou.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'For You'**
|
||||
String get forYou;
|
||||
|
||||
/// No description provided for @hot.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hot'**
|
||||
String get hot;
|
||||
|
||||
/// No description provided for @newest.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New'**
|
||||
String get newest;
|
||||
|
||||
/// No description provided for @favorite.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Favorite'**
|
||||
String get favorite;
|
||||
|
||||
/// No description provided for @myAccount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'My account'**
|
||||
String get myAccount;
|
||||
|
||||
/// No description provided for @coins.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Coins'**
|
||||
String get coins;
|
||||
|
||||
/// No description provided for @topUpWallet.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Top Up Wallet'**
|
||||
String get topUpWallet;
|
||||
|
||||
/// No description provided for @vipMembership.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'VIP Membership'**
|
||||
String get vipMembership;
|
||||
|
||||
/// No description provided for @notActive.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not Active'**
|
||||
String get notActive;
|
||||
|
||||
/// No description provided for @joinVip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Join VIP'**
|
||||
String get joinVip;
|
||||
|
||||
/// No description provided for @earnRewards.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Earn Rewards'**
|
||||
String get earnRewards;
|
||||
|
||||
/// No description provided for @freeBonus.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Get Free Bonus'**
|
||||
String get freeBonus;
|
||||
|
||||
/// No description provided for @watchHistory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Watch History'**
|
||||
String get watchHistory;
|
||||
|
||||
/// No description provided for @settings.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Settings'**
|
||||
String get settings;
|
||||
|
||||
/// No description provided for @signOut.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign Out'**
|
||||
String get signOut;
|
||||
|
||||
/// No description provided for @mockUserName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Vicky sheng'**
|
||||
String get mockUserName;
|
||||
|
||||
/// No description provided for @mockUserId.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ID: M1CZWVYE'**
|
||||
String get mockUserId;
|
||||
|
||||
/// No description provided for @mockNotice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sample data only.'**
|
||||
String get mockNotice;
|
||||
|
||||
/// No description provided for @topBadge.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'TOP 1'**
|
||||
String get topBadge;
|
||||
|
||||
/// No description provided for @signIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign In'**
|
||||
String get signIn;
|
||||
|
||||
/// No description provided for @signUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign Up'**
|
||||
String get signUp;
|
||||
|
||||
/// No description provided for @authSignInTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Welcome back'**
|
||||
String get authSignInTitle;
|
||||
|
||||
/// No description provided for @authSignUpTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create your account'**
|
||||
String get authSignUpTitle;
|
||||
|
||||
/// No description provided for @authSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in with your email and password.'**
|
||||
String get authSubtitle;
|
||||
|
||||
/// No description provided for @authEmailLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Email'**
|
||||
String get authEmailLabel;
|
||||
|
||||
/// No description provided for @authPasswordLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Password'**
|
||||
String get authPasswordLabel;
|
||||
|
||||
/// No description provided for @authPasswordHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'At least 8 characters, letters and numbers'**
|
||||
String get authPasswordHint;
|
||||
|
||||
/// No description provided for @authEmailRequired.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter your email'**
|
||||
String get authEmailRequired;
|
||||
|
||||
/// No description provided for @authEmailInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'That email doesn\'t look right'**
|
||||
String get authEmailInvalid;
|
||||
|
||||
/// No description provided for @authPasswordRequired.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter your password'**
|
||||
String get authPasswordRequired;
|
||||
|
||||
/// No description provided for @authPasswordLength.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Password must be 8-64 characters'**
|
||||
String get authPasswordLength;
|
||||
|
||||
/// No description provided for @authPasswordWeak.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Password needs both letters and numbers'**
|
||||
String get authPasswordWeak;
|
||||
|
||||
/// No description provided for @authNetworkError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Network error, please try again'**
|
||||
String get authNetworkError;
|
||||
|
||||
/// No description provided for @authSwitchToSignUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New here? Create an account'**
|
||||
String get authSwitchToSignUp;
|
||||
|
||||
/// No description provided for @authSwitchToSignIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Already have an account? Sign in'**
|
||||
String get authSwitchToSignIn;
|
||||
|
||||
/// No description provided for @authGateTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in to continue'**
|
||||
String get authGateTitle;
|
||||
|
||||
/// No description provided for @authGateMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Favorites and comments need an account.'**
|
||||
String get authGateMessage;
|
||||
|
||||
/// No description provided for @authGateAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign In'**
|
||||
String get authGateAction;
|
||||
|
||||
/// No description provided for @cancel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not now'**
|
||||
String get cancel;
|
||||
|
||||
/// No description provided for @noEpisodesYet.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No playable episodes yet.'**
|
||||
String get noEpisodesYet;
|
||||
|
||||
/// No description provided for @networkError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Network is unreachable. Please check your connection and retry.'**
|
||||
String get networkError;
|
||||
|
||||
/// No description provided for @progressError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your watch position could not be loaded.'**
|
||||
String get progressError;
|
||||
|
||||
/// No description provided for @favoritesError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your favorites could not be loaded.'**
|
||||
String get favoritesError;
|
||||
|
||||
/// No description provided for @favoriteToggleFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not update favorites. Please retry.'**
|
||||
String get favoriteToggleFailed;
|
||||
|
||||
/// No description provided for @remove.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove'**
|
||||
String get remove;
|
||||
|
||||
/// No description provided for @unlockComingSoon.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unlocking with coins is coming soon.'**
|
||||
String get unlockComingSoon;
|
||||
|
||||
/// No description provided for @resumeProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Resume at {position} / {total}'**
|
||||
String resumeProgress(Object position, Object total);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
@@ -854,7 +1204,7 @@ class _AppLocalizationsDelegate
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) =>
|
||||
<String>['en', 'zh'].contains(locale.languageCode);
|
||||
<String>['en', 'id', 'zh'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
||||
@@ -865,6 +1215,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
switch (locale.languageCode) {
|
||||
case 'en':
|
||||
return AppLocalizationsEn();
|
||||
case 'id':
|
||||
return AppLocalizationsId();
|
||||
case 'zh':
|
||||
return AppLocalizationsZh();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'Nontonki';
|
||||
String get appName => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get demo => 'DEMO';
|
||||
@@ -294,6 +294,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get catalogError => 'The catalog could not be loaded.';
|
||||
|
||||
@override
|
||||
String get detailError => 'The episode list could not be loaded.';
|
||||
|
||||
@override
|
||||
String get noMediaNotice => 'This episode has no playable media yet.';
|
||||
|
||||
@override
|
||||
String get noStories => 'No stories available yet.';
|
||||
|
||||
@@ -397,8 +403,180 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get preferencesHeading => 'PREFERENCES';
|
||||
|
||||
@override
|
||||
String get brandMark => 'NONTONKI · DEMO';
|
||||
String get brandMark => 'FRUITSHORTS';
|
||||
|
||||
@override
|
||||
String get shareFooter => 'Nontonki demo';
|
||||
String get shareFooter => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get shortsTab => 'Shorts';
|
||||
|
||||
@override
|
||||
String get myListTab => 'My List';
|
||||
|
||||
@override
|
||||
String get watchFull => 'Watch Full';
|
||||
|
||||
@override
|
||||
String get swipeForNext => 'Swipe up for next';
|
||||
|
||||
@override
|
||||
String get clearScreen => 'Clear screen';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => 'Exit clear screen';
|
||||
|
||||
@override
|
||||
String get logIn => 'Log in';
|
||||
|
||||
@override
|
||||
String get logInHint => 'Keep your coins and list';
|
||||
|
||||
@override
|
||||
String get rewards => 'Rewards';
|
||||
|
||||
@override
|
||||
String get forYou => 'For You';
|
||||
|
||||
@override
|
||||
String get hot => 'Hot';
|
||||
|
||||
@override
|
||||
String get newest => 'New';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get myAccount => 'My account';
|
||||
|
||||
@override
|
||||
String get coins => 'Coins';
|
||||
|
||||
@override
|
||||
String get topUpWallet => 'Top Up Wallet';
|
||||
|
||||
@override
|
||||
String get vipMembership => 'VIP Membership';
|
||||
|
||||
@override
|
||||
String get notActive => 'Not Active';
|
||||
|
||||
@override
|
||||
String get joinVip => 'Join VIP';
|
||||
|
||||
@override
|
||||
String get earnRewards => 'Earn Rewards';
|
||||
|
||||
@override
|
||||
String get freeBonus => 'Get Free Bonus';
|
||||
|
||||
@override
|
||||
String get watchHistory => 'Watch History';
|
||||
|
||||
@override
|
||||
String get settings => 'Settings';
|
||||
|
||||
@override
|
||||
String get signOut => 'Sign Out';
|
||||
|
||||
@override
|
||||
String get mockUserName => 'Vicky sheng';
|
||||
|
||||
@override
|
||||
String get mockUserId => 'ID: M1CZWVYE';
|
||||
|
||||
@override
|
||||
String get mockNotice => 'Sample data only.';
|
||||
|
||||
@override
|
||||
String get topBadge => 'TOP 1';
|
||||
|
||||
@override
|
||||
String get signIn => 'Sign In';
|
||||
|
||||
@override
|
||||
String get signUp => 'Sign Up';
|
||||
|
||||
@override
|
||||
String get authSignInTitle => 'Welcome back';
|
||||
|
||||
@override
|
||||
String get authSignUpTitle => 'Create your account';
|
||||
|
||||
@override
|
||||
String get authSubtitle => 'Sign in with your email and password.';
|
||||
|
||||
@override
|
||||
String get authEmailLabel => 'Email';
|
||||
|
||||
@override
|
||||
String get authPasswordLabel => 'Password';
|
||||
|
||||
@override
|
||||
String get authPasswordHint => 'At least 8 characters, letters and numbers';
|
||||
|
||||
@override
|
||||
String get authEmailRequired => 'Enter your email';
|
||||
|
||||
@override
|
||||
String get authEmailInvalid => 'That email doesn\'t look right';
|
||||
|
||||
@override
|
||||
String get authPasswordRequired => 'Enter your password';
|
||||
|
||||
@override
|
||||
String get authPasswordLength => 'Password must be 8-64 characters';
|
||||
|
||||
@override
|
||||
String get authPasswordWeak => 'Password needs both letters and numbers';
|
||||
|
||||
@override
|
||||
String get authNetworkError => 'Network error, please try again';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignUp => 'New here? Create an account';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignIn => 'Already have an account? Sign in';
|
||||
|
||||
@override
|
||||
String get authGateTitle => 'Sign in to continue';
|
||||
|
||||
@override
|
||||
String get authGateMessage => 'Favorites and comments need an account.';
|
||||
|
||||
@override
|
||||
String get authGateAction => 'Sign In';
|
||||
|
||||
@override
|
||||
String get cancel => 'Not now';
|
||||
|
||||
@override
|
||||
String get noEpisodesYet => 'No playable episodes yet.';
|
||||
|
||||
@override
|
||||
String get networkError =>
|
||||
'Network is unreachable. Please check your connection and retry.';
|
||||
|
||||
@override
|
||||
String get progressError => 'Your watch position could not be loaded.';
|
||||
|
||||
@override
|
||||
String get favoritesError => 'Your favorites could not be loaded.';
|
||||
|
||||
@override
|
||||
String get favoriteToggleFailed =>
|
||||
'Could not update favorites. Please retry.';
|
||||
|
||||
@override
|
||||
String get remove => 'Remove';
|
||||
|
||||
@override
|
||||
String get unlockComingSoon => 'Unlocking with coins is coming soon.';
|
||||
|
||||
@override
|
||||
String resumeProgress(Object position, Object total) {
|
||||
return 'Resume at $position / $total';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for Indonesian (`id`).
|
||||
class AppLocalizationsId extends AppLocalizations {
|
||||
AppLocalizationsId([String locale = 'id']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get demo => 'Demo';
|
||||
|
||||
@override
|
||||
String get homeHeadline => 'Tonton sekarang';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Pratinjau drama pendek gratis.';
|
||||
|
||||
@override
|
||||
String get mediaNotice => 'Video demo lokal dari pengguna · Pratinjau gratis';
|
||||
|
||||
@override
|
||||
String get featuredDemo => 'UNGGULAN · DEMO TEKNIS';
|
||||
|
||||
@override
|
||||
String get demoTitle => 'Sinyal';
|
||||
|
||||
@override
|
||||
String get demoSynopsis =>
|
||||
'Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.';
|
||||
|
||||
@override
|
||||
String get homeTab => 'Beranda';
|
||||
|
||||
@override
|
||||
String get openQuickMenu => 'Buka menu cepat';
|
||||
|
||||
@override
|
||||
String get quickAccess => 'Akses cepat';
|
||||
|
||||
@override
|
||||
String get currentSeries => 'Serial saat ini';
|
||||
|
||||
@override
|
||||
String get discoverTab => 'Jelajahi';
|
||||
|
||||
@override
|
||||
String get searchDiscover => 'Cari drama, aktor, genre...';
|
||||
|
||||
@override
|
||||
String get featured => 'Unggulan';
|
||||
|
||||
@override
|
||||
String get suspense => 'Thriller';
|
||||
|
||||
@override
|
||||
String get romance => 'Romantis';
|
||||
|
||||
@override
|
||||
String get actionGenre => 'Aksi';
|
||||
|
||||
@override
|
||||
String get filter => 'Filter';
|
||||
|
||||
@override
|
||||
String get topCharts => 'Peringkat Teratas';
|
||||
|
||||
@override
|
||||
String get newReleases => 'Rilisan Baru';
|
||||
|
||||
@override
|
||||
String get trendingMicroDramas => 'Drama Mini Populer';
|
||||
|
||||
@override
|
||||
String get seeAll => 'Lihat Semua';
|
||||
|
||||
@override
|
||||
String get artworkOnly => 'Gambar';
|
||||
|
||||
@override
|
||||
String get artworkOnlyNotice =>
|
||||
'Ini hanya gambar katalog. Videonya belum tersedia.';
|
||||
|
||||
@override
|
||||
String get videoPending => 'Video belum siap';
|
||||
|
||||
@override
|
||||
String get watchNow => 'Tonton gratis';
|
||||
|
||||
@override
|
||||
String get noSearchResults => 'Tidak ada drama yang cocok.';
|
||||
|
||||
@override
|
||||
String get demoCatalogNotice =>
|
||||
'Tiga drama demo memakai video lokal yang sama.';
|
||||
|
||||
@override
|
||||
String get myTab => 'Saya';
|
||||
|
||||
@override
|
||||
String get localViewer => 'Penonton Lokal';
|
||||
|
||||
@override
|
||||
String get localDemoProfile => 'Profil demo lokal';
|
||||
|
||||
@override
|
||||
String get continueWatching => 'Lanjut Tonton';
|
||||
|
||||
@override
|
||||
String watchedPercent(int percent) {
|
||||
return 'Sudah ditonton $percent%';
|
||||
}
|
||||
|
||||
@override
|
||||
String get resume => 'Lanjutkan';
|
||||
|
||||
@override
|
||||
String get history => 'Riwayat';
|
||||
|
||||
@override
|
||||
String get watchlist => 'Daftar Tonton';
|
||||
|
||||
@override
|
||||
String get likedTab => 'Disukai';
|
||||
|
||||
@override
|
||||
String get all => 'Semua';
|
||||
|
||||
@override
|
||||
String get inProgress => 'Sedang Berjalan';
|
||||
|
||||
@override
|
||||
String get completed => 'Selesai';
|
||||
|
||||
@override
|
||||
String get noSavedShows => 'Belum ada tontonan di sini.';
|
||||
|
||||
@override
|
||||
String get preferences => 'Preferensi';
|
||||
|
||||
@override
|
||||
String get appLanguage => 'Bahasa Aplikasi';
|
||||
|
||||
@override
|
||||
String get followingTab => 'Mengikuti';
|
||||
|
||||
@override
|
||||
String get animatedTab => 'Animasi';
|
||||
|
||||
@override
|
||||
String get recommendedTab => 'Rekomendasi';
|
||||
|
||||
@override
|
||||
String get noCategoryDramas => 'Belum ada drama di kategori ini.';
|
||||
|
||||
@override
|
||||
String get trendingTab => 'Sedang Tren';
|
||||
|
||||
@override
|
||||
String get liveActionTab => 'Live Action';
|
||||
|
||||
@override
|
||||
String get search => 'Cari';
|
||||
|
||||
@override
|
||||
String get free => 'Gratis';
|
||||
|
||||
@override
|
||||
String get mute => 'Bisukan';
|
||||
|
||||
@override
|
||||
String get unmute => 'Nyalakan suara';
|
||||
|
||||
@override
|
||||
String get like => 'Suka';
|
||||
|
||||
@override
|
||||
String get save => 'Simpan';
|
||||
|
||||
@override
|
||||
String get comments => 'Komentar';
|
||||
|
||||
@override
|
||||
String get share => 'Bagikan';
|
||||
|
||||
@override
|
||||
String get localOnlyComments => 'Hanya terlihat di perangkat ini';
|
||||
|
||||
@override
|
||||
String get noComments => 'Belum ada komentar.';
|
||||
|
||||
@override
|
||||
String get writeComment => 'Tulis komentar';
|
||||
|
||||
@override
|
||||
String get postComment => 'Kirim';
|
||||
|
||||
@override
|
||||
String get shareFailed => 'Tidak bisa membuka menu berbagi.';
|
||||
|
||||
@override
|
||||
String get nowPlaying => 'Sedang diputar';
|
||||
|
||||
@override
|
||||
String get watchFullscreen => 'Tonton mode lanskap';
|
||||
|
||||
@override
|
||||
String get exitFullscreen => 'Keluar dari layar penuh';
|
||||
|
||||
@override
|
||||
String get pictureInPicture => 'Gambar dalam gambar';
|
||||
|
||||
@override
|
||||
String get pipUnavailable =>
|
||||
'Gambar dalam gambar tidak tersedia di perangkat ini';
|
||||
|
||||
@override
|
||||
String get originalTag => 'Original';
|
||||
|
||||
@override
|
||||
String get dramaTag => 'Drama pendek';
|
||||
|
||||
@override
|
||||
String episodeLabel(int number) {
|
||||
return 'Episode $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get exploreSeries => 'Lihat serial';
|
||||
|
||||
@override
|
||||
String episodeCount(int count) {
|
||||
return '$count EPISODE';
|
||||
}
|
||||
|
||||
@override
|
||||
String technicalDemoCount(int count) {
|
||||
return 'PRATINJAU GRATIS · $count EPISODE';
|
||||
}
|
||||
|
||||
@override
|
||||
String get episodes => 'Episode';
|
||||
|
||||
@override
|
||||
String get watchEpisodeOne => 'Tonton episode 1';
|
||||
|
||||
@override
|
||||
String continueEpisode(int number) {
|
||||
return 'Lanjut episode $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get freeTestMedia => 'Klip demo sama · gratis';
|
||||
|
||||
@override
|
||||
String get lockedStoreUnavailable => 'Terkunci · toko belum tersedia';
|
||||
|
||||
@override
|
||||
String get freeTestClip => 'Klip uji gratis';
|
||||
|
||||
@override
|
||||
String get locked => 'Terkunci';
|
||||
|
||||
@override
|
||||
String get freeSamplesNotice =>
|
||||
'Dua sampel gratis · pengaturan toko belum selesai';
|
||||
|
||||
@override
|
||||
String get demoTestMedia => 'GRATIS · KLIP DEMO SAMA';
|
||||
|
||||
@override
|
||||
String episodeProgress(int number, int total) {
|
||||
return 'EPISODE $number / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get testVideoNotice => 'Video uji teknis buatan sendiri';
|
||||
|
||||
@override
|
||||
String lockedTitle(int number) {
|
||||
return 'Episode $number terkunci';
|
||||
}
|
||||
|
||||
@override
|
||||
String get lockedBody =>
|
||||
'Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.';
|
||||
|
||||
@override
|
||||
String get playbackError => 'Klip ini tidak bisa diputar.';
|
||||
|
||||
@override
|
||||
String get catalogError => 'Katalog gagal dimuat.';
|
||||
|
||||
@override
|
||||
String get detailError => 'Daftar episode gagal dimuat.';
|
||||
|
||||
@override
|
||||
String get noMediaNotice =>
|
||||
'Episode ini belum punya media yang bisa diputar.';
|
||||
|
||||
@override
|
||||
String get noStories => 'Belum ada cerita.';
|
||||
|
||||
@override
|
||||
String get retry => 'Coba lagi';
|
||||
|
||||
@override
|
||||
String get backToSeries => 'Kembali ke serial';
|
||||
|
||||
@override
|
||||
String get nextEpisode => 'Episode berikutnya';
|
||||
|
||||
@override
|
||||
String get pause => 'Jeda';
|
||||
|
||||
@override
|
||||
String get play => 'Putar';
|
||||
|
||||
@override
|
||||
String get seekVideo => 'Geser posisi video';
|
||||
|
||||
@override
|
||||
String get seekFailed => 'Gagal mengubah posisi putar.';
|
||||
|
||||
@override
|
||||
String get captions => 'Subtitle';
|
||||
|
||||
@override
|
||||
String get captionsOff => 'Subtitle nonaktif';
|
||||
|
||||
@override
|
||||
String get englishCaptions => 'Subtitle bahasa Inggris';
|
||||
|
||||
@override
|
||||
String get chineseCaptions => 'Subtitle bahasa Mandarin';
|
||||
|
||||
@override
|
||||
String get language => 'Bahasa';
|
||||
|
||||
@override
|
||||
String get systemLanguage => 'Sistem';
|
||||
|
||||
@override
|
||||
String get english => 'English';
|
||||
|
||||
@override
|
||||
String get simplifiedChinese => '简体中文';
|
||||
|
||||
@override
|
||||
String get episodeOneTitle => 'Cahaya Pertama';
|
||||
|
||||
@override
|
||||
String get episodeTwoTitle => 'Frekuensi Baru';
|
||||
|
||||
@override
|
||||
String get episodeThreeTitle => 'Menyeberang';
|
||||
|
||||
@override
|
||||
String get episodeFourTitle => 'Setelah Gema';
|
||||
|
||||
@override
|
||||
String get dramaSignalTitle => 'Sinyal';
|
||||
|
||||
@override
|
||||
String get dramaSignalSynopsis =>
|
||||
'Serial demo untuk tontonan vertikal. Setiap episode memakai satu klip lokal yang sama.';
|
||||
|
||||
@override
|
||||
String get dramaCityTitle => 'Cahaya Kota & Malam Beludru';
|
||||
|
||||
@override
|
||||
String get dramaCitySynopsis =>
|
||||
'Serial romansa demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.';
|
||||
|
||||
@override
|
||||
String get dramaVengeanceTitle => 'Badai Pembalasan';
|
||||
|
||||
@override
|
||||
String get dramaVengeanceSynopsis =>
|
||||
'Serial aksi demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.';
|
||||
|
||||
@override
|
||||
String get dramaEchoesTitle => 'Gema Takdir';
|
||||
|
||||
@override
|
||||
String get genreSuspenseMystery => 'Thriller · Misteri';
|
||||
|
||||
@override
|
||||
String get genreUrbanRomance => 'Romansa Kota';
|
||||
|
||||
@override
|
||||
String get genreActionThriller => 'Aksi · Thriller';
|
||||
|
||||
@override
|
||||
String get genreDramaMystery => 'Drama · Misteri';
|
||||
|
||||
@override
|
||||
String get continueWatchingHeading => 'LANJUT TONTON';
|
||||
|
||||
@override
|
||||
String get preferencesHeading => 'PREFERENSI';
|
||||
|
||||
@override
|
||||
String get brandMark => 'FRUITSHORTS';
|
||||
|
||||
@override
|
||||
String get shareFooter => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get shortsTab => 'Drama Pendek';
|
||||
|
||||
@override
|
||||
String get myListTab => 'Daftar Saya';
|
||||
|
||||
@override
|
||||
String get watchFull => 'Tonton Penuh';
|
||||
|
||||
@override
|
||||
String get swipeForNext => 'Geser ke atas untuk lanjut';
|
||||
|
||||
@override
|
||||
String get clearScreen => 'Bersihkan layar';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => 'Keluar dari mode bersih';
|
||||
|
||||
@override
|
||||
String get logIn => 'Masuk';
|
||||
|
||||
@override
|
||||
String get logInHint => 'Simpan koin dan daftar kamu';
|
||||
|
||||
@override
|
||||
String get rewards => 'Hadiah';
|
||||
|
||||
@override
|
||||
String get forYou => 'Untuk Kamu';
|
||||
|
||||
@override
|
||||
String get hot => 'Populer';
|
||||
|
||||
@override
|
||||
String get newest => 'Terbaru';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorit';
|
||||
|
||||
@override
|
||||
String get myAccount => 'Akun saya';
|
||||
|
||||
@override
|
||||
String get coins => 'Koin';
|
||||
|
||||
@override
|
||||
String get topUpWallet => 'Isi Ulang Dompet';
|
||||
|
||||
@override
|
||||
String get vipMembership => 'Keanggotaan VIP';
|
||||
|
||||
@override
|
||||
String get notActive => 'Tidak Aktif';
|
||||
|
||||
@override
|
||||
String get joinVip => 'Gabung VIP';
|
||||
|
||||
@override
|
||||
String get earnRewards => 'Dapatkan Hadiah';
|
||||
|
||||
@override
|
||||
String get freeBonus => 'Bonus Gratis';
|
||||
|
||||
@override
|
||||
String get watchHistory => 'Riwayat Tontonan';
|
||||
|
||||
@override
|
||||
String get settings => 'Pengaturan';
|
||||
|
||||
@override
|
||||
String get signOut => 'Keluar';
|
||||
|
||||
@override
|
||||
String get mockUserName => 'Vicky sheng';
|
||||
|
||||
@override
|
||||
String get mockUserId => 'ID: M1CZWVYE';
|
||||
|
||||
@override
|
||||
String get mockNotice => 'Hanya data contoh.';
|
||||
|
||||
@override
|
||||
String get topBadge => 'TOP 1';
|
||||
|
||||
@override
|
||||
String get signIn => 'Masuk';
|
||||
|
||||
@override
|
||||
String get signUp => 'Daftar';
|
||||
|
||||
@override
|
||||
String get authSignInTitle => 'Selamat datang kembali';
|
||||
|
||||
@override
|
||||
String get authSignUpTitle => 'Buat akun kamu';
|
||||
|
||||
@override
|
||||
String get authSubtitle => 'Masuk dengan email dan kata sandi.';
|
||||
|
||||
@override
|
||||
String get authEmailLabel => 'Email';
|
||||
|
||||
@override
|
||||
String get authPasswordLabel => 'Kata sandi';
|
||||
|
||||
@override
|
||||
String get authPasswordHint => 'Minimal 8 karakter, berisi huruf dan angka';
|
||||
|
||||
@override
|
||||
String get authEmailRequired => 'Masukkan email kamu';
|
||||
|
||||
@override
|
||||
String get authEmailInvalid => 'Email itu sepertinya tidak benar';
|
||||
|
||||
@override
|
||||
String get authPasswordRequired => 'Masukkan kata sandi kamu';
|
||||
|
||||
@override
|
||||
String get authPasswordLength => 'Kata sandi harus 8-64 karakter';
|
||||
|
||||
@override
|
||||
String get authPasswordWeak => 'Kata sandi perlu huruf dan angka';
|
||||
|
||||
@override
|
||||
String get authNetworkError => 'Jaringan bermasalah, coba lagi';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignUp => 'Baru di sini? Buat akun';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignIn => 'Sudah punya akun? Masuk';
|
||||
|
||||
@override
|
||||
String get authGateTitle => 'Masuk untuk melanjutkan';
|
||||
|
||||
@override
|
||||
String get authGateMessage => 'Favorit dan komentar perlu akun.';
|
||||
|
||||
@override
|
||||
String get authGateAction => 'Masuk';
|
||||
|
||||
@override
|
||||
String get cancel => 'Nanti saja';
|
||||
|
||||
@override
|
||||
String get noEpisodesYet => 'Belum ada episode yang bisa diputar.';
|
||||
|
||||
@override
|
||||
String get networkError =>
|
||||
'Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.';
|
||||
|
||||
@override
|
||||
String get progressError => 'Posisi tonton gagal dimuat.';
|
||||
|
||||
@override
|
||||
String get favoritesError => 'Daftar favorit gagal dimuat.';
|
||||
|
||||
@override
|
||||
String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.';
|
||||
|
||||
@override
|
||||
String get remove => 'Hapus';
|
||||
|
||||
@override
|
||||
String get unlockComingSoon => 'Pembukaan dengan koin segera hadir.';
|
||||
|
||||
@override
|
||||
String resumeProgress(Object position, Object total) {
|
||||
return 'Lanjut di $position / $total';
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
AppLocalizationsZh([String locale = 'zh']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'Nontonki';
|
||||
String get appName => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get demo => '演示';
|
||||
@@ -289,6 +289,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get catalogError => '剧目列表加载失败。';
|
||||
|
||||
@override
|
||||
String get detailError => '剧集信息加载失败。';
|
||||
|
||||
@override
|
||||
String get noMediaNotice => '该剧集还没有可播放的片源。';
|
||||
|
||||
@override
|
||||
String get noStories => '暂无剧目。';
|
||||
|
||||
@@ -389,8 +395,178 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get preferencesHeading => '偏好设置';
|
||||
|
||||
@override
|
||||
String get brandMark => 'NONTONKI · 演示';
|
||||
String get brandMark => 'FRUITSHORTS';
|
||||
|
||||
@override
|
||||
String get shareFooter => 'Nontonki 演示';
|
||||
String get shareFooter => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get shortsTab => '短剧';
|
||||
|
||||
@override
|
||||
String get myListTab => '片单';
|
||||
|
||||
@override
|
||||
String get watchFull => '看全集';
|
||||
|
||||
@override
|
||||
String get swipeForNext => '上滑看下一部';
|
||||
|
||||
@override
|
||||
String get clearScreen => '清屏';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => '退出清屏';
|
||||
|
||||
@override
|
||||
String get logIn => '登录';
|
||||
|
||||
@override
|
||||
String get logInHint => '登录后保存金币和片单';
|
||||
|
||||
@override
|
||||
String get rewards => '奖励';
|
||||
|
||||
@override
|
||||
String get forYou => '为你推荐';
|
||||
|
||||
@override
|
||||
String get hot => '热门';
|
||||
|
||||
@override
|
||||
String get newest => '最新';
|
||||
|
||||
@override
|
||||
String get favorite => '收藏';
|
||||
|
||||
@override
|
||||
String get myAccount => '我的账户';
|
||||
|
||||
@override
|
||||
String get coins => '金币';
|
||||
|
||||
@override
|
||||
String get topUpWallet => '充值';
|
||||
|
||||
@override
|
||||
String get vipMembership => '会员';
|
||||
|
||||
@override
|
||||
String get notActive => '未开通';
|
||||
|
||||
@override
|
||||
String get joinVip => '开通会员';
|
||||
|
||||
@override
|
||||
String get earnRewards => '赚奖励';
|
||||
|
||||
@override
|
||||
String get freeBonus => '领免费奖励';
|
||||
|
||||
@override
|
||||
String get watchHistory => '观看历史';
|
||||
|
||||
@override
|
||||
String get settings => '设置';
|
||||
|
||||
@override
|
||||
String get signOut => '退出登录';
|
||||
|
||||
@override
|
||||
String get mockUserName => 'Vicky sheng';
|
||||
|
||||
@override
|
||||
String get mockUserId => 'ID: M1CZWVYE';
|
||||
|
||||
@override
|
||||
String get mockNotice => '当前是示例数据。';
|
||||
|
||||
@override
|
||||
String get topBadge => 'TOP 1';
|
||||
|
||||
@override
|
||||
String get signIn => '登录';
|
||||
|
||||
@override
|
||||
String get signUp => '注册';
|
||||
|
||||
@override
|
||||
String get authSignInTitle => '欢迎回来';
|
||||
|
||||
@override
|
||||
String get authSignUpTitle => '创建账号';
|
||||
|
||||
@override
|
||||
String get authSubtitle => '用邮箱和密码登录。';
|
||||
|
||||
@override
|
||||
String get authEmailLabel => '邮箱';
|
||||
|
||||
@override
|
||||
String get authPasswordLabel => '密码';
|
||||
|
||||
@override
|
||||
String get authPasswordHint => '至少 8 位,需包含字母和数字';
|
||||
|
||||
@override
|
||||
String get authEmailRequired => '请输入邮箱';
|
||||
|
||||
@override
|
||||
String get authEmailInvalid => '邮箱格式不正确';
|
||||
|
||||
@override
|
||||
String get authPasswordRequired => '请输入密码';
|
||||
|
||||
@override
|
||||
String get authPasswordLength => '密码长度需在 8-64 位之间';
|
||||
|
||||
@override
|
||||
String get authPasswordWeak => '密码需同时包含字母和数字';
|
||||
|
||||
@override
|
||||
String get authNetworkError => '网络异常,请稍后重试';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignUp => '还没有账号?去注册';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignIn => '已有账号?去登录';
|
||||
|
||||
@override
|
||||
String get authGateTitle => '登录后可继续';
|
||||
|
||||
@override
|
||||
String get authGateMessage => '收藏、评论等操作需要登录。';
|
||||
|
||||
@override
|
||||
String get authGateAction => '去登录';
|
||||
|
||||
@override
|
||||
String get cancel => '暂不';
|
||||
|
||||
@override
|
||||
String get noEpisodesYet => '还没有可播放的剧集。';
|
||||
|
||||
@override
|
||||
String get networkError => '网络异常,请检查网络后重试。';
|
||||
|
||||
@override
|
||||
String get progressError => '续看进度加载失败。';
|
||||
|
||||
@override
|
||||
String get favoritesError => '收藏列表加载失败。';
|
||||
|
||||
@override
|
||||
String get favoriteToggleFailed => '收藏操作失败,请重试。';
|
||||
|
||||
@override
|
||||
String get remove => '移除';
|
||||
|
||||
@override
|
||||
String get unlockComingSoon => '金币解锁功能即将开放。';
|
||||
|
||||
@override
|
||||
String resumeProgress(Object position, Object total) {
|
||||
return '续看至 $position / $total';
|
||||
}
|
||||
}
|
||||
+81
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"@@locale": "zh",
|
||||
"appName": "Nontonki",
|
||||
"appName": "FruitShorts",
|
||||
"demo": "演示",
|
||||
"homeHeadline": "即刻观看",
|
||||
"homeSubtitle": "免费短剧预览。",
|
||||
@@ -34,7 +34,13 @@
|
||||
"localDemoProfile": "本地演示档案",
|
||||
"continueWatching": "继续观看",
|
||||
"watchedPercent": "已观看 {percent}%",
|
||||
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
|
||||
"@watchedPercent": {
|
||||
"placeholders": {
|
||||
"percent": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resume": "继续播放",
|
||||
"history": "历史",
|
||||
"watchlist": "追剧",
|
||||
@@ -72,7 +78,13 @@
|
||||
"originalTag": "原创",
|
||||
"dramaTag": "短剧",
|
||||
"episodeLabel": "第 {number} 集",
|
||||
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
|
||||
"@episodeLabel": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exploreSeries": "查看剧集",
|
||||
"episodeCount": "{count} 集",
|
||||
"technicalDemoCount": "免费试看 · {count} 集",
|
||||
@@ -91,6 +103,8 @@
|
||||
"lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。",
|
||||
"playbackError": "此视频暂时无法播放。",
|
||||
"catalogError": "剧目列表加载失败。",
|
||||
"detailError": "剧集信息加载失败。",
|
||||
"noMediaNotice": "该剧集还没有可播放的片源。",
|
||||
"noStories": "暂无剧目。",
|
||||
"retry": "重试",
|
||||
"backToSeries": "返回剧集详情",
|
||||
@@ -124,6 +138,68 @@
|
||||
"genreDramaMystery": "剧情 · 悬疑",
|
||||
"continueWatchingHeading": "继续观看",
|
||||
"preferencesHeading": "偏好设置",
|
||||
"brandMark": "NONTONKI · 演示",
|
||||
"shareFooter": "Nontonki 演示"
|
||||
"brandMark": "FRUITSHORTS",
|
||||
"shareFooter": "FruitShorts",
|
||||
"shortsTab": "短剧",
|
||||
"myListTab": "片单",
|
||||
"watchFull": "看全集",
|
||||
"swipeForNext": "上滑看下一部",
|
||||
"clearScreen": "清屏",
|
||||
"exitClearScreen": "退出清屏",
|
||||
"logIn": "登录",
|
||||
"logInHint": "登录后保存金币和片单",
|
||||
"rewards": "奖励",
|
||||
"forYou": "为你推荐",
|
||||
"hot": "热门",
|
||||
"newest": "最新",
|
||||
"favorite": "收藏",
|
||||
"myAccount": "我的账户",
|
||||
"coins": "金币",
|
||||
"topUpWallet": "充值",
|
||||
"vipMembership": "会员",
|
||||
"notActive": "未开通",
|
||||
"joinVip": "开通会员",
|
||||
"earnRewards": "赚奖励",
|
||||
"freeBonus": "领免费奖励",
|
||||
"watchHistory": "观看历史",
|
||||
"settings": "设置",
|
||||
"signOut": "退出登录",
|
||||
"mockUserName": "Vicky sheng",
|
||||
"mockUserId": "ID: M1CZWVYE",
|
||||
"mockNotice": "当前是示例数据。",
|
||||
"topBadge": "TOP 1",
|
||||
"signIn": "登录",
|
||||
"signUp": "注册",
|
||||
"authSignInTitle": "欢迎回来",
|
||||
"authSignUpTitle": "创建账号",
|
||||
"authSubtitle": "用邮箱和密码登录。",
|
||||
"authEmailLabel": "邮箱",
|
||||
"authPasswordLabel": "密码",
|
||||
"authPasswordHint": "至少 8 位,需包含字母和数字",
|
||||
"authEmailRequired": "请输入邮箱",
|
||||
"authEmailInvalid": "邮箱格式不正确",
|
||||
"authPasswordRequired": "请输入密码",
|
||||
"authPasswordLength": "密码长度需在 8-64 位之间",
|
||||
"authPasswordWeak": "密码需同时包含字母和数字",
|
||||
"authNetworkError": "网络异常,请稍后重试",
|
||||
"authSwitchToSignUp": "还没有账号?去注册",
|
||||
"authSwitchToSignIn": "已有账号?去登录",
|
||||
"authGateTitle": "登录后可继续",
|
||||
"authGateMessage": "收藏、评论等操作需要登录。",
|
||||
"authGateAction": "去登录",
|
||||
"cancel": "暂不",
|
||||
"noEpisodesYet": "还没有可播放的剧集。",
|
||||
"networkError": "网络异常,请检查网络后重试。",
|
||||
"progressError": "续看进度加载失败。",
|
||||
"favoritesError": "收藏列表加载失败。",
|
||||
"favoriteToggleFailed": "收藏操作失败,请重试。",
|
||||
"remove": "移除",
|
||||
"unlockComingSoon": "金币解锁功能即将开放。",
|
||||
"resumeProgress": "续看至 {position} / {total}",
|
||||
"@resumeProgress": {
|
||||
"placeholders": {
|
||||
"position": {},
|
||||
"total": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'auth_logic.dart';
|
||||
|
||||
/// 与其它页保持一致的三层写法:binding 负责装配,logic 放状态与逻辑,view 只渲染。
|
||||
class AuthBinding {
|
||||
AuthLogic create({bool signUpMode = false}) => AuthLogic(signUpMode: signUpMode);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/// 登录 / 注册页的表单状态与提交逻辑。
|
||||
///
|
||||
/// 校验规则与后端一致(邮箱格式、密码 8-64 位且含字母数字),本地先拦一次是为了少一次网络往返、
|
||||
/// 也让用户立刻看到哪个字段不合格;后端仍会再校验一遍(前端校验不作为安全边界)。
|
||||
class AuthLogic {
|
||||
bool signUpMode;
|
||||
String email = '';
|
||||
String password = '';
|
||||
bool busy = false;
|
||||
String? errorMessage;
|
||||
|
||||
AuthLogic({this.signUpMode = false, this.errorMessage});
|
||||
|
||||
void toggleMode() {
|
||||
signUpMode = !signUpMode;
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
/// 返回 null 表示校验通过,否则返回错误文案(由视图用 l10n 映射)
|
||||
AuthFieldError? validate() {
|
||||
final mail = email.trim();
|
||||
if (mail.isEmpty) return AuthFieldError.emailRequired;
|
||||
if (!_emailPattern.hasMatch(mail)) return AuthFieldError.emailInvalid;
|
||||
if (password.isEmpty) return AuthFieldError.passwordRequired;
|
||||
if (password.length < 8 || password.length > 64) {
|
||||
return AuthFieldError.passwordLength;
|
||||
}
|
||||
if (!_hasLetter.hasMatch(password) || !_hasDigit.hasMatch(password)) {
|
||||
return AuthFieldError.passwordWeak;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String get normalizedEmail => email.trim().toLowerCase();
|
||||
|
||||
static final _emailPattern = RegExp(
|
||||
r'^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?'
|
||||
r'(\.[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,}$',
|
||||
);
|
||||
static final _hasLetter = RegExp('[A-Za-z]');
|
||||
static final _hasDigit = RegExp('[0-9]');
|
||||
}
|
||||
|
||||
enum AuthFieldError {
|
||||
emailRequired,
|
||||
emailInvalid,
|
||||
passwordRequired,
|
||||
passwordLength,
|
||||
passwordWeak,
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'auth_logic.dart';
|
||||
|
||||
/// 登录 / 注册页:邮箱 + 密码。
|
||||
///
|
||||
/// 风格沿用 FruitShorts 现有页面(深色卡片 + 圆角 + 主色蓝),不另起一套视觉。
|
||||
class AuthPage extends StatefulWidget {
|
||||
const AuthPage({super.key, this.signUpMode = false});
|
||||
|
||||
final bool signUpMode;
|
||||
|
||||
@override
|
||||
State<AuthPage> createState() => _AuthPageState();
|
||||
}
|
||||
|
||||
class _AuthPageState extends State<AuthPage> {
|
||||
late AuthLogic _logic;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_logic = AuthLogic(signUpMode: widget.signUpMode);
|
||||
}
|
||||
|
||||
String _errorText(AppLocalizations l10n, AuthFieldError error) {
|
||||
switch (error) {
|
||||
case AuthFieldError.emailRequired:
|
||||
return l10n.authEmailRequired;
|
||||
case AuthFieldError.emailInvalid:
|
||||
return l10n.authEmailInvalid;
|
||||
case AuthFieldError.passwordRequired:
|
||||
return l10n.authPasswordRequired;
|
||||
case AuthFieldError.passwordLength:
|
||||
return l10n.authPasswordLength;
|
||||
case AuthFieldError.passwordWeak:
|
||||
return l10n.authPasswordWeak;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final invalid = _logic.validate();
|
||||
if (invalid != null) {
|
||||
setState(() => _logic.errorMessage = _errorText(l10n, invalid));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_logic.busy = true;
|
||||
_logic.errorMessage = null;
|
||||
});
|
||||
final auth = context.read<AuthController>();
|
||||
try {
|
||||
if (_logic.signUpMode) {
|
||||
await auth.register(
|
||||
email: _logic.normalizedEmail,
|
||||
password: _logic.password,
|
||||
);
|
||||
} else {
|
||||
await auth.signIn(
|
||||
email: _logic.normalizedEmail,
|
||||
password: _logic.password,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).maybePop();
|
||||
} on ApiException catch (error) {
|
||||
// 后端文案已经是可直接展示的中文/印尼语提示,错误码不外露
|
||||
setState(() => _logic.errorMessage = error.message);
|
||||
} catch (_) {
|
||||
setState(() => _logic.errorMessage = l10n.authNetworkError);
|
||||
} finally {
|
||||
if (mounted) setState(() => _logic.busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
elevation: 0,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(_logic.signUpMode ? l10n.signUp : l10n.signIn),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
|
||||
children: [
|
||||
Text(
|
||||
_logic.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.authSubtitle,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
_field(
|
||||
label: l10n.authEmailLabel,
|
||||
hint: 'name@example.com',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
onChanged: (value) => _logic.email = value,
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_field(
|
||||
label: l10n.authPasswordLabel,
|
||||
hint: l10n.authPasswordHint,
|
||||
obscure: true,
|
||||
onChanged: (value) => _logic.password = value,
|
||||
),
|
||||
if (_logic.errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Color(0xFFFF6B6B), size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_logic.errorMessage!,
|
||||
style: const TextStyle(color: Color(0xFFFF6B6B), fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
onPressed: _logic.busy ? null : _submit,
|
||||
child: _logic.busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_logic.signUpMode ? l10n.signUp : l10n.signIn,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextButton(
|
||||
onPressed: _logic.busy
|
||||
? null
|
||||
: () => setState(() => _logic.toggleMode()),
|
||||
child: Text(
|
||||
_logic.signUpMode ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp,
|
||||
style: const TextStyle(color: AppColors.blueSoft, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field({
|
||||
required String label,
|
||||
required String hint,
|
||||
required ValueChanged<String> onChanged,
|
||||
bool obscure = false,
|
||||
bool autofocus = false,
|
||||
TextInputType? keyboardType,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
autofocus: autofocus,
|
||||
obscureText: obscure,
|
||||
keyboardType: keyboardType,
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(color: Color(0xFF6C7280), fontSize: 14),
|
||||
filled: true,
|
||||
fillColor: AppColors.card,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import 'discover_logic.dart';
|
||||
class DiscoverBinding {
|
||||
DiscoverLogic create({
|
||||
required Drama drama,
|
||||
WatchHistoryRepository? history,
|
||||
required WatchHistoryRepository history,
|
||||
}) => DiscoverLogic(
|
||||
drama: drama,
|
||||
history: history ?? SharedPreferencesWatchHistoryRepository(),
|
||||
history: history,
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
final FocusNode searchFocus = FocusNode();
|
||||
final ScrollController scroll = ScrollController();
|
||||
int category = 0;
|
||||
bool searchOpen = false;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
@@ -25,11 +26,11 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void refreshPosition() {
|
||||
position = history.positionFor(
|
||||
demoProfileId,
|
||||
drama.id,
|
||||
drama.episodes.first.id,
|
||||
);
|
||||
// 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看"
|
||||
position = history
|
||||
.resumePointFor(drama.id)
|
||||
.then((point) => point?.position)
|
||||
.catchError((Object _) => null);
|
||||
}
|
||||
|
||||
void selectCategory(int index) {
|
||||
@@ -39,6 +40,14 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
|
||||
void onSearchChanged() => notify();
|
||||
|
||||
bool get showSearch => searchOpen || search.text.isNotEmpty;
|
||||
|
||||
void openSearch() {
|
||||
searchOpen = true;
|
||||
notify();
|
||||
searchFocus.requestFocus();
|
||||
}
|
||||
|
||||
void syncDrama({
|
||||
required bool active,
|
||||
required bool wasActive,
|
||||
@@ -54,6 +63,8 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
notify();
|
||||
}
|
||||
if (active && searchRequest != previousSearchRequest) {
|
||||
searchOpen = true;
|
||||
notify();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (isActive()) searchFocus.requestFocus();
|
||||
});
|
||||
@@ -113,8 +124,9 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
void showFilters(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final labels = [
|
||||
l10n.featured,
|
||||
l10n.suspense,
|
||||
l10n.forYou,
|
||||
l10n.hot,
|
||||
l10n.newest,
|
||||
l10n.romance,
|
||||
l10n.actionGenre,
|
||||
];
|
||||
@@ -131,7 +143,7 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
ListTile(
|
||||
title: Text(labels[index]),
|
||||
trailing: index == category
|
||||
? const Icon(Icons.check_rounded, color: Color(0xFFFF5B3D))
|
||||
? const Icon(Icons.check_rounded, color: Color(0xFF2F7BFF))
|
||||
: null,
|
||||
onTap: () {
|
||||
selectCategory(index);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/data/models/home_feed.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'discover_binding.dart';
|
||||
import 'discover_logic.dart';
|
||||
import 'widgets/cover_carousel.dart';
|
||||
|
||||
class DiscoverPage extends StatefulWidget {
|
||||
const DiscoverPage({
|
||||
@@ -18,6 +23,10 @@ class DiscoverPage extends StatefulWidget {
|
||||
this.embedded = false,
|
||||
this.active = true,
|
||||
this.searchRequest = 0,
|
||||
this.signedIn = false,
|
||||
this.onSignIn,
|
||||
this.onCoins,
|
||||
this.onRewards,
|
||||
this.onAction,
|
||||
this.dramas,
|
||||
this.onDramaSelected,
|
||||
@@ -30,6 +39,10 @@ class DiscoverPage extends StatefulWidget {
|
||||
final bool embedded;
|
||||
final bool active;
|
||||
final int searchRequest;
|
||||
final bool signedIn;
|
||||
final VoidCallback? onSignIn;
|
||||
final VoidCallback? onCoins;
|
||||
final VoidCallback? onRewards;
|
||||
final ValueChanged<DiscoverPageAction>? onAction;
|
||||
final List<Drama>? dramas;
|
||||
final ValueChanged<Drama>? onDramaSelected;
|
||||
@@ -46,8 +59,9 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
super.initState();
|
||||
logic = DiscoverBinding().create(
|
||||
drama: widget.drama,
|
||||
history: widget.history,
|
||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||
);
|
||||
if (widget.focusSearch) logic.searchOpen = true;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -80,50 +94,64 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cards = _cardsFor(l10n, logic.category, logic.search.text);
|
||||
final catalog = context.watch<CatalogController>();
|
||||
final allDramas = widget.dramas ?? [logic.drama];
|
||||
final cards = _cardsFor(
|
||||
allDramas,
|
||||
catalog.sections,
|
||||
logic.category,
|
||||
'',
|
||||
);
|
||||
final content = SafeArea(
|
||||
bottom: !widget.embedded,
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(l10n),
|
||||
_header(l10n),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: logic.scroll,
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
_searchBar(l10n),
|
||||
const SizedBox(height: 12),
|
||||
_categories(l10n),
|
||||
const SizedBox(height: 15),
|
||||
_continueCard(l10n),
|
||||
const SizedBox(height: 15),
|
||||
_quickActions(l10n),
|
||||
const SizedBox(height: 20),
|
||||
_sectionTitle(l10n),
|
||||
const SizedBox(height: 13),
|
||||
if (cards.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
l10n.noSearchResults,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
)
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: cards.length,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 14,
|
||||
childAspectRatio: 0.59,
|
||||
),
|
||||
itemBuilder: (context, index) =>
|
||||
_posterCard(l10n, cards[index]),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: _carousel(l10n, catalog.banners, allDramas),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_categories(l10n),
|
||||
const SizedBox(height: 18),
|
||||
_continueHeader(l10n),
|
||||
const SizedBox(height: 12),
|
||||
if (cards.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
l10n.noSearchResults,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 196,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final card in cards) ...[
|
||||
_continueTile(l10n, card),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -142,412 +170,233 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar(AppLocalizations l10n) => SizedBox(
|
||||
height: 51,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 29,
|
||||
height: 29,
|
||||
decoration: BoxDecoration(
|
||||
color: _orange,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
l10n.discoverTab,
|
||||
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: l10n.search,
|
||||
onPressed: () => logic.searchFocus.requestFocus(),
|
||||
icon: const Icon(Icons.search_rounded, size: 22),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: () => logic.act(
|
||||
context,
|
||||
DiscoverPageAction.my,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
child: const CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: Color(0xFF34353B),
|
||||
child: Icon(Icons.person_rounded, size: 19),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _searchBar(AppLocalizations l10n) => Container(
|
||||
height: 43,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF25252A),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 11),
|
||||
const Icon(Icons.search_rounded, color: Color(0xFFF2EEEF), size: 20),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: logic.search,
|
||||
focusNode: logic.searchFocus,
|
||||
autofocus: widget.focusSearch,
|
||||
onChanged: (_) => logic.onSearchChanged(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchDiscover,
|
||||
hintStyle: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 12,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.filter,
|
||||
onPressed: () => logic.showFilters(context),
|
||||
icon: const Icon(Icons.tune_rounded, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _categories(AppLocalizations l10n) {
|
||||
final labels = [
|
||||
l10n.featured,
|
||||
l10n.suspense,
|
||||
l10n.romance,
|
||||
l10n.actionGenre,
|
||||
];
|
||||
return SizedBox(
|
||||
height: 30,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: labels.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 7),
|
||||
itemBuilder: (context, index) => ChoiceChip(
|
||||
label: Text(labels[index]),
|
||||
selected: logic.category == index,
|
||||
onSelected: (_) => logic.selectCategory(index),
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
backgroundColor: const Color(0xFF29272B),
|
||||
selectedColor: _orange,
|
||||
side: BorderSide.none,
|
||||
labelStyle: TextStyle(
|
||||
color: logic.category == index ? Colors.white : const Color(0xFFDDD5D7),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _continueCard(AppLocalizations l10n) => FutureBuilder<Duration?>(
|
||||
future: logic.position,
|
||||
builder: (context, snapshot) {
|
||||
final position = snapshot.data ?? Duration.zero;
|
||||
final total = widget.duration > Duration.zero
|
||||
? widget.duration
|
||||
: const Duration(seconds: 12);
|
||||
final progress = (position.inMilliseconds / total.inMilliseconds).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1C1D21),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: SizedBox(
|
||||
width: 55,
|
||||
height: 69,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(widget.drama.posterAsset, fit: BoxFit.cover),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 12,
|
||||
child: ColoredBox(color: Color(0xFF1C1D21)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.continueWatchingHeading,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFA490),
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
localizedDramaTitle(l10n, widget.drama),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.episodeProgress(1, widget.drama.episodes.length),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 3,
|
||||
backgroundColor: const Color(0xFF474248),
|
||||
color: _orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
FilledButton.icon(
|
||||
onPressed: () => logic.act(
|
||||
context,
|
||||
DiscoverPageAction.resume,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFFBD5C),
|
||||
foregroundColor: const Color(0xFF2A160B),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
minimumSize: const Size(0, 32),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 14),
|
||||
label: Text(l10n.resume),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _quickActions(AppLocalizations l10n) {
|
||||
final actions = [
|
||||
(Icons.tune_rounded, l10n.filter, () => logic.showFilters(context)),
|
||||
(Icons.bar_chart_rounded, l10n.topCharts, () => logic.catalogInfo(context)),
|
||||
(
|
||||
Icons.auto_awesome_rounded,
|
||||
l10n.newReleases,
|
||||
() => logic.catalogInfo(context),
|
||||
),
|
||||
(
|
||||
Icons.bookmark_outline_rounded,
|
||||
l10n.watchlist,
|
||||
() {
|
||||
logic.act(
|
||||
context,
|
||||
DiscoverPageAction.watchlist,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
return Row(
|
||||
children: [
|
||||
for (var index = 0; index < actions.length; index++) ...[
|
||||
const _BrandMark(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: actions[index].$3,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 77,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF202126),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: const Color(0xFF333036),
|
||||
child: Icon(
|
||||
actions[index].$1,
|
||||
size: 19,
|
||||
color: index == 1
|
||||
? const Color(0xFFFFC15E)
|
||||
: const Color(0xFFFFAB96),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
actions[index].$2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
l10n.appName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (index < actions.length - 1) const SizedBox(width: 7),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.signedIn) ...[
|
||||
_HeaderPill(
|
||||
icon: Icons.monetization_on_rounded,
|
||||
label: r'$ 0',
|
||||
onTap: widget.onCoins,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_HeaderPill(
|
||||
icon: Icons.card_giftcard_rounded,
|
||||
label: l10n.rewards,
|
||||
onTap: widget.onRewards,
|
||||
),
|
||||
] else
|
||||
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _carousel(
|
||||
AppLocalizations l10n,
|
||||
List<FeedBanner> banners,
|
||||
List<Drama> allDramas,
|
||||
) {
|
||||
final items = banners.where((banner) => banner.imageUrl.isNotEmpty).toList();
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return CoverCarousel(
|
||||
items: [
|
||||
for (final banner in items)
|
||||
CoverItem(
|
||||
title: _bannerTitle(banner, allDramas),
|
||||
subtitle: '',
|
||||
image: banner.imageUrl,
|
||||
onOpen: () => _openBanner(banner, allDramas),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(AppLocalizations l10n) => Row(
|
||||
String _bannerTitle(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return '';
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) return drama.title;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
void _openBanner(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return;
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) {
|
||||
logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _continueHeader(AppLocalizations l10n) => Row(
|
||||
children: [
|
||||
Container(width: 3, height: 20, color: _orange),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.trendingMicroDramas,
|
||||
l10n.continueWatching,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => logic.selectCategory(0),
|
||||
child: Text(
|
||||
l10n.seeAll,
|
||||
style: const TextStyle(color: Color(0xFFFFB3A1)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.seeAll,
|
||||
style: const TextStyle(
|
||||
color: AppColors.blue,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) {
|
||||
final playable =
|
||||
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]) != null;
|
||||
return InkWell(
|
||||
onTap: () => logic.openCard(
|
||||
context,
|
||||
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]),
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(card.image, fit: BoxFit.cover),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 37,
|
||||
child: ColoredBox(color: Color(0xFF101116)),
|
||||
Widget _continueTile(AppLocalizations l10n, _ShowcaseCard card) {
|
||||
final drama = logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]);
|
||||
return SizedBox(
|
||||
width: 132,
|
||||
child: InkWell(
|
||||
onTap: () => logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(card.image),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0xE6000000)],
|
||||
stops: [0.45, 1],
|
||||
),
|
||||
Positioned(
|
||||
top: 6,
|
||||
left: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: playable ? _orange : const Color(0xDD37343B),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Text(
|
||||
playable ? l10n.free : l10n.artworkOnly,
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (drama == null)
|
||||
const ColoredBox(
|
||||
color: Color(0x99000000),
|
||||
child: Center(
|
||||
child: Icon(Icons.image_outlined, color: Colors.white),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: Text(
|
||||
playable ? l10n.freeTestMedia : l10n.videoPending,
|
||||
),
|
||||
Positioned(
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 10,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
card.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFC789),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
drama == null ? l10n.videoPending : card.genre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xE6FFFFFF),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _categories(AppLocalizations l10n) {
|
||||
final labels = [
|
||||
l10n.forYou,
|
||||
l10n.hot,
|
||||
l10n.newest,
|
||||
l10n.romance,
|
||||
l10n.actionGenre,
|
||||
];
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: labels.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final selected = logic.category == index;
|
||||
return GestureDetector(
|
||||
onTap: () => logic.selectCategory(index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.blue : const Color(0xFF1C1E24),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
labels[index],
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : AppColors.muted,
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
card.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
card.genre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -637,60 +486,131 @@ class _ShowcaseCard {
|
||||
required this.title,
|
||||
required this.genre,
|
||||
required this.image,
|
||||
required this.category,
|
||||
this.dramaId,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String genre;
|
||||
final String image;
|
||||
final int category;
|
||||
final String? dramaId;
|
||||
}
|
||||
|
||||
/// 分区由服务端下发:0 为全部,其余按分区顺序对应筛选菜单(热门 / 最新 / 各分类)。
|
||||
List<_ShowcaseCard> _cardsFor(
|
||||
AppLocalizations l10n,
|
||||
List<Drama> dramas,
|
||||
List<FeedSection> sections,
|
||||
int category,
|
||||
String query,
|
||||
) {
|
||||
final cards = [
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaSignalTitle,
|
||||
genre: l10n.genreSuspenseMystery,
|
||||
image: 'assets/images/stitch_signal_poster_clean.png',
|
||||
category: 1,
|
||||
dramaId: 'the-signal-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaCityTitle,
|
||||
genre: l10n.genreUrbanRomance,
|
||||
image: 'assets/images/stitch_city_poster_clean.png',
|
||||
category: 2,
|
||||
dramaId: 'city-lights-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaVengeanceTitle,
|
||||
genre: l10n.genreActionThriller,
|
||||
image: 'assets/images/stitch_vengeance_poster_clean.png',
|
||||
category: 3,
|
||||
dramaId: 'vengeance-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaEchoesTitle,
|
||||
genre: l10n.genreDramaMystery,
|
||||
image: 'assets/images/stitch_rain_scene.jpg',
|
||||
category: 1,
|
||||
),
|
||||
];
|
||||
final source = switch (category) {
|
||||
0 => dramas,
|
||||
_ => category < sections.length ? sections[category].dramas : const <Drama>[],
|
||||
};
|
||||
final normalized = query.trim().toLowerCase();
|
||||
return cards.where((card) {
|
||||
final categoryMatches = category == 0 || card.category == category;
|
||||
final queryMatches =
|
||||
normalized.isEmpty ||
|
||||
card.title.toLowerCase().contains(normalized) ||
|
||||
card.genre.toLowerCase().contains(normalized);
|
||||
return categoryMatches && queryMatches;
|
||||
}).toList();
|
||||
return [
|
||||
for (final drama in source)
|
||||
if (normalized.isEmpty ||
|
||||
drama.title.toLowerCase().contains(normalized) ||
|
||||
drama.genre.toLowerCase().contains(normalized) ||
|
||||
drama.category.toLowerCase().contains(normalized))
|
||||
_ShowcaseCard(
|
||||
title: drama.title,
|
||||
genre: drama.genre.isEmpty ? drama.category : drama.genre,
|
||||
image: drama.posterUrl,
|
||||
dramaId: drama.id,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const _orange = Color(0xFFFF5B3D);
|
||||
const _orange = AppColors.blue;
|
||||
const _coin = Color(0xFFF5C451);
|
||||
|
||||
class _BrandMark extends StatelessWidget {
|
||||
const _BrandMark();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderPill extends StatelessWidget {
|
||||
const _HeaderPill({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color(0xFF1C1E24),
|
||||
shape: const StadiumBorder(side: BorderSide(color: Color(0x66F5C451))),
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 15, color: _coin),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginButton extends StatelessWidget {
|
||||
const _LoginButton({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
onPressed: onTap,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: AppColors.blue,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
child: Text(label, maxLines: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
|
||||
class CoverItem {
|
||||
const CoverItem({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.image,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String image;
|
||||
final VoidCallback onOpen;
|
||||
}
|
||||
|
||||
/// Full-bleed posters that turn as faces of a cube. Titles sit on the image.
|
||||
class CoverCarousel extends StatefulWidget {
|
||||
const CoverCarousel({super.key, required this.items});
|
||||
|
||||
final List<CoverItem> items;
|
||||
|
||||
@override
|
||||
State<CoverCarousel> createState() => _CoverCarouselState();
|
||||
}
|
||||
|
||||
class _CoverCarouselState extends State<CoverCarousel>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _snap;
|
||||
double _page = 0;
|
||||
double _from = 0;
|
||||
double _to = 0;
|
||||
int? _pointer;
|
||||
double _drag = 0;
|
||||
double _dragPage = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_snap = AnimationController(vsync: this)..addListener(_onSnap);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_snap.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get _last => math.max(0, widget.items.length - 1).toDouble();
|
||||
|
||||
void _onSnap() {
|
||||
final t = Curves.easeOutCubic.transform(_snap.value);
|
||||
setState(() => _page = _from + (_to - _from) * t);
|
||||
}
|
||||
|
||||
void _turnTo(double target) {
|
||||
_from = _page;
|
||||
_to = target.clamp(0, _last);
|
||||
_snap.duration = const Duration(milliseconds: 420);
|
||||
_snap.forward(from: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final height = math.min(width * 0.52, 210).toDouble();
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: height,
|
||||
child: _stage(width),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stage(double width) {
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (event) {
|
||||
_snap.stop();
|
||||
_pointer = event.pointer;
|
||||
_drag = 0;
|
||||
_dragPage = _page;
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
if (event.pointer != _pointer) return;
|
||||
_drag += event.delta.dx;
|
||||
setState(() {
|
||||
_page = (_dragPage - _drag / width).clamp(0.0, _last);
|
||||
});
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
if (event.pointer != _pointer) return;
|
||||
_pointer = null;
|
||||
_turnTo(_page.roundToDouble());
|
||||
},
|
||||
onPointerCancel: (event) {
|
||||
if (event.pointer != _pointer) return;
|
||||
_pointer = null;
|
||||
_turnTo(_page.roundToDouble());
|
||||
},
|
||||
child: ClipRect(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [for (final index in _visible) _face(index, width)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<int> get _visible {
|
||||
final first = _page.floor();
|
||||
final indexes = <int>[first, first + 1];
|
||||
indexes.retainWhere((index) => index >= 0 && index < widget.items.length);
|
||||
indexes.sort((a, b) => (b - _page).abs().compareTo((a - _page).abs()));
|
||||
return indexes;
|
||||
}
|
||||
|
||||
Widget _face(int index, double width) {
|
||||
final delta = index - _page;
|
||||
if (delta <= -1 || delta >= 1) return const SizedBox.shrink();
|
||||
final item = widget.items[index];
|
||||
final angle = delta * math.pi / 2;
|
||||
// Push the face out to the cube surface, spin the whole cube on its
|
||||
// vertical center, then bring that center back to the banner.
|
||||
final face = Matrix4.identity()
|
||||
..translateByDouble(0, 0, -width / 2, 1)
|
||||
..rotateY(angle)
|
||||
..translateByDouble(0, 0, width / 2, 1);
|
||||
final transform = Matrix4.identity()
|
||||
..setEntry(3, 2, 0.0018)
|
||||
..multiply(face);
|
||||
return Positioned.fill(
|
||||
child: Transform(
|
||||
alignment: Alignment.center,
|
||||
filterQuality: FilterQuality.medium,
|
||||
transform: transform,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if ((_page - index).abs() < 0.08) item.onOpen();
|
||||
},
|
||||
child: _poster(item),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _poster(CoverItem item) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(item.image),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0x00000000), Color(0xE6000000)],
|
||||
stops: [0, 0.5, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.15,
|
||||
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xF2FFFFFF),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
|
||||
class DramaDetailBinding {
|
||||
DramaDetailLogic create({
|
||||
required Drama drama,
|
||||
WatchHistoryRepository? history,
|
||||
required WatchHistoryRepository history,
|
||||
required PlaybackRepository playback,
|
||||
required FavoriteController favorites,
|
||||
}) => DramaDetailLogic(
|
||||
drama: drama,
|
||||
history: history ?? SharedPreferencesWatchHistoryRepository(),
|
||||
history: history,
|
||||
playback: playback,
|
||||
favorites: favorites,
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,104 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../player/player_view.dart';
|
||||
|
||||
class DramaDetailLogic extends ChangeNotifier {
|
||||
DramaDetailLogic({required this.drama, required this.history});
|
||||
DramaDetailLogic({
|
||||
required this.drama,
|
||||
required this.history,
|
||||
required this.playback,
|
||||
required this.favorites,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
|
||||
Drama drama;
|
||||
final WatchHistoryRepository history;
|
||||
final PlaybackRepository playback;
|
||||
final FavoriteController favorites;
|
||||
bool loading = false;
|
||||
bool failed = false;
|
||||
|
||||
/// 接口给出的失败原因;为空时界面回退到本地文案。
|
||||
String? failureMessage;
|
||||
int? continueIndex;
|
||||
WatchProgress? progress;
|
||||
String? progressError;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
/// 列表卡片只带一集可播预览,进详情时补一次完整剧集列表。
|
||||
Future<void> load(Future<Drama> Function() fetch) async {
|
||||
if (drama.detailLoaded) {
|
||||
await loadContinue();
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
failed = false;
|
||||
failureMessage = null;
|
||||
notify();
|
||||
try {
|
||||
drama = await fetch();
|
||||
await loadContinue();
|
||||
} on ApiException catch (exception) {
|
||||
failed = true;
|
||||
failureMessage = exception.message;
|
||||
} on DioException {
|
||||
// 断网/超时:交给 l10n 文案,不把 HTTP 细节丢给用户
|
||||
failed = true;
|
||||
failureMessage = 'networkError';
|
||||
} catch (_) {
|
||||
failed = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadContinue() async {
|
||||
try {
|
||||
final id = await history.lastEpisodeId(demoProfileId, drama.id);
|
||||
if (id == null || _disposed) return;
|
||||
final index = drama.episodes.indexWhere((episode) => episode.id == id);
|
||||
if (index >= 0 && drama.episodes[index].isFree) {
|
||||
continueIndex = index;
|
||||
notify();
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep the first episode available if preferences cannot be read.
|
||||
final record = await history.progressFor(drama.id);
|
||||
if (_disposed) return;
|
||||
progress = record;
|
||||
progressError = null;
|
||||
continueIndex = record == null
|
||||
? null
|
||||
: _indexOf(record.episodeId) ?? continueIndex;
|
||||
notify();
|
||||
} catch (exception) {
|
||||
if (_disposed) return;
|
||||
// 拿不到续看位置不影响看第一集,但要让用户知道(可重试)
|
||||
progressError = exception is ApiException
|
||||
? exception.message
|
||||
: 'progressError';
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
int? _indexOf(String episodeId) {
|
||||
final index = drama.episodes.indexWhere(
|
||||
(episode) => episode.id == episodeId,
|
||||
);
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
|
||||
/// 该集的续看进度(0~1),用于剧集行上的进度条;没有记录返回 null。
|
||||
double? progressFractionFor(String episodeId) {
|
||||
final record = progress;
|
||||
if (record == null || record.episodeId != episodeId) return null;
|
||||
if (!record.hasPosition) return null;
|
||||
return record.fraction;
|
||||
}
|
||||
|
||||
Future<void> openPlayer(BuildContext context, int index) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
@@ -39,6 +108,9 @@ class DramaDetailLogic extends ChangeNotifier {
|
||||
await loadContinue();
|
||||
}
|
||||
|
||||
/// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。
|
||||
Future<bool> toggleFavorite() => favorites.toggle(drama.id, card: drama);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'drama_detail_binding.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
|
||||
class DramaDetailPage extends StatefulWidget {
|
||||
const DramaDetailPage({super.key, required this.drama});
|
||||
@@ -20,8 +30,21 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
logic = DramaDetailBinding().create(drama: widget.drama);
|
||||
logic.loadContinue();
|
||||
logic = DramaDetailBinding().create(
|
||||
drama: widget.drama,
|
||||
history: context.read<WatchHistoryRepository>(),
|
||||
playback: context.read<PlaybackRepository>(),
|
||||
favorites: context.read<FavoriteController>(),
|
||||
);
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
unawaited(
|
||||
logic.load(
|
||||
() => context.read<CatalogController>().requireDetail(widget.drama),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -40,7 +63,69 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (logic.loading) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Color(0xFF111111),
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (logic.failed) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
errorText(l10n, logic.failureMessage, l10n.detailError),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _load, child: Text(l10n.retry)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final drama = logic.drama;
|
||||
if (drama.episodes.isEmpty) {
|
||||
// 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(localizedDramaTitle(l10n, drama)),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.movie_filter_outlined,
|
||||
size: 48,
|
||||
color: Colors.white38,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.noEpisodesYet,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _load, child: Text(l10n.retry)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -49,8 +134,9 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
pinned: true,
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
foregroundColor: Colors.white,
|
||||
actions: [_favoriteAction(context, l10n)],
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Image.asset(drama.posterAsset, fit: BoxFit.cover),
|
||||
background: RemoteImage(drama.posterUrl),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
@@ -61,7 +147,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
l10n.technicalDemoCount(drama.episodes.length),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
letterSpacing: 1.4,
|
||||
color: const Color(0xFF9278EA),
|
||||
color: const Color(0xFF2F7BFF),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -80,15 +166,54 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
logic.openPlayer(context, logic.continueIndex ?? 0),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(
|
||||
logic.continueIndex == null
|
||||
? l10n.watchEpisodeOne
|
||||
: l10n.continueEpisode(logic.continueIndex! + 1),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
logic.openPlayer(context, logic.continueIndex ?? 0),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(
|
||||
logic.continueIndex == null
|
||||
? l10n.watchEpisodeOne
|
||||
: l10n.continueEpisode(logic.continueIndex! + 1),
|
||||
),
|
||||
),
|
||||
if (_continueFraction != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_ResumeBar(
|
||||
fraction: _continueFraction!,
|
||||
label: l10n.resumeProgress(
|
||||
_clock(logic.progress!.position),
|
||||
_clock(logic.progress!.duration),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (logic.progressError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorText(
|
||||
l10n,
|
||||
logic.progressError,
|
||||
l10n.progressError,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(logic.loadContinue()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Text(
|
||||
@@ -106,18 +231,24 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
itemCount: drama.episodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = drama.episodes[index];
|
||||
final fraction = logic.progressFractionFor(episode.id);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: ListTile(
|
||||
minTileHeight: 76,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: const Color(
|
||||
0xFFFF6B45,
|
||||
0xFF2F7BFF,
|
||||
).withValues(alpha: 0.18),
|
||||
child: Text('${episode.number}'),
|
||||
),
|
||||
title: Text(l10n.episodeLabel(episode.number)),
|
||||
subtitle: Text(l10n.freeTestMedia),
|
||||
subtitle: fraction == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: _ResumeBar(fraction: fraction),
|
||||
),
|
||||
trailing: const Icon(Icons.play_circle_outline_rounded),
|
||||
onTap: () => logic.openPlayer(context, index),
|
||||
),
|
||||
@@ -129,4 +260,71 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double? get _continueFraction {
|
||||
final record = logic.progress;
|
||||
if (record == null || !record.hasPosition) return null;
|
||||
if (logic.continueIndex == null) return null;
|
||||
return record.fraction;
|
||||
}
|
||||
|
||||
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
|
||||
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
final saved = favorites.isSaved(logic.drama.id);
|
||||
return IconButton(
|
||||
tooltip: l10n.favorite,
|
||||
icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border),
|
||||
onPressed: () async {
|
||||
if (!await ensureSignedIn(context)) return;
|
||||
final ok = await logic.toggleFavorite();
|
||||
if (ok || !context.mounted) return;
|
||||
// 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚)
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _clock(Duration duration) {
|
||||
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return duration.inHours > 0
|
||||
? '${duration.inHours}:$minutes:$seconds'
|
||||
: '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
|
||||
/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。
|
||||
class _ResumeBar extends StatelessWidget {
|
||||
const _ResumeBar({required this.fraction, this.label});
|
||||
|
||||
final double fraction;
|
||||
final String? label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: fraction,
|
||||
minHeight: 3,
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor: const AlwaysStoppedAnimation(Color(0xFF2F7BFF)),
|
||||
),
|
||||
),
|
||||
if (label != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label!,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import '../../core/data/models/drama.dart';
|
||||
|
||||
enum HomeFeedCategory { following, animated, liveAction, recommended }
|
||||
|
||||
/// Maps catalog entries into home feeds without owning playback or UI state.
|
||||
class HomeFeedSections {
|
||||
HomeFeedSections(this.dramas, {this.followedDramaIds = const {}});
|
||||
|
||||
final List<Drama> dramas;
|
||||
final Set<String> followedDramaIds;
|
||||
|
||||
List<int> indicesFor(HomeFeedCategory category) => [
|
||||
for (var index = 0; index < dramas.length; index++)
|
||||
if (dramas[index].episodes.isNotEmpty &&
|
||||
switch (category) {
|
||||
HomeFeedCategory.following => followedDramaIds.contains(
|
||||
dramas[index].id,
|
||||
),
|
||||
HomeFeedCategory.animated =>
|
||||
dramas[index].format == DramaFormat.animated,
|
||||
HomeFeedCategory.liveAction =>
|
||||
dramas[index].format == DramaFormat.liveAction,
|
||||
HomeFeedCategory.recommended => true,
|
||||
})
|
||||
index,
|
||||
];
|
||||
|
||||
Drama dramaAt(HomeFeedCategory category, int categoryIndex) =>
|
||||
dramas[indicesFor(category)[categoryIndex]];
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../discover/discover_logic.dart';
|
||||
import '../drama_detail/drama_detail_view.dart';
|
||||
import '../mine/mine_logic.dart';
|
||||
import '../player/player_view.dart';
|
||||
|
||||
class HomeLogic extends ChangeNotifier {
|
||||
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
|
||||
|
||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
Drama currentDrama;
|
||||
int tab = 0;
|
||||
int tab = 1;
|
||||
int searchRequest = 0;
|
||||
int sectionRequest = 0;
|
||||
int resumeRequest = 0;
|
||||
@@ -19,6 +22,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
bool drawerOpen = false;
|
||||
bool detailOpen = false;
|
||||
bool videoImmersive = false;
|
||||
bool profile = false;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
@@ -62,9 +66,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => DramaDetailPage(drama: currentDrama),
|
||||
),
|
||||
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: currentDrama)),
|
||||
);
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
@@ -80,6 +82,34 @@ class HomeLogic extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> playDrama(BuildContext context, Drama drama) async {
|
||||
// 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。
|
||||
var playable = drama;
|
||||
try {
|
||||
playable = await context.read<CatalogController>().requireDetail(drama);
|
||||
} catch (_) {
|
||||
// 取详情失败就用卡片自带的那一集起播。
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlayerPage(drama: playable, initialIndex: 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void openProfile() {
|
||||
profile = true;
|
||||
scaffoldKey.currentState?.closeDrawer();
|
||||
select(2, mySection: 0);
|
||||
}
|
||||
|
||||
void openLibrary({int section = 1}) {
|
||||
profile = false;
|
||||
scaffoldKey.currentState?.closeDrawer();
|
||||
select(2, mySection: section);
|
||||
}
|
||||
|
||||
void discoverAction(BuildContext context, DiscoverPageAction action) {
|
||||
switch (action) {
|
||||
case DiscoverPageAction.home:
|
||||
@@ -87,7 +117,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
case DiscoverPageAction.my:
|
||||
select(2);
|
||||
case DiscoverPageAction.watchlist:
|
||||
select(2, watchlist: true);
|
||||
openLibrary();
|
||||
case DiscoverPageAction.resume:
|
||||
select(0, resume: true);
|
||||
case DiscoverPageAction.detail:
|
||||
@@ -112,11 +142,10 @@ class HomeLogic extends ChangeNotifier {
|
||||
} else if (videoImmersive) {
|
||||
backRequest++;
|
||||
notify();
|
||||
} else if (tab != 0) {
|
||||
select(0);
|
||||
} else if (tab != 1) {
|
||||
select(1);
|
||||
} else {
|
||||
backRequest++;
|
||||
notify();
|
||||
openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../discover/discover_view.dart';
|
||||
import '../mine/mine_view.dart';
|
||||
@@ -29,7 +34,6 @@ class HomePage extends StatelessWidget {
|
||||
CatalogController catalog,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
|
||||
if (catalog.loading) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
@@ -46,6 +50,13 @@ class HomePage extends StatelessWidget {
|
||||
Text(catalog.error == null ? l10n.noStories : l10n.catalogError),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: catalog.load, child: Text(l10n.retry)),
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppConfig.apiBaseUrl,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white54),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -92,8 +103,11 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _scaffold(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
// 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关)
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
@@ -107,8 +121,8 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
drama: logic.currentDrama,
|
||||
onOpenSeries: () => logic.openCurrentSeries(context),
|
||||
onDiscover: () => logic.selectFromMenu(1),
|
||||
onHistory: () => logic.selectFromMenu(2, mySection: 0),
|
||||
onWatchlist: () => logic.selectFromMenu(2, mySection: 1),
|
||||
onHistory: () => logic.openLibrary(section: 0),
|
||||
onWatchlist: logic.openLibrary,
|
||||
onLiked: () => logic.selectFromMenu(2, mySection: 2),
|
||||
),
|
||||
drawerEnableOpenDragGesture: false,
|
||||
@@ -127,7 +141,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
resumeRequest: logic.resumeRequest,
|
||||
backRequest: logic.backRequest,
|
||||
onDiscover: () => logic.select(1),
|
||||
onMy: () => logic.select(2),
|
||||
onMy: logic.openProfile,
|
||||
onSearch: () => logic.select(1, search: true),
|
||||
onMenu: logic.openMenu,
|
||||
onImmersiveChanged: logic.onImmersiveChanged,
|
||||
@@ -135,11 +149,15 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
DiscoverPage(
|
||||
drama: logic.currentDrama,
|
||||
dramas: widget.dramas,
|
||||
onDramaSelected: (drama) => logic.openDrama(context, drama),
|
||||
onDramaSelected: (drama) => logic.playDrama(context, drama),
|
||||
duration: Duration.zero,
|
||||
embedded: true,
|
||||
active: logic.tab == 1,
|
||||
searchRequest: logic.searchRequest,
|
||||
signedIn: signedIn,
|
||||
onSignIn: () => openAuthPage(context),
|
||||
onCoins: logic.openProfile,
|
||||
onRewards: logic.openProfile,
|
||||
onAction: (action) => logic.discoverAction(context, action),
|
||||
),
|
||||
MyPage(
|
||||
@@ -148,10 +166,15 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
onDramaSelected: (drama) => logic.openDrama(context, drama),
|
||||
duration: Duration.zero,
|
||||
initialSection: logic.mySection,
|
||||
profile: logic.profile,
|
||||
sectionRequest: logic.sectionRequest,
|
||||
embedded: true,
|
||||
active: logic.tab == 2,
|
||||
onAction: logic.myAction,
|
||||
onOpenHistory: () => logic.openLibrary(section: 0),
|
||||
signedIn: signedIn,
|
||||
onSignIn: () => openAuthPage(context),
|
||||
onSignOut: () => context.read<AuthController>().signOut(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -167,28 +190,38 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
border: Border(top: BorderSide(color: Color(0xFF242329))),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_TabButton(
|
||||
key: const Key('tab-home'),
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
label: l10n.homeTab,
|
||||
selected: logic.tab == 0,
|
||||
onTap: () => logic.select(0),
|
||||
),
|
||||
_TabButton(
|
||||
key: const Key('tab-discover'),
|
||||
icon: Icons.explore_outlined,
|
||||
label: l10n.discoverTab,
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home_rounded,
|
||||
label: l10n.homeTab,
|
||||
selected: logic.tab == 1,
|
||||
onTap: () => logic.select(1),
|
||||
),
|
||||
_TabButton(
|
||||
key: const Key('tab-home'),
|
||||
icon: Icons.movie_outlined,
|
||||
selectedIcon: Icons.movie_rounded,
|
||||
label: l10n.shortsTab,
|
||||
selected: logic.tab == 0,
|
||||
onTap: () => logic.select(0),
|
||||
),
|
||||
_TabButton(
|
||||
key: const Key('tab-my-list'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
selectedIcon: Icons.bookmark_rounded,
|
||||
label: l10n.myListTab,
|
||||
selected: logic.tab == 2 && !logic.profile,
|
||||
onTap: logic.openLibrary,
|
||||
),
|
||||
_TabButton(
|
||||
key: const Key('tab-my'),
|
||||
icon: Icons.video_library_outlined,
|
||||
icon: Icons.person_outline_rounded,
|
||||
selectedIcon: Icons.person_rounded,
|
||||
label: l10n.myTab,
|
||||
selected: logic.tab == 2,
|
||||
onTap: () => logic.select(2),
|
||||
selected: logic.tab == 2 && logic.profile,
|
||||
onTap: logic.openProfile,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -203,35 +236,47 @@ class _TabButton extends StatelessWidget {
|
||||
const _TabButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.selectedIcon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
final IconData icon;
|
||||
final IconData selectedIcon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
Widget build(BuildContext context) => Expanded(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 21,
|
||||
color: selected ? const Color(0xFFFF5C37) : const Color(0xFFE6E1DE),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 3),
|
||||
decoration: selected
|
||||
? BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
)
|
||||
: null,
|
||||
child: Icon(
|
||||
selected ? selectedIcon : icon,
|
||||
size: 20,
|
||||
color: selected ? Colors.white : AppColors.muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: selected
|
||||
? const Color(0xFFFF5C37)
|
||||
: const Color(0xFFE6E1DE),
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected ? AppColors.blue : AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Converts horizontal drags into a previous/next category request.
|
||||
/// Placement decides where the gesture is active; the seek bar sits outside it.
|
||||
class CategorySwipeRegion extends StatefulWidget {
|
||||
const CategorySwipeRegion({
|
||||
super.key,
|
||||
required this.onShift,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final ValueChanged<int> onShift;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<CategorySwipeRegion> createState() => _CategorySwipeRegionState();
|
||||
}
|
||||
|
||||
class _CategorySwipeRegionState extends State<CategorySwipeRegion> {
|
||||
double _distance = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragStart: (_) => _distance = 0,
|
||||
onHorizontalDragUpdate: (details) => _distance += details.delta.dx,
|
||||
onHorizontalDragEnd: (details) {
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
if (_distance.abs() < 44 && velocity.abs() < 300) return;
|
||||
final direction = _distance.abs() >= 44 ? _distance : velocity;
|
||||
widget.onShift(direction < 0 ? 1 : -1);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../home_feed_sections.dart';
|
||||
import 'category_swipe_region.dart';
|
||||
|
||||
/// Reused by playable and empty home feeds.
|
||||
class HomeFeedHeader extends StatelessWidget {
|
||||
const HomeFeedHeader({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.category,
|
||||
required this.onCategorySelected,
|
||||
required this.onCategoryShift,
|
||||
required this.onSearch,
|
||||
required this.onMy,
|
||||
this.onMenu,
|
||||
this.onMute,
|
||||
this.muted = false,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final HomeFeedCategory category;
|
||||
final ValueChanged<HomeFeedCategory> onCategorySelected;
|
||||
final ValueChanged<int> onCategoryShift;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onMy;
|
||||
final VoidCallback? onMenu;
|
||||
final VoidCallback? onMute;
|
||||
final bool muted;
|
||||
|
||||
String _label(HomeFeedCategory category) => switch (category) {
|
||||
HomeFeedCategory.following => l10n.followingTab,
|
||||
HomeFeedCategory.animated => l10n.animatedTab,
|
||||
HomeFeedCategory.liveAction => l10n.liveActionTab,
|
||||
HomeFeedCategory.recommended => l10n.recommendedTab,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IconTheme(
|
||||
data: const IconThemeData(color: Colors.white),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: const TextStyle(color: Colors.white),
|
||||
child: _header(context),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _header(BuildContext context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (onMenu != null)
|
||||
IconButton(
|
||||
key: const Key('home-open-menu'),
|
||||
tooltip: l10n.openQuickMenu,
|
||||
onPressed: onMenu,
|
||||
icon: const Icon(Icons.menu_rounded),
|
||||
),
|
||||
Container(
|
||||
width: 29,
|
||||
height: 29,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF5C37),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.homeTab,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: l10n.search,
|
||||
onPressed: onSearch,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.myTab,
|
||||
onPressed: onMy,
|
||||
icon: const CircleAvatar(
|
||||
radius: 15,
|
||||
backgroundColor: Color(0xFF3C3A3A),
|
||||
child: Icon(
|
||||
Icons.person_rounded,
|
||||
color: Colors.white,
|
||||
size: 19,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CategorySwipeRegion(
|
||||
key: const Key('home-category-swipe'),
|
||||
onShift: onCategoryShift,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final item in HomeFeedCategory.values)
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
key: Key('home-category-${item.name}'),
|
||||
onTap: () => onCategorySelected(item),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 9),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_label(item),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: category == item
|
||||
? const Color(0xFFFFC2B0)
|
||||
: const Color(0xFFF7F4F2),
|
||||
shadows: const [
|
||||
Shadow(blurRadius: 8, color: Colors.black),
|
||||
],
|
||||
fontSize: 12,
|
||||
fontWeight: category == item
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 2,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: category == item
|
||||
? const Color(0xFFFF5C37)
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onMute != null)
|
||||
IconButton(
|
||||
tooltip: muted ? l10n.unmute : l10n.mute,
|
||||
onPressed: onMute,
|
||||
icon: Icon(
|
||||
muted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
|
||||
size: 21,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../../core/config/locale_controller.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// Shortcuts for the existing root tabs; this widget owns no navigation state.
|
||||
@@ -27,7 +28,7 @@ class HomeQuickDrawer extends StatelessWidget {
|
||||
|
||||
static const _panel = Color(0xFF222225);
|
||||
static const _card = Color(0xFF303033);
|
||||
static const _orange = Color(0xFFFF5C37);
|
||||
static const _orange = Color(0xFF2F7BFF);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -88,11 +89,10 @@ class HomeQuickDrawer extends StatelessWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.asset(
|
||||
drama.posterAsset,
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
width: 74,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -111,18 +111,9 @@ class HomeQuickDrawer extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
l10n.episodeCount(drama.episodes.length),
|
||||
l10n.episodeCount(drama.episodeCount),
|
||||
style: const TextStyle(color: Color(0xFFE7E2E0)),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
l10n.freeTestMedia,
|
||||
maxLines: 2,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFB99E),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,10 +6,10 @@ class MineBinding {
|
||||
MineLogic create({
|
||||
required Drama drama,
|
||||
required int initialSection,
|
||||
WatchHistoryRepository? history,
|
||||
required WatchHistoryRepository history,
|
||||
}) => MineLogic(
|
||||
drama: drama,
|
||||
initialSection: initialSection,
|
||||
history: history ?? SharedPreferencesWatchHistoryRepository(),
|
||||
history: history,
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/locale_controller.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
@@ -20,6 +21,11 @@ class MineLogic extends ChangeNotifier {
|
||||
final WatchHistoryRepository history;
|
||||
Drama drama;
|
||||
late Future<Duration?> position;
|
||||
List<WatchProgress> recent = const [];
|
||||
bool recentLoading = false;
|
||||
String? recentError;
|
||||
/// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始)
|
||||
bool positionFailed = false;
|
||||
int section;
|
||||
int filter = 0;
|
||||
bool _disposed = false;
|
||||
@@ -29,11 +35,33 @@ class MineLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void refreshPosition() {
|
||||
position = history.positionFor(
|
||||
demoProfileId,
|
||||
drama.id,
|
||||
drama.episodes.first.id,
|
||||
);
|
||||
position = history
|
||||
.resumePointFor(drama.id)
|
||||
.then((point) {
|
||||
positionFailed = false;
|
||||
return point?.position;
|
||||
})
|
||||
.catchError((Object _) {
|
||||
positionFailed = true;
|
||||
notify();
|
||||
return null;
|
||||
});
|
||||
refreshRecent();
|
||||
}
|
||||
|
||||
/// 最近观看:服务端只给 dramaId/位置,剧名封面用已加载的目录卡片按 id 映射。
|
||||
Future<void> refreshRecent() async {
|
||||
recentLoading = true;
|
||||
recentError = null;
|
||||
notify();
|
||||
try {
|
||||
recent = await history.recent(limit: 20);
|
||||
} catch (_) {
|
||||
recentError = 'progressError';
|
||||
} finally {
|
||||
recentLoading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
void selectSection(int index) {
|
||||
|
||||
+400
-223
@@ -1,10 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/engagement_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'mine_binding.dart';
|
||||
import 'mine_logic.dart';
|
||||
@@ -15,11 +22,16 @@ class MyPage extends StatefulWidget {
|
||||
required this.drama,
|
||||
required this.duration,
|
||||
this.initialSection = 0,
|
||||
this.profile = false,
|
||||
this.onOpenHistory,
|
||||
this.history,
|
||||
this.embedded = false,
|
||||
this.active = true,
|
||||
this.sectionRequest = 0,
|
||||
this.onAction,
|
||||
this.signedIn = false,
|
||||
this.onSignIn,
|
||||
this.onSignOut,
|
||||
this.dramas,
|
||||
this.onDramaSelected,
|
||||
});
|
||||
@@ -27,11 +39,16 @@ class MyPage extends StatefulWidget {
|
||||
final Drama drama;
|
||||
final Duration duration;
|
||||
final int initialSection;
|
||||
final bool profile;
|
||||
final VoidCallback? onOpenHistory;
|
||||
final WatchHistoryRepository? history;
|
||||
final bool embedded;
|
||||
final bool active;
|
||||
final int sectionRequest;
|
||||
final ValueChanged<MyPageAction>? onAction;
|
||||
final bool signedIn;
|
||||
final VoidCallback? onSignIn;
|
||||
final VoidCallback? onSignOut;
|
||||
final List<Drama>? dramas;
|
||||
final ValueChanged<Drama>? onDramaSelected;
|
||||
|
||||
@@ -48,7 +65,7 @@ class _MyPageState extends State<MyPage> {
|
||||
logic = MineBinding().create(
|
||||
drama: widget.drama,
|
||||
initialSection: widget.initialSection,
|
||||
history: widget.history,
|
||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +103,7 @@ class _MyPageState extends State<MyPage> {
|
||||
bottom: !widget.embedded,
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(l10n),
|
||||
if (!widget.profile) _topBar(l10n),
|
||||
Expanded(
|
||||
child: FutureBuilder<Duration?>(
|
||||
future: logic.position,
|
||||
@@ -101,15 +118,13 @@ class _MyPageState extends State<MyPage> {
|
||||
1.0,
|
||||
);
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
||||
children: [
|
||||
_profile(l10n),
|
||||
const SizedBox(height: 22),
|
||||
_continueCard(l10n, fraction, position, total),
|
||||
const SizedBox(height: 26),
|
||||
_library(l10n, position, total, fraction),
|
||||
const SizedBox(height: 26),
|
||||
_preferences(l10n),
|
||||
if (widget.profile)
|
||||
..._accountPage(l10n)
|
||||
else ...[
|
||||
_library(l10n, position, total, fraction),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Text(
|
||||
@@ -147,8 +162,8 @@ class _MyPageState extends State<MyPage> {
|
||||
const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
l10n.myTab,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
||||
l10n.myListTab,
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
@@ -172,158 +187,247 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _profile(AppLocalizations l10n) => Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundColor: Color(0xFF32343A),
|
||||
child: Icon(Icons.person_rounded, color: Colors.white, size: 34),
|
||||
List<Widget> _accountPage(AppLocalizations l10n) => [
|
||||
if (widget.signedIn) _signedInRow(l10n) else _guestRow(l10n),
|
||||
if (widget.signedIn) ...[const SizedBox(height: 18), _wallet(l10n)],
|
||||
const SizedBox(height: 14),
|
||||
_menuCard(l10n),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
l10n.preferencesHeading,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.localViewer,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.localDemoProfile,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF24252A),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(l10n.demo, style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _continueCard(
|
||||
AppLocalizations l10n,
|
||||
double fraction,
|
||||
Duration position,
|
||||
Duration total,
|
||||
) => Container(
|
||||
padding: const EdgeInsets.all(13),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF201B1E),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.language_rounded, color: AppColors.blueSoft),
|
||||
title: Text(l10n.appLanguage),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => logic.showLanguage(context),
|
||||
),
|
||||
];
|
||||
|
||||
Widget _guestRow(AppLocalizations l10n) {
|
||||
return Row(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.history_rounded, color: _orange, size: 17),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.continueWatching,
|
||||
const CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: Color(0xFF32343A),
|
||||
child: Icon(Icons.person_rounded, color: Colors.white, size: 30),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.logInHint,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: widget.onSignIn,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: AppColors.blue,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
child: Text(l10n.logIn),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _signedInRow(AppLocalizations l10n) {
|
||||
// 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案
|
||||
final user = context.watch<AuthController>().user;
|
||||
final name = (user?.nickname.isNotEmpty ?? false) ? user!.nickname : l10n.mockUserName;
|
||||
final uidText = (user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.mockUserId;
|
||||
return Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: Color(0xFF32343A),
|
||||
child: Icon(Icons.person_rounded, color: Colors.white, size: 30),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.episodeProgress(1, widget.drama.episodes.length),
|
||||
style: const TextStyle(color: Color(0xFFFFA38F), fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_poster(width: 75, height: 96),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedDramaTitle(l10n, widget.drama),
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
l10n.dramaTag,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
l10n.watchedPercent((fraction * 100).round()),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE7DEDD),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: fraction,
|
||||
minHeight: 4,
|
||||
backgroundColor: const Color(0xFF444046),
|
||||
color: _orange,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${_clock(position)} / ${_clock(total)}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
uidText,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => logic.act(
|
||||
context,
|
||||
MyPageAction.resume,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _orange,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(37),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
||||
label: Text(l10n.resume),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onSignOut ?? () => _mockSnack(l10n),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2A2C33),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
child: Text(l10n.signOut),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wallet(AppLocalizations l10n) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF17191F),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.myAccount,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _accountTile(
|
||||
label: l10n.coins,
|
||||
value: '\$ 0',
|
||||
action: l10n.topUpWallet,
|
||||
color: AppColors.blue,
|
||||
onPressed: () => _mockSnack(l10n),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _accountTile(
|
||||
label: l10n.vipMembership,
|
||||
value: l10n.notActive,
|
||||
action: l10n.joinVip,
|
||||
color: const Color(0xFFF5C451),
|
||||
onPressed: () => _mockSnack(l10n),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _mockSnack(AppLocalizations l10n) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.mockNotice)));
|
||||
}
|
||||
|
||||
Widget _accountTile({
|
||||
required String label,
|
||||
required String value,
|
||||
required String action,
|
||||
required Color color,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF12141A),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: color.withValues(alpha: 0.45)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: color, fontSize: 12)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: onPressed,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: color == AppColors.blue
|
||||
? Colors.white
|
||||
: const Color(0xFF2A2110),
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
child: Text(action, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _menuCard(AppLocalizations l10n) {
|
||||
return Material(
|
||||
color: const Color(0xFF17191F),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.card_giftcard_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
title: Text(l10n.earnRewards),
|
||||
trailing: Text(
|
||||
l10n.freeBonus,
|
||||
style: const TextStyle(color: AppColors.blue, fontSize: 12),
|
||||
),
|
||||
onTap: () => _mockSnack(l10n),
|
||||
),
|
||||
const Divider(height: 1, color: Color(0xFF2A2C33)),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.history_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
title: Text(l10n.watchHistory),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: widget.onOpenHistory,
|
||||
),
|
||||
const Divider(height: 1, color: Color(0xFF2A2C33)),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.settings_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
title: Text(l10n.settings),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => logic.showLanguage(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _library(
|
||||
AppLocalizations l10n,
|
||||
@@ -338,11 +442,14 @@ class _MyPageState extends State<MyPage> {
|
||||
(logic.filter == 1 && fraction < 0.99) ||
|
||||
(logic.filter == 2 && fraction >= 0.99);
|
||||
final engagement = context.watch<EngagementController>();
|
||||
final markedDramas = (widget.dramas ?? [widget.drama]).where((drama) {
|
||||
return logic.section == 1
|
||||
? engagement.isSaved(drama.id)
|
||||
: engagement.isLiked(drama.id);
|
||||
}).toList();
|
||||
// 收藏以服务端为准(收藏列表接口返回卡片结构);点赞仍是本地状态(K3 接互动)。
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
final isWatchlist = logic.section == 1;
|
||||
final markedDramas = isWatchlist
|
||||
? favorites.cards
|
||||
: (widget.dramas ?? [widget.drama])
|
||||
.where((drama) => engagement.isLiked(drama.id))
|
||||
.toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -352,15 +459,30 @@ class _MyPageState extends State<MyPage> {
|
||||
children: [
|
||||
for (var index = 0; index < tabs.length; index++) ...[
|
||||
InkWell(
|
||||
onTap: () => logic.selectSection(index),
|
||||
child: Text(
|
||||
tabs[index],
|
||||
style: TextStyle(
|
||||
onTap: () {
|
||||
logic.selectSection(index);
|
||||
// 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐)
|
||||
if (index == 1) unawaited(favorites.load());
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: logic.section == index
|
||||
? _orange
|
||||
: const Color(0xFFE4DBDD),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
? AppColors.blue
|
||||
: const Color(0xFF24262E),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
tabs[index],
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -446,7 +568,7 @@ class _MyPageState extends State<MyPage> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFA18D),
|
||||
color: AppColors.blueSoft,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
@@ -466,6 +588,34 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (isWatchlist && favorites.loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 22),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (isWatchlist && favorites.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
errorText(l10n, favorites.error, l10n.favoritesError),
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(favorites.load()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (logic.section != 0 && markedDramas.isNotEmpty)
|
||||
for (final drama in markedDramas) ...[
|
||||
_markedDramaCard(drama, l10n),
|
||||
@@ -479,10 +629,90 @@ class _MyPageState extends State<MyPage> {
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
),
|
||||
// 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧;
|
||||
// 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。
|
||||
if (logic.section == 0) ...[
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
l10n.history,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
if (logic.positionFailed)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.progressError,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: logic.refreshPosition,
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (logic.recentLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (logic.recentError != null)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorText(l10n, logic.recentError, l10n.progressError),
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(logic.refreshRecent()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (_recentDramas.isEmpty)
|
||||
Text(
|
||||
l10n.noStories,
|
||||
style: const TextStyle(color: Color(0xFFB7B3B8)),
|
||||
)
|
||||
else
|
||||
for (final drama in _recentDramas) ...[
|
||||
_markedDramaCard(drama, l10n),
|
||||
const SizedBox(height: 9),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 服务端最近观看 → 已加载目录里的卡片(按 id 匹配,去重,当前剧也算一条)。
|
||||
List<Drama> get _recentDramas {
|
||||
final pool = <Drama>[...?widget.dramas, widget.drama];
|
||||
final result = <Drama>[];
|
||||
for (final item in logic.recent) {
|
||||
for (final drama in pool) {
|
||||
if (drama.id == item.dramaId &&
|
||||
!result.any((picked) => picked.id == drama.id)) {
|
||||
result.add(drama);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell(
|
||||
onTap: () {
|
||||
if (widget.onDramaSelected != null) {
|
||||
@@ -507,12 +737,7 @@ class _MyPageState extends State<MyPage> {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.asset(
|
||||
drama.posterAsset,
|
||||
width: 74,
|
||||
height: 103,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
child: RemoteImage(drama.posterUrl, width: 74, height: 103),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
@@ -530,7 +755,7 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
l10n.episodeCount(drama.episodes.length),
|
||||
l10n.episodeCount(drama.episodeCount),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 12,
|
||||
@@ -545,49 +770,6 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _preferences(AppLocalizations l10n) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.preferencesHeading,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFCEBBB9),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Material(
|
||||
color: const Color(0xFF1B1C21),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.translate_rounded,
|
||||
color: Color(0xFFFFBC65),
|
||||
),
|
||||
title: Text(l10n.appLanguage),
|
||||
subtitle: Text(l10n.language),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
Localizations.localeOf(context).languageCode == 'zh'
|
||||
? l10n.simplifiedChinese
|
||||
: l10n.english,
|
||||
style: const TextStyle(color: Color(0xFFFFB2A1), fontSize: 11),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right_rounded, size: 18),
|
||||
],
|
||||
),
|
||||
onTap: () => logic.showLanguage(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _bottomNav(AppLocalizations l10n) => Container(
|
||||
height: 63,
|
||||
decoration: const BoxDecoration(
|
||||
@@ -629,12 +811,7 @@ class _MyPageState extends State<MyPage> {
|
||||
|
||||
Widget _poster({required double width, required double height}) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: Image.asset(
|
||||
widget.drama.posterAsset,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
child: RemoteImage(widget.drama.posterUrl, width: width, height: height),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -683,4 +860,4 @@ String _clock(Duration duration) {
|
||||
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
const _orange = Color(0xFFFF5B3D);
|
||||
const _orange = AppColors.blue;
|
||||
@@ -1,10 +1,15 @@
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
import '../../core/data/services/android_picture_in_picture.dart';
|
||||
import 'player_logic.dart';
|
||||
|
||||
class PlayerBinding {
|
||||
PlayerLogic create() => PlayerLogic(
|
||||
history: SharedPreferencesWatchHistoryRepository(),
|
||||
PlayerLogic create({
|
||||
required WatchHistoryRepository history,
|
||||
required PlaybackRepository playback,
|
||||
}) => PlayerLogic(
|
||||
history: history,
|
||||
playback: playback,
|
||||
pictureInPicture: AndroidPictureInPicture(),
|
||||
);
|
||||
}
|
||||
+323
-120
@@ -2,11 +2,17 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/play_ticket.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/models/player_port.dart';
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/android_picture_in_picture.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/platform_share_service.dart';
|
||||
import '../../core/data/services/playback_queue.dart';
|
||||
import '../../core/data/services/playback_session.dart';
|
||||
@@ -16,30 +22,42 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../discover/discover_logic.dart';
|
||||
import '../discover/discover_view.dart';
|
||||
import '../drama_detail/drama_detail_view.dart';
|
||||
import '../home/home_feed_sections.dart';
|
||||
import '../mine/mine_logic.dart';
|
||||
import '../mine/mine_view.dart';
|
||||
import 'player_view.dart';
|
||||
import 'widgets/drama_comments_sheet.dart';
|
||||
|
||||
class PlayerLogic extends ChangeNotifier {
|
||||
PlayerLogic({required this.history, required this.pictureInPicture});
|
||||
PlayerLogic({
|
||||
required this.history,
|
||||
required this.playback,
|
||||
required this.pictureInPicture,
|
||||
});
|
||||
|
||||
final WatchHistoryRepository history;
|
||||
final PlaybackRepository playback;
|
||||
final AndroidPictureInPicture pictureInPicture;
|
||||
|
||||
late PageController pages;
|
||||
late PlaybackQueue queue;
|
||||
late HomeFeedSections homeSections;
|
||||
late List<int> visibleIndices;
|
||||
HomeFeedCategory category = HomeFeedCategory.recommended;
|
||||
int categoryRequest = 0;
|
||||
late PlaybackSession session;
|
||||
VideoPlayerPort? trackedPlayer;
|
||||
Episode? trackedEpisode;
|
||||
Drama? trackedDrama;
|
||||
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
/// 进度上报节流:契约建议每 10 秒一次
|
||||
Duration progressInterval = const Duration(seconds: 10);
|
||||
Future<void> pendingWrite = Future<void>.value();
|
||||
/// 服务端续看点:进入播放页时定位到"那一集的哪一秒"
|
||||
ResumePoint? resumePoint;
|
||||
bool resumeLoaded = false;
|
||||
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
|
||||
bool ticketLoading = false;
|
||||
String? ticketError;
|
||||
PlayTicket? blockedTicket;
|
||||
String? blockedEpisodeId;
|
||||
List<Episode> sessionEpisodes = const [];
|
||||
int pageRequest = 0;
|
||||
bool muted = false;
|
||||
bool stoppedAtFeedEnd = false;
|
||||
@@ -47,6 +65,8 @@ class PlayerLogic extends ChangeNotifier {
|
||||
bool fullscreen = false;
|
||||
bool pip = false;
|
||||
bool pipPreparing = false;
|
||||
bool screenCleared = false;
|
||||
final Map<String, int> pickedEpisode = {};
|
||||
|
||||
bool Function() isMounted = () => false;
|
||||
bool homeMode = false;
|
||||
@@ -64,9 +84,30 @@ class PlayerLogic extends ChangeNotifier {
|
||||
ValueChanged<Drama>? onDramaChanged;
|
||||
ValueChanged<bool>? onImmersiveChanged;
|
||||
bool _disposed = false;
|
||||
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
|
||||
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
|
||||
bool _started = false;
|
||||
|
||||
bool get immersive => fullscreen || pip || pipPreparing;
|
||||
|
||||
bool get chromeHidden => immersive || screenCleared;
|
||||
|
||||
Episode displayEpisode(Drama drama, Episode fallback) {
|
||||
final number = pickedEpisode[drama.id];
|
||||
if (number == null) return fallback;
|
||||
for (final episode in drama.episodes) {
|
||||
if (episode.number == number) return episode;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
void setScreenCleared(bool value) {
|
||||
if (screenCleared == value) return;
|
||||
screenCleared = value;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
}
|
||||
|
||||
void notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
@@ -93,21 +134,20 @@ class PlayerLogic extends ChangeNotifier {
|
||||
required PlayerFactory factory,
|
||||
required PlaybackCoordinator coordinator,
|
||||
}) {
|
||||
_started = true;
|
||||
pictureInPicture.setStateListener(onPipChanged);
|
||||
queue = feedDramas == null
|
||||
? PlaybackQueue.series(pageDrama)
|
||||
: PlaybackQueue.home(feedDramas!);
|
||||
homeSections = HomeFeedSections([
|
||||
for (final entry in queue.entries) entry.drama,
|
||||
]);
|
||||
visibleIndices = homeMode
|
||||
? homeSections.indicesFor(category)
|
||||
: List<int>.generate(queue.length, (index) => index);
|
||||
visibleIndices = List<int>.generate(queue.length, (index) => index);
|
||||
pages = PageController(initialPage: initialIndex);
|
||||
// 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它)
|
||||
sessionEpisodes = [for (final entry in queue.entries) entry.episode];
|
||||
session = PlaybackSession(
|
||||
episodes: [for (final entry in queue.entries) entry.episode],
|
||||
episodes: sessionEpisodes,
|
||||
factory: factory,
|
||||
canPlay: (episode) => episode.isFree,
|
||||
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
|
||||
canPlay: (episode) => episode.videoUrl.isNotEmpty,
|
||||
prepare: preparePlayer,
|
||||
activate: activatePlayer,
|
||||
coordinator: coordinator,
|
||||
@@ -115,9 +155,88 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (!active || visibleIndices.isEmpty) {
|
||||
session.setRouteVisible(false);
|
||||
}
|
||||
unawaited(loadResumePoint());
|
||||
unawaited(selectEpisode(initialIndex));
|
||||
}
|
||||
|
||||
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
|
||||
Future<void> loadResumePoint() async {
|
||||
final dramaId = queue.entryAt(initialIndex).drama.id;
|
||||
try {
|
||||
resumePoint = await history.resumePointFor(dramaId);
|
||||
} catch (_) {
|
||||
// 拿不到续看位置不影响播放:从当前集开头看
|
||||
resumePoint = null;
|
||||
} finally {
|
||||
resumeLoaded = true;
|
||||
if (isMounted()) notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
|
||||
/// 该集是否该定位到服务端记录的位置:只有"记录里的那一集"才定位,其它集从头播。
|
||||
Duration? resumePositionFor(String episodeId) {
|
||||
final point = resumePoint;
|
||||
if (point == null || point.episodeId != episodeId) return null;
|
||||
if (point.position <= Duration.zero) return null;
|
||||
return point.position;
|
||||
}
|
||||
|
||||
/// 进度上报节流:距上次上报不足 [progressInterval] 就不报。
|
||||
///
|
||||
/// 切集/退出/切后台走 [saveCurrentProgress],那条路径**不受节流限制**。
|
||||
bool shouldReportProgress(DateTime now) {
|
||||
if (now.difference(lastProgressWrite) < progressInterval) return false;
|
||||
lastProgressWrite = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
|
||||
/// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。
|
||||
Future<bool> ensureTicket(int index) async {
|
||||
if (index < 0 || index >= sessionEpisodes.length) return false;
|
||||
final episode = sessionEpisodes[index];
|
||||
final request = pageRequest;
|
||||
ticketLoading = true;
|
||||
ticketError = null;
|
||||
notify();
|
||||
try {
|
||||
final ticket = await playback.ticketFor(episode.id);
|
||||
if (request != pageRequest) return false;
|
||||
blockedTicket = null;
|
||||
blockedEpisodeId = null;
|
||||
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
|
||||
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
|
||||
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
|
||||
return true;
|
||||
}
|
||||
blockedTicket = ticket;
|
||||
blockedEpisodeId = episode.id;
|
||||
return false;
|
||||
} catch (exception) {
|
||||
if (request != pageRequest) return false;
|
||||
// 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了
|
||||
if (episode.videoUrl.isNotEmpty) {
|
||||
ticketError = null;
|
||||
return true;
|
||||
}
|
||||
ticketError = exception is ApiException
|
||||
? exception.message
|
||||
: 'ticketError';
|
||||
return false;
|
||||
} finally {
|
||||
ticketLoading = false;
|
||||
if (isMounted()) notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 重试当前集的票据(失败态的"重试"按钮)。
|
||||
Future<void> retryTicket() async {
|
||||
ticketError = null;
|
||||
notify();
|
||||
unawaited(selectEpisode(session.currentIndex));
|
||||
}
|
||||
|
||||
void onPageUpdated(PlayerPage previous) {
|
||||
if (previous.active != active) {
|
||||
session.setRouteVisible(active && visibleIndices.isNotEmpty);
|
||||
@@ -135,8 +254,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (isMounted() && backRequest == request) {
|
||||
if (fullscreen) {
|
||||
unawaited(setFullscreen(false));
|
||||
} else if (screenCleared) {
|
||||
setScreenCleared(false);
|
||||
} else {
|
||||
shiftCategory(-1);
|
||||
onMenu?.call();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -145,6 +266,14 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
void onLifecycle(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress());
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// 签名地址有有效期:回前台时若票据已过期,重新取一张再起播,
|
||||
// 否则会撞上 403(契约 §1.4)
|
||||
final episode = trackedEpisode;
|
||||
if (episode != null && !playback.isFresh(episode.id)) {
|
||||
unawaited(selectEpisode(session.currentIndex));
|
||||
}
|
||||
}
|
||||
session.setAppResumed(
|
||||
state == AppLifecycleState.resumed || pip || pipPreparing,
|
||||
);
|
||||
@@ -163,14 +292,16 @@ class PlayerLogic extends ChangeNotifier {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
||||
}
|
||||
unawaited(saveCurrentProgress());
|
||||
session.setRouteVisible(false);
|
||||
cancelScrub();
|
||||
unawaited(session.close());
|
||||
pages.dispose();
|
||||
if (_started) {
|
||||
session.setRouteVisible(false);
|
||||
cancelScrub();
|
||||
unawaited(session.close());
|
||||
pages.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void notifyImmersive() => onImmersiveChanged?.call(immersive);
|
||||
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
|
||||
|
||||
Future<void> setFullscreen(bool value) async {
|
||||
if (fullscreen == value || !isMounted()) return;
|
||||
@@ -192,7 +323,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
} catch (_) {
|
||||
if (value && isMounted()) {
|
||||
fullscreen = false;
|
||||
notify();
|
||||
notify();
|
||||
notifyImmersive();
|
||||
}
|
||||
}
|
||||
@@ -226,7 +357,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
session.setAppResumed(true);
|
||||
} else {
|
||||
pipPreparing = false;
|
||||
notify();
|
||||
notify();
|
||||
notifyImmersive();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)),
|
||||
@@ -248,23 +379,18 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
|
||||
if (player is! VideoPlayerPort) return;
|
||||
final drama = queue.entries
|
||||
.firstWhere((e) => e.episode.id == episode.id)
|
||||
.drama;
|
||||
try {
|
||||
await pendingWrite;
|
||||
final saved = await history.positionFor(
|
||||
demoProfileId,
|
||||
drama.id,
|
||||
episode.id,
|
||||
);
|
||||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||||
if (resumePoint == null) await loadResumePoint();
|
||||
final saved = resumePositionFor(episode.id);
|
||||
if (saved != null) {
|
||||
await player.controller.seekTo(
|
||||
clampResumePosition(saved, player.controller.value.duration),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// A local preference failure does not prevent playback.
|
||||
// 续看位置取不到不影响播放:从头播。
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +413,13 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (player.controller.value.isCompleted && !completionProgressSaved) {
|
||||
completionProgressSaved = true;
|
||||
unawaited(
|
||||
queueProgress(drama, episode, player.controller.value.duration),
|
||||
queueProgress(
|
||||
drama,
|
||||
episode,
|
||||
player.controller.value.duration,
|
||||
duration: player.controller.value.duration,
|
||||
finished: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!completionHandled &&
|
||||
@@ -297,11 +429,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
completionHandled = true;
|
||||
unawaited(advanceAfterEnd(player, index));
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (now.difference(lastProgressWrite) < const Duration(seconds: 5)) {
|
||||
return;
|
||||
}
|
||||
lastProgressWrite = now;
|
||||
if (!shouldReportProgress(DateTime.now())) return;
|
||||
unawaited(
|
||||
queueProgress(drama, episode, player.controller.value.position),
|
||||
);
|
||||
@@ -327,17 +455,30 @@ class PlayerLogic extends ChangeNotifier {
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
} catch (_) {
|
||||
// A category change can detach the old page controller mid-animation.
|
||||
// The page can leave the tree before the animation finishes.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> queueProgress(Drama drama, Episode episode, Duration position) {
|
||||
/// 进度上报:节流由调用方控制(10 秒),切集/退出/切后台都会补一次。
|
||||
Future<void> queueProgress(
|
||||
Drama drama,
|
||||
Episode episode,
|
||||
Duration position, {
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
}) {
|
||||
pendingWrite = pendingWrite
|
||||
.then(
|
||||
(_) => history.save(demoProfileId, drama.id, episode.id, position),
|
||||
(_) => history.save(
|
||||
episodeId: episode.id,
|
||||
position: position,
|
||||
duration: duration,
|
||||
finished: finished,
|
||||
),
|
||||
)
|
||||
.catchError((Object _) {
|
||||
// Playback continues if local history cannot be stored.
|
||||
// 进度上报失败不影响播放(下次节流点会再报一次)。
|
||||
return null;
|
||||
});
|
||||
return pendingWrite;
|
||||
}
|
||||
@@ -352,7 +493,12 @@ class PlayerLogic extends ChangeNotifier {
|
||||
!player.controller.value.isInitialized) {
|
||||
return pendingWrite;
|
||||
}
|
||||
return queueProgress(drama, episode, player.controller.value.position);
|
||||
return queueProgress(
|
||||
drama,
|
||||
episode,
|
||||
player.controller.value.position,
|
||||
duration: player.controller.value.duration,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> selectEpisode(int index) async {
|
||||
@@ -361,6 +507,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
// Capture the old position synchronously, persist without blocking a swipe.
|
||||
unawaited(saveCurrentProgress());
|
||||
if (request != pageRequest || !isMounted()) return;
|
||||
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
|
||||
final ready = await ensureTicket(index);
|
||||
if (request != pageRequest || !isMounted()) return;
|
||||
if (!ready && ticketError != null) return;
|
||||
await session.select(index, preloadIndex: nextVisibleIndex(index));
|
||||
}
|
||||
|
||||
@@ -371,49 +521,6 @@ class PlayerLogic extends ChangeNotifier {
|
||||
: null;
|
||||
}
|
||||
|
||||
void shiftCategory(int offset) {
|
||||
if (!homeMode) return;
|
||||
final next = category.index + offset;
|
||||
if (next < 0) {
|
||||
onMenu?.call();
|
||||
return;
|
||||
}
|
||||
if (next >= HomeFeedCategory.values.length) return;
|
||||
unawaited(selectCategory(HomeFeedCategory.values[next]));
|
||||
}
|
||||
|
||||
Future<void> selectCategory(HomeFeedCategory next) async {
|
||||
if (!homeMode || next == category) return;
|
||||
final request = ++categoryRequest;
|
||||
++pageRequest;
|
||||
session.setRouteVisible(false);
|
||||
cancelScrub();
|
||||
await saveCurrentProgress();
|
||||
if (!isMounted() || request != categoryRequest) return;
|
||||
|
||||
final oldPages = pages;
|
||||
final indices = homeSections.indicesFor(next);
|
||||
category = next;
|
||||
visibleIndices = indices;
|
||||
pages = PageController();
|
||||
notify();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => oldPages.dispose());
|
||||
|
||||
if (indices.isEmpty) return;
|
||||
onDramaChanged?.call(queue.entryAt(indices.first).drama);
|
||||
await session.select(
|
||||
indices.first,
|
||||
preloadIndex: nextVisibleIndex(indices.first),
|
||||
);
|
||||
if (isMounted() && request == categoryRequest) {
|
||||
if (stoppedAtFeedEnd) {
|
||||
stoppedAtFeedEnd = false;
|
||||
session.setUserWantsPlay(true);
|
||||
}
|
||||
session.setRouteVisible(active);
|
||||
}
|
||||
}
|
||||
|
||||
void beginScrub(VideoPlayerPort player) {
|
||||
if (session.active != player) return;
|
||||
scrubbingPlayer = player;
|
||||
@@ -426,7 +533,11 @@ class PlayerLogic extends ChangeNotifier {
|
||||
session.setOverlayOpen(false);
|
||||
}
|
||||
|
||||
Future<void> seekTo(BuildContext context, VideoPlayerPort player, Duration position) async {
|
||||
Future<void> seekTo(
|
||||
BuildContext context,
|
||||
VideoPlayerPort player,
|
||||
Duration position,
|
||||
) async {
|
||||
if (scrubbingPlayer != player || session.active != player) return;
|
||||
try {
|
||||
await session.playbackSettled;
|
||||
@@ -464,12 +575,31 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (isMounted()) session.setRouteVisible(active);
|
||||
}
|
||||
|
||||
Future<void> openSeriesAt(
|
||||
BuildContext context,
|
||||
Drama drama,
|
||||
int index,
|
||||
) async {
|
||||
session.setRouteVisible(false);
|
||||
await session.playbackSettled;
|
||||
await saveCurrentProgress();
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlayerPage(drama: drama, initialIndex: index),
|
||||
),
|
||||
);
|
||||
if (isMounted()) session.setRouteVisible(active);
|
||||
}
|
||||
|
||||
Future<void> showEpisodes(BuildContext context) async {
|
||||
if (feedDramas != null) {
|
||||
await openDetail(context);
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final entry = queue.entryAt(session.currentIndex);
|
||||
final drama = await _withFullEpisodes(context, entry.drama);
|
||||
if (!context.mounted) return;
|
||||
final homeFeed = feedDramas != null;
|
||||
final episodes = drama.episodes;
|
||||
final currentNumber = entry.episode.number;
|
||||
session.setOverlayOpen(true);
|
||||
await session.playbackSettled;
|
||||
if (!context.mounted) {
|
||||
@@ -479,49 +609,57 @@ class PlayerLogic extends ChangeNotifier {
|
||||
try {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF202024),
|
||||
backgroundColor: const Color(0xFF16171C),
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * 0.6,
|
||||
height: MediaQuery.sizeOf(sheetContext).height * 0.46,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||||
child: Text(
|
||||
l10n.episodes,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFB7B3B8),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: queue.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = queue.entryAt(index).episode;
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: const Color(0xFF3A3030),
|
||||
child: Text('${episode.number}'),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 6,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 1.45,
|
||||
),
|
||||
title: Text(l10n.episodeLabel(episode.number)),
|
||||
subtitle: Text(l10n.freeTestMedia),
|
||||
trailing: const Icon(Icons.play_arrow_rounded),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
unawaited(
|
||||
pages.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
itemCount: episodes.length,
|
||||
itemBuilder: (_, index) => _episodeCell(
|
||||
context,
|
||||
sheetContext,
|
||||
drama,
|
||||
episodes[index],
|
||||
index,
|
||||
homeFeed,
|
||||
currentNumber,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -534,6 +672,68 @@ class PlayerLogic extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
|
||||
/// 取不到(或页面外没有目录来源)就退回卡片本身。
|
||||
Future<Drama> _withFullEpisodes(BuildContext context, Drama drama) async {
|
||||
if (drama.detailLoaded) return drama;
|
||||
final CatalogController catalog;
|
||||
try {
|
||||
catalog = context.read<CatalogController>();
|
||||
} catch (_) {
|
||||
return drama;
|
||||
}
|
||||
try {
|
||||
return await catalog.requireDetail(drama);
|
||||
} catch (_) {
|
||||
return drama;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _episodeCell(
|
||||
BuildContext pageContext,
|
||||
BuildContext sheetContext,
|
||||
Drama drama,
|
||||
Episode episode,
|
||||
int index,
|
||||
bool homeFeed,
|
||||
int currentNumber,
|
||||
) {
|
||||
final selected = episode.number == currentNumber;
|
||||
return Material(
|
||||
color: selected ? const Color(0xFF2F7BFF) : const Color(0xFF2A2C33),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (homeFeed) {
|
||||
if (episode.number == currentNumber) return;
|
||||
unawaited(openSeriesAt(pageContext, drama, index));
|
||||
return;
|
||||
}
|
||||
if (index == session.currentIndex) return;
|
||||
unawaited(
|
||||
pages.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${episode.number}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showMy(BuildContext context, {int initialSection = 0}) async {
|
||||
if (embedded) {
|
||||
onMy?.call();
|
||||
@@ -574,7 +774,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
await showDiscover(context, focusSearch: true);
|
||||
}
|
||||
|
||||
Future<void> showDiscover(BuildContext context, {bool focusSearch = false}) async {
|
||||
Future<void> showDiscover(
|
||||
BuildContext context, {
|
||||
bool focusSearch = false,
|
||||
}) async {
|
||||
if (embedded) {
|
||||
onDiscover?.call();
|
||||
return;
|
||||
@@ -601,7 +804,8 @@ class PlayerLogic extends ChangeNotifier {
|
||||
await openDetail(context);
|
||||
} else if (action == DiscoverPageAction.my ||
|
||||
action == DiscoverPageAction.watchlist) {
|
||||
await showMy(context,
|
||||
await showMy(
|
||||
context,
|
||||
initialSection: action == DiscoverPageAction.watchlist ? 1 : 0,
|
||||
);
|
||||
} else {
|
||||
@@ -661,5 +865,4 @@ class PlayerLogic extends ChangeNotifier {
|
||||
session.setOverlayOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+416
-572
File diff suppressed because it is too large.
Load diff
@@ -22,7 +22,6 @@ class SeekProgressBar extends StatefulWidget {
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.showTimes = true,
|
||||
this.compactIdle = false,
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||
@@ -30,7 +29,6 @@ class SeekProgressBar extends StatefulWidget {
|
||||
final VoidCallback? onScrubStart;
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
final bool showTimes;
|
||||
final bool compactIdle;
|
||||
|
||||
@override
|
||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||
@@ -99,20 +97,32 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.showTimes && scrubbing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'${_format(previewPosition)} / ${_format(video.duration)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: widget.compactIdle && !scrubbing ? 20 : 36,
|
||||
height: scrubbing ? 48 : 44,
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: widget.compactIdle && !scrubbing ? 2 : 3,
|
||||
activeTrackColor: const Color(0xFFFF5C37),
|
||||
trackHeight: scrubbing ? 4 : 3,
|
||||
activeTrackColor: const Color(0xFF2F7BFF),
|
||||
inactiveTrackColor: Colors.white30,
|
||||
thumbColor: Colors.white,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: widget.compactIdle && !scrubbing ? 0 : 6,
|
||||
),
|
||||
overlayShape: RoundSliderOverlayShape(
|
||||
overlayRadius: widget.compactIdle && !scrubbing ? 0 : 11,
|
||||
enabledThumbRadius: scrubbing ? 8 : 6,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
|
||||
),
|
||||
child: Semantics(
|
||||
label: widget.semanticsLabel,
|
||||
@@ -134,20 +144,6 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showTimes && (!widget.compactIdle || scrubbing))
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_format(previewPosition),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
_format(video.duration),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,22 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.11.1"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -118,6 +134,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -149,6 +170,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -157,6 +183,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.15.7"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -165,6 +199,11 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -237,6 +276,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.19.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -301,6 +348,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.5.2"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.0.6"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -402,6 +457,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
sync_http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sync_http
|
||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -418,6 +481,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -482,6 +553,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+13
-11
@@ -1,5 +1,5 @@
|
||||
name: nontonki
|
||||
description: "Nontonki short-drama playback prototype."
|
||||
name: fruitshorts
|
||||
description: "FruitShorts short-drama playback app (Indonesia first)."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
@@ -34,6 +34,8 @@ dependencies:
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
# 网络层用 dio(统一拦截器/超时/错误映射);纯 Dart 无原生插件,仍然不引代码生成与图片缓存库
|
||||
dio: ^5.7.0
|
||||
provider: ^6.1.5+1
|
||||
video_player: ^2.14.0
|
||||
shared_preferences: ^2.5.5
|
||||
@@ -44,6 +46,11 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# 设备侧集成测试:真机/模拟器进程里真实发请求,验证"设备能不能连到开发机后端"
|
||||
# (红线第 10 条:设备验证必须设备侧取证,不能用开发机 curl 充数)
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
@@ -59,13 +66,8 @@ dev_dependencies:
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
generate: true
|
||||
assets:
|
||||
- assets/media/demo_clip.mp4
|
||||
- assets/images/demo_poster.jpg
|
||||
- assets/images/stitch_signal_poster_clean.png
|
||||
- assets/images/stitch_city_poster_clean.png
|
||||
- assets/images/stitch_vengeance_poster_clean.png
|
||||
- assets/images/stitch_rain_scene.jpg
|
||||
# 封面与视频全部由后端下发(/files/**),本地不再打包演示素材:
|
||||
# 原先 assets/media 下的演示视频有 316MB,会把安装包撑到无法接受的大小。
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
@@ -106,7 +108,7 @@ flutter:
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: assets/icon/nontonki.png
|
||||
image_path: assets/icon/fruitshorts.png
|
||||
adaptive_icon_background: "#D74A7D"
|
||||
adaptive_icon_foreground: assets/icon/nontonki.png
|
||||
adaptive_icon_foreground: assets/icon/fruitshorts.png
|
||||
remove_alpha_ios: true
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
/// 用假的 dio adapter 覆盖四条关键路径:注册成功、401 → 刷新 → 重试、刷新失败 → 清会话、登出。
|
||||
///
|
||||
/// 网络层从 http 换成 dio 后,这里的**断言口径一条没变**,只是注入方式从
|
||||
/// `http.testing.MockClient` 换成 `FakeHttpAdapter`。
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
});
|
||||
|
||||
String tokenBody({
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
String uid = 'U1',
|
||||
}) {
|
||||
return jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {
|
||||
'accessToken': accessToken,
|
||||
'refreshToken': refreshToken,
|
||||
'expiresIn': 7200,
|
||||
'user': {'uid': uid, 'nickname': 'Tester', 'avatarUrl': ''},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
ApiClient clientWith(
|
||||
AuthController Function() auth, {
|
||||
required FakeHttpAdapter adapter,
|
||||
}) {
|
||||
final store = AuthSessionStore();
|
||||
return ApiClient(
|
||||
baseUrl: 'http://127.0.0.1:8081',
|
||||
adapter: adapter,
|
||||
accessTokenReader: () => auth().accessToken,
|
||||
tokenRefresher: () => auth().refresh(),
|
||||
onUnauthorized: () => auth().clear(),
|
||||
deviceIdReader: store.deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
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') {
|
||||
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;
|
||||
final adapter = FakeHttpAdapter((options) async {
|
||||
if (options.uri.path == '/app/auth/refresh') {
|
||||
return FakeHttpResponse(
|
||||
jsonEncode({'code': 401, '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<ApiException>()));
|
||||
expect(auth.isSignedIn, isFalse);
|
||||
expect(await AuthSessionStore().read(), isNull);
|
||||
});
|
||||
|
||||
test('should_clear_local_session_when_sign_out_hits_server_error', () async {
|
||||
late AuthController auth;
|
||||
final adapter = FakeHttpAdapter((options) async {
|
||||
if (options.uri.path == '/app/auth/logout') {
|
||||
return FakeHttpResponse('{}', 500);
|
||||
}
|
||||
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
|
||||
});
|
||||
final store = AuthSessionStore();
|
||||
auth = AuthController(
|
||||
AuthApi(clientWith(() => auth, adapter: adapter)),
|
||||
store,
|
||||
);
|
||||
await auth.register(email: 'a@b.com', password: 'abcd1234');
|
||||
|
||||
await auth.signOut();
|
||||
expect(auth.isSignedIn, isFalse);
|
||||
expect(await AuthSessionStore().read(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/auth/auth_view.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
/// 登录/注册页的可读证据:校验提示 + 登录/注册切换 + 未登录时不会去发请求。
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
});
|
||||
|
||||
Future<void> pumpAuthPage(WidgetTester tester, {bool signUp = false}) async {
|
||||
final auth = AuthController(
|
||||
AuthApi(
|
||||
// 这个测试不联网:请求一律 500,正好用于验证"本地校验没过就不发请求"
|
||||
ApiClient(
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
|
||||
),
|
||||
),
|
||||
AuthSessionStore(),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<AuthController>.value(
|
||||
value: auth,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AuthPage(signUpMode: signUp),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', (tester) async {
|
||||
await pumpAuthPage(tester);
|
||||
|
||||
expect(find.text('Welcome back'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Enter your email'), findsOneWidget);
|
||||
expect(find.text('Welcome back'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_reject_weak_password_locally_when_only_letters_are_typed', (tester) async {
|
||||
await pumpAuthPage(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'a@b.com');
|
||||
await tester.enterText(find.byType(TextField).last, 'abcdefgh');
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Password needs both letters and numbers'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_flip_between_sign_in_and_sign_up_when_switch_link_is_tapped', (tester) async {
|
||||
await pumpAuthPage(tester);
|
||||
expect(find.text('Welcome back'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('New here? Create an account'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Create your account'), findsOneWidget);
|
||||
expect(find.text('Already have an account? Sign in'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -1,39 +1,96 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/services/catalog_controller.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'empty catalog is distinct from a failed load, and retry recovers',
|
||||
() async {
|
||||
final repo = ScriptedCatalogRepository();
|
||||
final controller = CatalogController(repo);
|
||||
await controller.load();
|
||||
expect(controller.dramas, isEmpty);
|
||||
expect(controller.error, isNull);
|
||||
test('should_distinguish_empty_catalog_from_load_failure_when_retry_succeeds', () async {
|
||||
final repository = ScriptedCatalogRepository();
|
||||
final controller = CatalogController(repository);
|
||||
|
||||
repo.fail = true;
|
||||
await controller.load();
|
||||
expect(controller.error, isNotNull);
|
||||
await controller.load();
|
||||
expect(controller.dramas, isEmpty);
|
||||
expect(controller.error, isNull);
|
||||
|
||||
repository.fail = true;
|
||||
await controller.load();
|
||||
expect(controller.error, isNotNull);
|
||||
|
||||
repository.fail = false;
|
||||
repository.feed = HomeFeed(
|
||||
sections: [
|
||||
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
|
||||
],
|
||||
);
|
||||
await controller.load();
|
||||
expect(controller.error, isNull);
|
||||
expect(controller.dramas, hasLength(1));
|
||||
expect(controller.sections.single.title, '热门推荐');
|
||||
});
|
||||
|
||||
test('should_request_drama_detail_once_when_it_is_loaded_on_demand', () async {
|
||||
final repository = ScriptedCatalogRepository();
|
||||
final controller = CatalogController(repository);
|
||||
repository.feed = HomeFeed(
|
||||
sections: [
|
||||
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
|
||||
],
|
||||
);
|
||||
await controller.load();
|
||||
|
||||
final card = controller.dramas.single;
|
||||
expect(card.episodes, isEmpty);
|
||||
|
||||
final detail = await controller.requireDetail(card);
|
||||
expect(detail.episodes, hasLength(2));
|
||||
expect(repository.detailCalls, 1);
|
||||
|
||||
// 第二次已经是详情,直接复用,不再请求
|
||||
await controller.requireDetail(detail);
|
||||
expect(repository.detailCalls, 1);
|
||||
expect(controller.dramas.single.episodes, hasLength(2));
|
||||
});
|
||||
|
||||
repo.fail = false;
|
||||
repo.items = await DemoCatalogRepository().fetchDramas();
|
||||
await controller.load();
|
||||
expect(controller.error, isNull);
|
||||
expect(controller.dramas, hasLength(3));
|
||||
expect(controller.dramas.first.episodes.length, 3);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Drama _card(String id) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: '简介 $id',
|
||||
posterUrl: '/files/$id.jpg',
|
||||
);
|
||||
|
||||
Drama _detail(String id) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: '简介 $id',
|
||||
posterUrl: '/files/$id.jpg',
|
||||
detailLoaded: true,
|
||||
episodes: const [
|
||||
Episode(id: '1', number: 1, title: '第一集', isFree: true),
|
||||
Episode(id: '2', number: 2, title: '第二集', isFree: false),
|
||||
],
|
||||
);
|
||||
|
||||
class ScriptedCatalogRepository implements CatalogRepository {
|
||||
bool fail = false;
|
||||
List<Drama> items = const [];
|
||||
HomeFeed feed = const HomeFeed();
|
||||
int detailCalls = 0;
|
||||
|
||||
@override
|
||||
Future<List<Drama>> fetchDramas() async {
|
||||
Future<HomeFeed> fetchHomeFeed() async {
|
||||
if (fail) throw StateError('offline');
|
||||
return items;
|
||||
return feed;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => feed;
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
detailCalls++;
|
||||
return _detail(dramaId);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('all mock entries reuse one bundled local demo video', () async {
|
||||
final dramas = await DemoCatalogRepository().fetchDramas();
|
||||
expect(dramas, hasLength(3));
|
||||
expect(dramas.every((drama) => drama.episodes.length == 3), isTrue);
|
||||
final paths = {
|
||||
for (final drama in dramas)
|
||||
for (final episode in drama.episodes) episode.assetPath,
|
||||
};
|
||||
expect(paths, {'assets/media/demo_clip.mp4'});
|
||||
expect(
|
||||
dramas.every(
|
||||
(drama) => drama.episodes.every((episode) => episode.isFree),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
final bytes = await rootBundle.load(paths.single);
|
||||
expect(bytes.lengthInBytes, greaterThan(1000000));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
|
||||
void main() {
|
||||
test('should_carry_one_preview_and_total_count_when_list_card_is_parsed', () {
|
||||
final drama = Drama.fromCardJson(const {
|
||||
'id': 5,
|
||||
'title': '闪婚后',
|
||||
'synopsis': 'synopsis',
|
||||
'cover': '/files/cover.jpg',
|
||||
'category': '都市情感',
|
||||
'genre': '都市情感',
|
||||
'totalEpisodes': 77,
|
||||
'isHot': true,
|
||||
'viewCount': 3100000,
|
||||
'preview': {
|
||||
'id': 9,
|
||||
'number': 1,
|
||||
'title': '第 1 集',
|
||||
'isFree': true,
|
||||
'playUrl': '/files/episode/9.mp4',
|
||||
},
|
||||
});
|
||||
|
||||
expect(drama.detailLoaded, isFalse);
|
||||
expect(drama.episodes, hasLength(1));
|
||||
expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4');
|
||||
expect(drama.episodeCount, 77);
|
||||
expect(drama.isHot, isTrue);
|
||||
});
|
||||
|
||||
test('should_have_no_episodes_when_list_card_has_no_playable_preview', () {
|
||||
final drama = Drama.fromCardJson(const {
|
||||
'id': 6,
|
||||
'title': '替嫁新娘',
|
||||
'totalEpisodes': 12,
|
||||
});
|
||||
|
||||
expect(drama.episodes, isEmpty);
|
||||
expect(drama.episodeCount, 12);
|
||||
});
|
||||
|
||||
test('should_count_shipped_episodes_when_detail_response_is_parsed', () {
|
||||
final drama = Drama.fromDetailJson(const {
|
||||
'id': 5,
|
||||
'title': '闪婚后',
|
||||
'synopsis': 'synopsis',
|
||||
'cover': '/files/cover.jpg',
|
||||
'totalEpisodes': 77,
|
||||
'episodes': [
|
||||
{'id': 9, 'number': 1, 'title': '第 1 集', 'isFree': true},
|
||||
{'id': 10, 'number': 2, 'title': '第 2 集', 'isFree': false},
|
||||
],
|
||||
});
|
||||
|
||||
expect(drama.detailLoaded, isTrue);
|
||||
expect(drama.episodeCount, 77);
|
||||
expect(drama.episodes.first.id, '9');
|
||||
});
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/pages/home/home_feed_sections.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'home categories filter demo dramas without changing their media',
|
||||
() async {
|
||||
final dramas = await DemoCatalogRepository().fetchDramas();
|
||||
final sections = HomeFeedSections(dramas);
|
||||
|
||||
expect(sections.indicesFor(HomeFeedCategory.recommended), [0, 1, 2]);
|
||||
expect(sections.indicesFor(HomeFeedCategory.animated), [0]);
|
||||
expect(sections.indicesFor(HomeFeedCategory.liveAction), [1, 2]);
|
||||
expect(sections.indicesFor(HomeFeedCategory.following), isEmpty);
|
||||
expect(
|
||||
sections.dramaAt(HomeFeedCategory.liveAction, 0).title,
|
||||
'City Lights & Velvet Nights',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
|
||||
/// id-ID 文案接入的可读证据:设备语言是 `id` 时真的拿到印尼语、`id` 在支持列表里、
|
||||
/// 并且品牌文案里不再有 demo 标记。
|
||||
void main() {
|
||||
test('should_return_indonesian_copy_when_locale_is_id', () async {
|
||||
final l10n = await AppLocalizations.delegate.load(const Locale('id'));
|
||||
|
||||
expect(l10n.homeTab, 'Beranda');
|
||||
expect(l10n.watchNow, 'Tonton gratis');
|
||||
expect(l10n.watchlist, 'Daftar Tonton');
|
||||
expect(l10n.coins, 'Koin');
|
||||
expect(l10n.signIn, 'Masuk');
|
||||
});
|
||||
|
||||
test('should_include_id_when_supported_locales_are_read', () {
|
||||
final codes = AppLocalizations.supportedLocales
|
||||
.map((locale) => locale.languageCode)
|
||||
.toList();
|
||||
|
||||
expect(codes, containsAll(<String>['en', 'zh', 'id']));
|
||||
});
|
||||
|
||||
test('should_drop_demo_marker_when_brand_copy_is_loaded', () async {
|
||||
for (final locale in const [Locale('en'), Locale('zh'), Locale('id')]) {
|
||||
final l10n = await AppLocalizations.delegate.load(locale);
|
||||
|
||||
expect(l10n.appName, 'FruitShorts', reason: '$locale appName');
|
||||
expect(l10n.brandMark, 'FRUITSHORTS', reason: '$locale brandMark');
|
||||
expect(l10n.shareFooter, 'FruitShorts', reason: '$locale shareFooter');
|
||||
expect(l10n.brandMark.toLowerCase(), isNot(contains('demo')));
|
||||
expect(l10n.shareFooter.toLowerCase(), isNot(contains('demo')));
|
||||
}
|
||||
});
|
||||
|
||||
test('should_keep_placeholders_when_indonesian_copy_renders', () async {
|
||||
final l10n = await AppLocalizations.delegate.load(const Locale('id'));
|
||||
|
||||
expect(l10n.episodeLabel(3), contains('3'));
|
||||
expect(l10n.episodeProgress(2, 10), contains('2'));
|
||||
expect(l10n.episodeProgress(2, 10), contains('10'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/config/locale_controller.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
});
|
||||
|
||||
test('should_keep_device_locale_when_no_choice_is_stored', () async {
|
||||
final controller = LocaleController();
|
||||
var notifications = 0;
|
||||
controller.addListener(() => notifications++);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.locale, isNull);
|
||||
expect(notifications, 1);
|
||||
});
|
||||
|
||||
test('should_restore_chosen_language_when_app_restarts', () async {
|
||||
final first = LocaleController();
|
||||
await first.select('zh');
|
||||
expect(first.locale, const Locale('zh'));
|
||||
|
||||
final reopened = LocaleController();
|
||||
await reopened.load();
|
||||
expect(reopened.locale, const Locale('zh'));
|
||||
});
|
||||
|
||||
test('should_clear_stored_chinese_preference_when_english_is_selected', () async {
|
||||
await LocaleController().select('zh');
|
||||
|
||||
final reopened = LocaleController();
|
||||
await reopened.load();
|
||||
await reopened.select('en');
|
||||
|
||||
final third = LocaleController();
|
||||
await third.load();
|
||||
expect(third.locale, const Locale('en'));
|
||||
});
|
||||
|
||||
test('should_clear_locale_when_code_is_unsupported', () async {
|
||||
final controller = LocaleController();
|
||||
await controller.select('zh');
|
||||
expect(controller.locale, const Locale('zh'));
|
||||
|
||||
await controller.select('tlh');
|
||||
|
||||
expect(controller.locale, isNull);
|
||||
});
|
||||
|
||||
test('should_resolve_to_device_locale_when_stored_code_is_unsupported', () async {
|
||||
await SharedPreferencesAsync().setString('ui.locale', 'fr');
|
||||
|
||||
final controller = LocaleController();
|
||||
await controller.load();
|
||||
|
||||
expect(controller.locale, isNull);
|
||||
});
|
||||
|
||||
test('should_survive_load_and_select_when_preference_store_is_broken', () async {
|
||||
SharedPreferencesAsyncPlatform.instance = _BrokenPreferences();
|
||||
final controller = LocaleController();
|
||||
|
||||
await controller.load();
|
||||
expect(controller.locale, isNull);
|
||||
|
||||
await controller.select('zh');
|
||||
expect(controller.locale, const Locale('zh'));
|
||||
});
|
||||
}
|
||||
|
||||
/// Stands in for a platform whose preference store is unavailable.
|
||||
base class _BrokenPreferences extends InMemorySharedPreferencesAsync {
|
||||
_BrokenPreferences() : super.empty();
|
||||
|
||||
@override
|
||||
Future<String?> getString(String key, SharedPreferencesOptions options) =>
|
||||
Future<String?>.error(StateError('preferences unavailable'));
|
||||
|
||||
@override
|
||||
Future<bool> setString(
|
||||
String key,
|
||||
String value,
|
||||
SharedPreferencesOptions options,
|
||||
) => Future<bool>.error(StateError('preferences unavailable'));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const channel = MethodChannel('test/pip_channel');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
AndroidPictureInPicture pip() => AndroidPictureInPicture(channel: channel);
|
||||
|
||||
/// Simulates a native call arriving on the channel.
|
||||
Future<void> sendToDart(String method, Object? arguments) => messenger
|
||||
.handlePlatformMessage(
|
||||
channel.name,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
MethodCall(method, arguments),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
|
||||
tearDown(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
channel.setMethodCallHandler(null);
|
||||
});
|
||||
|
||||
group('platform support', () {
|
||||
test('should_support_only_android_when_target_is_checked', () {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
expect(pip().isPlatformSupported, isTrue);
|
||||
|
||||
for (final platform in <TargetPlatform>[
|
||||
TargetPlatform.iOS,
|
||||
TargetPlatform.macOS,
|
||||
TargetPlatform.linux,
|
||||
TargetPlatform.windows,
|
||||
TargetPlatform.fuchsia,
|
||||
]) {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
expect(
|
||||
pip().isPlatformSupported,
|
||||
isFalse,
|
||||
reason: '$platform must not claim picture in picture support',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('enter', () {
|
||||
test('should_refuse_without_touching_channel_when_target_is_not_android', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return true;
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
|
||||
expect(await pip().enter(), isFalse);
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
|
||||
test('should_count_as_refused_when_native_answer_is_missing', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async => null);
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
|
||||
expect(await pip().enter(), isFalse);
|
||||
});
|
||||
|
||||
test('should_report_entered_when_native_call_succeeds', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return true;
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
|
||||
expect(await pip().enter(), isTrue);
|
||||
expect(calls.single.method, 'enter');
|
||||
expect(calls.single.arguments, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('state listener', () {
|
||||
test('should_deliver_pip_state_changes_in_order_when_native_events_arrive', () async {
|
||||
final received = <bool>[];
|
||||
pip().setStateListener(received.add);
|
||||
|
||||
await sendToDart('onPipChanged', true);
|
||||
await sendToDart('onPipChanged', false);
|
||||
|
||||
expect(received, [true, false]);
|
||||
});
|
||||
|
||||
test('should_treat_only_literal_true_as_enabled_when_native_value_arrives', () async {
|
||||
final received = <bool>[];
|
||||
pip().setStateListener(received.add);
|
||||
|
||||
await sendToDart('onPipChanged', null);
|
||||
await sendToDart('onPipChanged', 'yes');
|
||||
await sendToDart('onPipChanged', 1);
|
||||
|
||||
expect(received, [false, false, false]);
|
||||
});
|
||||
|
||||
test('should_ignore_unrelated_native_methods_when_events_arrive', () async {
|
||||
final received = <bool>[];
|
||||
pip().setStateListener(received.add);
|
||||
|
||||
await sendToDart('somethingElse', true);
|
||||
await sendToDart('onPipChanged', true);
|
||||
|
||||
expect(received, [true]);
|
||||
});
|
||||
|
||||
test('should_silence_later_callbacks_when_listener_is_cleared', () async {
|
||||
final received = <bool>[];
|
||||
final observer = pip()..setStateListener(received.add);
|
||||
await sendToDart('onPipChanged', true);
|
||||
|
||||
observer.setStateListener(null);
|
||||
await sendToDart('onPipChanged', true);
|
||||
|
||||
expect(received, [true]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
/// 网络层换 dio 之后,把「对外契约」逐条钉住:解包、错误码映射、请求头、查询参数、跳过重试。
|
||||
void main() {
|
||||
String envelope(Object? data, {int code = 0, String message = 'ok'}) {
|
||||
return jsonEncode({'code': code, 'message': message, 'data': data});
|
||||
}
|
||||
|
||||
test('should_return_data_field_when_envelope_code_is_zero', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(envelope({'id': 7})),
|
||||
);
|
||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
|
||||
|
||||
final data = await client.get('/app/dramas/7');
|
||||
expect((data as Map)['id'], 7);
|
||||
});
|
||||
|
||||
test('should_throw_api_exception_with_server_message_when_envelope_code_is_not_zero', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
envelope(null, code: 40001, message: '邮箱格式不正确'),
|
||||
),
|
||||
);
|
||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
|
||||
|
||||
await expectLater(
|
||||
client.post('/app/auth/register', body: {'target': 'bad'}),
|
||||
throwsA(
|
||||
isA<ApiException>()
|
||||
.having((e) => e.code, 'code', 40001)
|
||||
.having((e) => e.message, 'message', '邮箱格式不正确'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('should_map_to_api_exception_with_http_status_when_body_has_no_envelope', () async {
|
||||
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse('oops', 502));
|
||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
|
||||
|
||||
await expectLater(
|
||||
client.get('/app/home/feed'),
|
||||
throwsA(
|
||||
isA<ApiException>().having((e) => e.code, 'code', 502),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('should_send_bearer_token_and_device_header_when_both_are_available', () async {
|
||||
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null)));
|
||||
final client = ApiClient(
|
||||
baseUrl: 'http://127.0.0.1:8081',
|
||||
adapter: adapter,
|
||||
accessTokenReader: () => 'token-1',
|
||||
deviceIdReader: () async => 'device-9',
|
||||
);
|
||||
|
||||
await client.get('/app/me');
|
||||
|
||||
final request = adapter.requests.single;
|
||||
expect(request.headers['Authorization'], 'Bearer token-1');
|
||||
expect(request.headers['X-Device-Id'], 'device-9');
|
||||
expect(request.headers['Accept'], 'application/json');
|
||||
});
|
||||
|
||||
test('should_put_query_map_into_request_uri_when_query_is_given', () async {
|
||||
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null)));
|
||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
|
||||
|
||||
await client.get('/app/search', query: {'keyword': '短剧', 'page': '2'});
|
||||
|
||||
final uri = adapter.requests.single.uri;
|
||||
expect(uri.path, '/app/search');
|
||||
expect(uri.queryParameters['keyword'], '短剧');
|
||||
expect(uri.queryParameters['page'], '2');
|
||||
});
|
||||
|
||||
test('should_skip_refresh_when_call_opts_out_of_auth_retry', () async {
|
||||
var refreshed = false;
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(envelope(null, code: 401, message: '登录已过期')),
|
||||
);
|
||||
final client = ApiClient(
|
||||
baseUrl: 'http://127.0.0.1:8081',
|
||||
adapter: adapter,
|
||||
tokenRefresher: () async {
|
||||
refreshed = true;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
client.post('/app/auth/refresh', body: {'refreshToken': 'r1'}, skipAuthRetry: true),
|
||||
throwsA(isA<ApiException>().having((e) => e.code, 'code', 401)),
|
||||
);
|
||||
expect(refreshed, isFalse);
|
||||
expect(adapter.requests, hasLength(1));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/auth_session.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/json_numbers.dart';
|
||||
import 'package:fruitshorts/core/data/models/play_ticket.dart';
|
||||
|
||||
/// 后端契约(FruitServer/docs/api-phase1.md §0):`Long`/`long` 字段以字符串下发。
|
||||
/// 这里钉住客户端在"收到字符串形式的 id / 计数"时照样能解析,不抛异常。
|
||||
void main() {
|
||||
group('jsonToInt', () {
|
||||
test('should_parse_int_when_value_is_number', () {
|
||||
expect(jsonToInt(12345), 12345);
|
||||
expect(jsonToInt(12.9), 12);
|
||||
});
|
||||
|
||||
test('should_parse_int_when_value_is_numeric_string', () {
|
||||
expect(jsonToInt('2111012753262120962'), 2111012753262120962);
|
||||
expect(jsonToInt(' 42 '), 42);
|
||||
});
|
||||
|
||||
test('should_fall_back_when_value_is_missing_or_invalid', () {
|
||||
expect(jsonToInt(null), 0);
|
||||
expect(jsonToInt(''), 0);
|
||||
expect(jsonToInt('abc'), 0);
|
||||
expect(jsonToInt(null, fallback: 7), 7);
|
||||
});
|
||||
});
|
||||
|
||||
group('drama model', () {
|
||||
test('should_parse_string_ids_when_backend_sends_ids_as_string', () {
|
||||
final drama = Drama.fromCardJson({
|
||||
'id': '2111012753262120962',
|
||||
'title': '闪婚后,傅先生每天都在撒糖',
|
||||
'totalEpisodes': 72,
|
||||
'viewCount': '3100000',
|
||||
'isHot': true,
|
||||
});
|
||||
|
||||
expect(drama.id, '2111012753262120962');
|
||||
expect(drama.title, '闪婚后,傅先生每天都在撒糖');
|
||||
expect(drama.totalEpisodes, 72);
|
||||
// viewCount 后端是 Long → 字符串
|
||||
expect(drama.viewCount, 3100000);
|
||||
});
|
||||
|
||||
test('should_parse_view_count_when_backend_sends_number', () {
|
||||
final drama = Drama.fromCardJson({'id': 5, 'title': 'T', 'viewCount': 1200});
|
||||
|
||||
expect(drama.id, '5');
|
||||
expect(drama.viewCount, 1200);
|
||||
});
|
||||
|
||||
test('should_parse_detail_payload_when_ids_are_strings', () {
|
||||
final drama = Drama.fromDetailJson({
|
||||
'id': '2111012753262120962',
|
||||
'title': 'T',
|
||||
'totalEpisodes': 12,
|
||||
'freeEpisodes': 3,
|
||||
'viewCount': '99',
|
||||
'episodes': [
|
||||
{'id': '2111012753262120963', 'number': 1, 'title': 'E1', 'isFree': true},
|
||||
],
|
||||
});
|
||||
|
||||
expect(drama.id, '2111012753262120962');
|
||||
expect(drama.freeEpisodes, 3);
|
||||
expect(drama.viewCount, 99);
|
||||
expect(drama.episodes.first.id, '2111012753262120963');
|
||||
expect(drama.episodes.first.number, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('auth session', () {
|
||||
test('should_parse_expires_in_when_backend_sends_string', () {
|
||||
final session = AuthSession.fromJson({
|
||||
'accessToken': 'a',
|
||||
'refreshToken': 'r',
|
||||
'expiresIn': '7200',
|
||||
'user': {'uid': 'u-1', 'nickname': 'n'},
|
||||
});
|
||||
|
||||
expect(session.expiresIn, 7200);
|
||||
expect(session.user.uid, 'u-1');
|
||||
});
|
||||
});
|
||||
|
||||
group('play ticket', () {
|
||||
test('should_parse_episode_id_when_backend_sends_string', () {
|
||||
final ticket = PlayTicket.fromJson({
|
||||
'episodeId': '2111012753262120963',
|
||||
'accessible': false,
|
||||
'accessType': PlayTicket.coinRequired,
|
||||
'unlockCoin': 30,
|
||||
});
|
||||
|
||||
expect(ticket.episodeId, '2111012753262120963');
|
||||
expect(ticket.unlockCoin, 30);
|
||||
expect(ticket.needsUnlock, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('should_turn_cube_and_keep_title_on_poster_when_carousel_pages', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: CoverCarousel(
|
||||
items: [
|
||||
for (final title in ['The Signal', 'City Lights', 'Vengeance'])
|
||||
CoverItem(
|
||||
title: title,
|
||||
subtitle: 'Mock',
|
||||
image: '/files/poster.png',
|
||||
onOpen: _noop,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
expect(find.text('Mock'), findsOneWidget);
|
||||
|
||||
final box = tester.getRect(find.byType(CoverCarousel));
|
||||
final gesture = await tester.startGesture(Offset(box.right - 40, box.center.dy));
|
||||
await gesture.moveBy(const Offset(-500, 0));
|
||||
await tester.pump();
|
||||
|
||||
final turned = tester.widgetList<Transform>(find.byType(Transform)).any(
|
||||
(transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15,
|
||||
);
|
||||
expect(turned, isTrue);
|
||||
|
||||
await gesture.moveBy(const Offset(-200, 0));
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 450));
|
||||
|
||||
expect(find.text('City Lights'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
void _noop() {}
|
||||
@@ -0,0 +1,344 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/discover/discover_logic.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
|
||||
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/poster.png',
|
||||
episodes: [
|
||||
for (final number in numbers)
|
||||
Episode(
|
||||
id: '$id-$number',
|
||||
number: number,
|
||||
title: 'Episode $number',
|
||||
videoUrl: 'https://example.test/clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('should_resolve_resume_position_of_episode_one_when_constructed', () async {
|
||||
final history = FakeWatchHistory()..saved = const Duration(seconds: 4);
|
||||
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
|
||||
|
||||
expect(await logic.position, const Duration(seconds: 4));
|
||||
expect(history.reads, ['signal']);
|
||||
expect(logic.category, 0);
|
||||
expect(logic.searchOpen, isFalse);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_reread_resume_position_when_drama_is_swapped', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
|
||||
|
||||
logic.drama = _drama('city');
|
||||
logic.refreshPosition();
|
||||
await logic.position;
|
||||
|
||||
expect(history.reads, ['signal', 'city']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_resume_from_last_watch_when_list_card_has_no_episodes', () async {
|
||||
final history = FakeWatchHistory()
|
||||
..saved = const Duration(seconds: 9)
|
||||
..lastEpisode = 'signal-2';
|
||||
final logic = DiscoverLogic(
|
||||
history: history,
|
||||
drama: _drama('signal', numbers: const []),
|
||||
);
|
||||
|
||||
expect(await logic.position, const Duration(seconds: 9));
|
||||
expect(history.reads, ['signal']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_have_no_resume_position_when_server_has_no_record', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = DiscoverLogic(
|
||||
history: history,
|
||||
drama: _drama('signal', numbers: const []),
|
||||
);
|
||||
|
||||
expect(await logic.position, isNull);
|
||||
// 续看位置改为按剧问服务端:即使卡片没有剧集也会读一次
|
||||
expect(history.reads, ['signal']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_store_index_and_notify_once_when_category_is_selected', () {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.selectCategory(3);
|
||||
|
||||
expect(logic.category, 3);
|
||||
expect(notifications, 1);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_show_search_when_flag_is_on_or_text_is_typed', () {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
|
||||
expect(logic.showSearch, isFalse);
|
||||
|
||||
logic.search.text = 'vengeance';
|
||||
logic.onSearchChanged();
|
||||
expect(logic.showSearch, isTrue);
|
||||
|
||||
logic.search.clear();
|
||||
expect(logic.showSearch, isFalse);
|
||||
|
||||
logic.searchOpen = true;
|
||||
expect(logic.showSearch, isTrue);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_match_by_id_and_tolerate_missing_id_when_looking_up_drama', () {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
final catalog = [_drama('signal'), _drama('city')];
|
||||
|
||||
expect(logic.dramaFor('city', catalog)?.id, 'city');
|
||||
expect(logic.dramaFor('missing', catalog), isNull);
|
||||
expect(logic.dramaFor(null, catalog), isNull);
|
||||
expect(logic.dramaFor('city', const []), isNull);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_adopt_drama_only_when_page_is_active', () {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
|
||||
|
||||
void sync(
|
||||
Drama next, {
|
||||
required bool active,
|
||||
required bool wasActive,
|
||||
required bool dramaChanged,
|
||||
int searchRequest = 0,
|
||||
int previousSearchRequest = 0,
|
||||
}) => logic.syncDrama(
|
||||
active: active,
|
||||
wasActive: wasActive,
|
||||
nextDrama: next,
|
||||
dramaChanged: dramaChanged,
|
||||
searchRequest: searchRequest,
|
||||
previousSearchRequest: previousSearchRequest,
|
||||
isActive: () => active,
|
||||
);
|
||||
|
||||
// An inactive page keeps showing its own drama.
|
||||
sync(_drama('city'), active: false, wasActive: true, dramaChanged: true);
|
||||
expect(logic.drama.id, 'signal');
|
||||
|
||||
// Becoming active adopts the incoming drama.
|
||||
sync(_drama('city'), active: true, wasActive: false, dramaChanged: false);
|
||||
expect(logic.drama.id, 'city');
|
||||
|
||||
// Staying active with a changed drama re-reads history.
|
||||
history.reads.clear();
|
||||
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true);
|
||||
expect(logic.drama.id, 'vengeance');
|
||||
expect(history.reads, ['vengeance']);
|
||||
|
||||
// Staying active with no change leaves both drama and history alone.
|
||||
history.reads.clear();
|
||||
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
|
||||
expect(logic.drama.id, 'vengeance');
|
||||
expect(history.reads, isEmpty);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('should_open_and_focus_field_when_search_is_requested_and_page_is_active', (
|
||||
tester,
|
||||
) async {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: TextField(
|
||||
controller: logic.search,
|
||||
focusNode: logic.searchFocus,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
logic.syncDrama(
|
||||
active: true,
|
||||
wasActive: true,
|
||||
nextDrama: logic.drama,
|
||||
dramaChanged: false,
|
||||
searchRequest: 1,
|
||||
previousSearchRequest: 0,
|
||||
isActive: () => true,
|
||||
);
|
||||
|
||||
expect(logic.searchOpen, isTrue);
|
||||
await tester.pumpAndSettle();
|
||||
expect(logic.searchFocus.hasFocus, isTrue);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('should_keep_search_field_closed_when_page_is_hidden', (
|
||||
tester,
|
||||
) async {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: TextField(
|
||||
controller: logic.search,
|
||||
focusNode: logic.searchFocus,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// The search request branch is gated on `active`, so a page that is not on
|
||||
// screen must neither reveal the field nor pull focus away from the visible tab.
|
||||
logic.syncDrama(
|
||||
active: false,
|
||||
wasActive: false,
|
||||
nextDrama: logic.drama,
|
||||
dramaChanged: false,
|
||||
searchRequest: 1,
|
||||
previousSearchRequest: 0,
|
||||
isActive: () => false,
|
||||
);
|
||||
|
||||
expect(logic.searchOpen, isFalse);
|
||||
await tester.pumpAndSettle();
|
||||
expect(logic.searchFocus.hasFocus, isFalse);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', (
|
||||
tester,
|
||||
) async {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
final forwarded = <DiscoverPageAction>[];
|
||||
final popped = Completer<DiscoverPageAction?>();
|
||||
late BuildContext pageContext;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => Navigator.of(context)
|
||||
.push<DiscoverPageAction>(
|
||||
MaterialPageRoute<DiscoverPageAction>(
|
||||
builder: (inner) {
|
||||
pageContext = inner;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
)
|
||||
.then(popped.complete),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
logic.act(
|
||||
pageContext,
|
||||
DiscoverPageAction.home,
|
||||
embedded: true,
|
||||
onAction: forwarded.add,
|
||||
);
|
||||
expect(forwarded, [DiscoverPageAction.home]);
|
||||
expect(Navigator.of(pageContext).canPop(), isTrue);
|
||||
|
||||
logic.act(pageContext, DiscoverPageAction.watchlist, embedded: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await expectLater(popped.future, completion(DiscoverPageAction.watchlist));
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('should_show_artwork_notice_when_card_has_no_playable_media', (
|
||||
tester,
|
||||
) async {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
late BuildContext context;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (inner) {
|
||||
context = inner;
|
||||
return const Scaffold(body: SizedBox.shrink());
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
logic.openCard(context, null, embedded: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
expect(find.text(l10n.artworkOnlyNotice), findsOneWidget);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('should_forward_chosen_drama_when_card_is_embedded', (tester) async {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
late BuildContext context;
|
||||
Drama? selected;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (inner) {
|
||||
context = inner;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
logic.openCard(
|
||||
context,
|
||||
_drama('city'),
|
||||
embedded: true,
|
||||
onDramaSelected: (drama) => selected = drama,
|
||||
);
|
||||
|
||||
expect(selected?.id, 'city');
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_do_nothing_when_notifying_after_dispose', () {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.dispose();
|
||||
logic.notify();
|
||||
|
||||
expect(notifications, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
||||
import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
|
||||
/// 详情页逻辑依赖:票据仓库 + 收藏控制器(测试里都是内存实现)。
|
||||
DramaDetailLogic _logic(Drama drama, FakeWatchHistory history) =>
|
||||
DramaDetailLogic(
|
||||
drama: drama,
|
||||
history: history,
|
||||
playback: FakePlaybackRepository(),
|
||||
favorites: FavoriteController(InMemoryFavoriteRepository()),
|
||||
);
|
||||
|
||||
Drama _card(String id) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/$id.jpg',
|
||||
totalEpisodes: 3,
|
||||
episodes: [
|
||||
Episode(
|
||||
id: '$id-1',
|
||||
number: 1,
|
||||
title: '第 1 集',
|
||||
isFree: true,
|
||||
videoUrl: '/files/$id-1.mp4',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Drama _detail(String id) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/$id.jpg',
|
||||
totalEpisodes: 3,
|
||||
detailLoaded: true,
|
||||
episodes: [
|
||||
for (var number = 1; number <= 3; number++)
|
||||
Episode(
|
||||
id: '$id-$number',
|
||||
number: number,
|
||||
title: '第 $number 集',
|
||||
isFree: true,
|
||||
videoUrl: '/files/$id-$number.mp4',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('should_upgrade_to_full_episode_list_when_detail_loads_for_list_card', () async {
|
||||
var calls = 0;
|
||||
final logic = _logic(_card('signal'), FakeWatchHistory());
|
||||
|
||||
await logic.load(() async {
|
||||
calls++;
|
||||
return _detail('signal');
|
||||
});
|
||||
|
||||
expect(calls, 1);
|
||||
expect(logic.loading, isFalse);
|
||||
expect(logic.failed, isFalse);
|
||||
expect(logic.drama.episodes, hasLength(3));
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_keep_card_and_report_reason_when_load_fails', () async {
|
||||
final logic = _logic(_card('signal'), FakeWatchHistory());
|
||||
|
||||
await logic.load(() async => throw const ApiException(500, '服务异常'));
|
||||
|
||||
expect(logic.failed, isTrue);
|
||||
expect(logic.failureMessage, '服务异常');
|
||||
expect(logic.drama.episodes, hasLength(1));
|
||||
|
||||
await logic.load(() async => throw StateError('offline'));
|
||||
expect(logic.failed, isTrue);
|
||||
expect(logic.failureMessage, isNull);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_skip_request_when_detail_is_already_loaded', () async {
|
||||
var calls = 0;
|
||||
final logic = _logic(_detail('signal'), FakeWatchHistory());
|
||||
|
||||
await logic.load(() async {
|
||||
calls++;
|
||||
return _detail('signal');
|
||||
});
|
||||
|
||||
expect(calls, 0);
|
||||
expect(logic.drama.episodes, hasLength(3));
|
||||
logic.dispose();
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/services/engagement_controller.dart';
|
||||
import 'package:nontonki/core/data/repositories/engagement_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/engagement_controller.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/engagement_repository.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
void main() {
|
||||
test('saved, liked and comments are kept by drama id', () async {
|
||||
test('should_keep_saved_liked_and_comments_by_drama_id_when_toggled', () async {
|
||||
final repository = InMemoryEngagementRepository();
|
||||
final first = EngagementController(repository);
|
||||
await first.load();
|
||||
@@ -28,7 +28,7 @@ void main() {
|
||||
expect(reopened.isSaved('signal'), isFalse);
|
||||
});
|
||||
|
||||
test('local engagement survives a new repository instance', () async {
|
||||
test('should_survive_new_repository_instance_when_engagement_is_local', () async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final first = EngagementController(SharedPreferencesEngagementRepository());
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/engagement_snapshot.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/engagement_repository.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
/// Mirrors the private key used by [SharedPreferencesEngagementRepository].
|
||||
const _key = 'engagement.v1.demo-local-v1';
|
||||
|
||||
void main() {
|
||||
late SharedPreferencesAsync preferences;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
preferences = SharedPreferencesAsync();
|
||||
});
|
||||
|
||||
SharedPreferencesEngagementRepository repository() =>
|
||||
SharedPreferencesEngagementRepository(preferences: preferences);
|
||||
|
||||
Future<EngagementSnapshot> loadRaw(String raw) async {
|
||||
await preferences.setString(_key, raw);
|
||||
return repository().load();
|
||||
}
|
||||
|
||||
test('should_read_empty_snapshot_when_first_launch', () async {
|
||||
final snapshot = await repository().load();
|
||||
|
||||
expect(snapshot.savedDramaIds, isEmpty);
|
||||
expect(snapshot.likedDramaIds, isEmpty);
|
||||
expect(snapshot.commentsByDrama, isEmpty);
|
||||
});
|
||||
|
||||
test('should_survive_save_and_reload_round_trip_when_snapshot_is_stored', () async {
|
||||
await repository().save(
|
||||
const EngagementSnapshot(
|
||||
savedDramaIds: {'signal', 'city'},
|
||||
likedDramaIds: {'city'},
|
||||
commentsByDrama: {
|
||||
'signal': ['first note', 'second note'],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final loaded = await repository().load();
|
||||
|
||||
expect(loaded.savedDramaIds, {'signal', 'city'});
|
||||
expect(loaded.likedDramaIds, {'city'});
|
||||
expect(loaded.commentsByDrama, {
|
||||
'signal': ['first note', 'second note'],
|
||||
});
|
||||
});
|
||||
|
||||
test('should_round_trip_as_empty_collections_when_snapshot_is_empty', () async {
|
||||
await repository().save(const EngagementSnapshot());
|
||||
|
||||
final loaded = await repository().load();
|
||||
|
||||
expect(loaded.savedDramaIds, isEmpty);
|
||||
expect(loaded.likedDramaIds, isEmpty);
|
||||
expect(loaded.commentsByDrama, isEmpty);
|
||||
});
|
||||
|
||||
test('should_fall_back_to_empty_snapshot_when_payload_is_corrupt_or_not_an_object', () async {
|
||||
for (final raw in <String>[
|
||||
'',
|
||||
'not json at all',
|
||||
'[]',
|
||||
'"a bare string"',
|
||||
'42',
|
||||
'null',
|
||||
]) {
|
||||
final snapshot = await loadRaw(raw);
|
||||
|
||||
expect(snapshot.savedDramaIds, isEmpty, reason: raw);
|
||||
expect(snapshot.likedDramaIds, isEmpty, reason: raw);
|
||||
expect(snapshot.commentsByDrama, isEmpty, reason: raw);
|
||||
}
|
||||
});
|
||||
|
||||
test('should_drop_mistyped_fields_when_payload_has_wrong_types', () async {
|
||||
final snapshot = await loadRaw(
|
||||
jsonEncode({
|
||||
'saved': 'signal',
|
||||
'liked': {'city': true},
|
||||
'comments': {
|
||||
'signal': 'a plain string',
|
||||
'city': ['kept', 7, null, true],
|
||||
'town': <Object>[],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(snapshot.savedDramaIds, isEmpty);
|
||||
expect(snapshot.likedDramaIds, isEmpty);
|
||||
expect(snapshot.commentsByDrama, {
|
||||
'city': ['kept'],
|
||||
'town': <String>[],
|
||||
});
|
||||
});
|
||||
|
||||
test('should_fall_back_per_field_when_payload_keys_are_missing', () async {
|
||||
final snapshot = await loadRaw(jsonEncode({'saved': ['signal']}));
|
||||
|
||||
expect(snapshot.savedDramaIds, {'signal'});
|
||||
expect(snapshot.likedDramaIds, isEmpty);
|
||||
expect(snapshot.commentsByDrama, isEmpty);
|
||||
});
|
||||
|
||||
test('should_write_only_json_primitives_when_saving', () async {
|
||||
await repository().save(
|
||||
const EngagementSnapshot(
|
||||
savedDramaIds: {'signal'},
|
||||
likedDramaIds: {'city'},
|
||||
commentsByDrama: {
|
||||
'signal': ['note'],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final raw = await preferences.getString(_key);
|
||||
expect(raw, isNotNull);
|
||||
|
||||
final decoded = jsonDecode(raw!) as Map<String, dynamic>;
|
||||
expect(decoded['saved'], ['signal']);
|
||||
expect(decoded['liked'], ['city']);
|
||||
expect(decoded['comments'], {
|
||||
'signal': ['note'],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/formatting/localized_content.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations_en.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations_zh.dart';
|
||||
|
||||
Drama _drama({required String id, String title = 'raw', String synopsis = 'raw'}) =>
|
||||
Drama(
|
||||
id: id,
|
||||
title: title,
|
||||
synopsis: synopsis,
|
||||
posterUrl: '/files/poster.png',
|
||||
episodes: const [],
|
||||
);
|
||||
|
||||
void main() {
|
||||
final en = AppLocalizationsEn();
|
||||
final zh = AppLocalizationsZh();
|
||||
|
||||
test('should_map_each_seeded_drama_to_its_own_localized_copy_when_looked_up', () {
|
||||
final signal = _drama(id: 'the-signal-demo');
|
||||
final city = _drama(id: 'city-lights-demo');
|
||||
final vengeance = _drama(id: 'vengeance-demo');
|
||||
|
||||
expect(localizedDramaTitle(en, signal), en.dramaSignalTitle);
|
||||
expect(localizedDramaTitle(en, city), en.dramaCityTitle);
|
||||
expect(localizedDramaTitle(en, vengeance), en.dramaVengeanceTitle);
|
||||
|
||||
expect(localizedDramaSynopsis(en, signal), en.dramaSignalSynopsis);
|
||||
expect(localizedDramaSynopsis(en, city), en.dramaCitySynopsis);
|
||||
expect(localizedDramaSynopsis(en, vengeance), en.dramaVengeanceSynopsis);
|
||||
});
|
||||
|
||||
test('should_keep_two_dramas_apart_when_localized_copy_is_mapped', () {
|
||||
final titles = {
|
||||
localizedDramaTitle(en, _drama(id: 'the-signal-demo')),
|
||||
localizedDramaTitle(en, _drama(id: 'city-lights-demo')),
|
||||
localizedDramaTitle(en, _drama(id: 'vengeance-demo')),
|
||||
};
|
||||
final synopses = {
|
||||
localizedDramaSynopsis(en, _drama(id: 'the-signal-demo')),
|
||||
localizedDramaSynopsis(en, _drama(id: 'city-lights-demo')),
|
||||
localizedDramaSynopsis(en, _drama(id: 'vengeance-demo')),
|
||||
};
|
||||
|
||||
expect(titles, hasLength(3));
|
||||
expect(synopses, hasLength(3));
|
||||
});
|
||||
|
||||
test('should_translate_localized_copy_when_content_is_looked_up', () {
|
||||
final signal = _drama(id: 'the-signal-demo');
|
||||
|
||||
expect(localizedDramaTitle(zh, signal), zh.dramaSignalTitle);
|
||||
expect(localizedDramaSynopsis(zh, signal), zh.dramaSignalSynopsis);
|
||||
expect(localizedDramaTitle(zh, signal), isNot(en.dramaSignalTitle));
|
||||
});
|
||||
|
||||
test('should_keep_catalog_text_in_every_locale_when_drama_is_unknown', () {
|
||||
final custom = _drama(
|
||||
id: 'custom-demo',
|
||||
title: 'Custom Title',
|
||||
synopsis: 'Custom synopsis',
|
||||
);
|
||||
|
||||
expect(localizedDramaTitle(en, custom), 'Custom Title');
|
||||
expect(localizedDramaTitle(zh, custom), 'Custom Title');
|
||||
expect(localizedDramaSynopsis(en, custom), 'Custom synopsis');
|
||||
expect(localizedDramaSynopsis(zh, custom), 'Custom synopsis');
|
||||
});
|
||||
|
||||
test('should_ignore_raw_catalog_text_when_drama_is_seeded', () {
|
||||
final signal = _drama(
|
||||
id: 'the-signal-demo',
|
||||
title: 'stale title',
|
||||
synopsis: 'stale synopsis',
|
||||
);
|
||||
|
||||
expect(localizedDramaTitle(en, signal), isNot('stale title'));
|
||||
expect(localizedDramaSynopsis(en, signal), isNot('stale synopsis'));
|
||||
});
|
||||
|
||||
test('should_use_named_titles_when_episode_number_is_one_to_four', () {
|
||||
expect(localizedEpisodeTitle(en, 1), en.episodeOneTitle);
|
||||
expect(localizedEpisodeTitle(en, 2), en.episodeTwoTitle);
|
||||
expect(localizedEpisodeTitle(en, 3), en.episodeThreeTitle);
|
||||
expect(localizedEpisodeTitle(en, 4), en.episodeFourTitle);
|
||||
});
|
||||
|
||||
test('should_fall_back_to_numbered_label_when_episode_number_is_later', () {
|
||||
for (final number in <int>[5, 12, 100]) {
|
||||
expect(localizedEpisodeTitle(en, number), en.episodeLabel(number));
|
||||
expect(localizedEpisodeTitle(en, number), contains('$number'));
|
||||
}
|
||||
expect(localizedEpisodeTitle(zh, 12), zh.episodeLabel(12));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/pages/discover/discover_logic.dart';
|
||||
import 'package:fruitshorts/pages/home/home_logic.dart';
|
||||
import 'package:fruitshorts/pages/mine/mine_logic.dart';
|
||||
|
||||
Drama _drama(String id) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/poster.png',
|
||||
episodes: const [],
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final dramas = [_drama('signal'), _drama('city')];
|
||||
|
||||
test('should_make_first_catalog_entry_the_current_drama_when_catalog_loads', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
|
||||
expect(logic.currentDrama.id, 'signal');
|
||||
expect(logic.tab, 1);
|
||||
expect(logic.mySection, 0);
|
||||
expect(logic.drawerOpen, isFalse);
|
||||
expect(logic.profile, isFalse);
|
||||
expect(logic.videoImmersive, isFalse);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_not_build_shell_when_catalog_is_empty', () {
|
||||
expect(() => HomeLogic(const []), throwsStateError);
|
||||
});
|
||||
|
||||
test('should_not_raise_request_counter_when_only_tab_switches', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.select(1);
|
||||
|
||||
expect(logic.tab, 1);
|
||||
expect(logic.searchRequest, 0);
|
||||
expect(logic.sectionRequest, 0);
|
||||
expect(logic.resumeRequest, 0);
|
||||
expect(logic.backRequest, 0);
|
||||
expect(notifications, 1);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_raise_only_its_own_counter_when_each_select_flag_is_used', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
|
||||
logic.select(1, search: true);
|
||||
expect(logic.tab, 1);
|
||||
expect(logic.searchRequest, 1);
|
||||
expect(logic.sectionRequest, 0);
|
||||
|
||||
logic.select(2, mySection: 0);
|
||||
expect(logic.mySection, 0);
|
||||
expect(logic.sectionRequest, 1);
|
||||
expect(logic.searchRequest, 1);
|
||||
|
||||
logic.select(0, resume: true);
|
||||
expect(logic.resumeRequest, 1);
|
||||
expect(logic.sectionRequest, 1);
|
||||
|
||||
expect(logic.backRequest, 0);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_default_to_second_section_when_watchlist_shortcut_is_used', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
|
||||
logic.select(2, watchlist: true);
|
||||
|
||||
expect(logic.tab, 2);
|
||||
expect(logic.mySection, 1);
|
||||
expect(logic.sectionRequest, 1);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
|
||||
logic.openProfile();
|
||||
expect(logic.profile, isTrue);
|
||||
expect(logic.tab, 2);
|
||||
expect(logic.mySection, 0);
|
||||
expect(logic.sectionRequest, 1);
|
||||
|
||||
logic.openLibrary(section: 1);
|
||||
expect(logic.profile, isFalse);
|
||||
expect(logic.mySection, 1);
|
||||
expect(logic.sectionRequest, 2);
|
||||
|
||||
logic.openLibrary();
|
||||
expect(logic.profile, isFalse);
|
||||
expect(logic.mySection, 1);
|
||||
expect(logic.sectionRequest, 3);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_return_home_and_open_drawer_when_system_back_is_pressed', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
|
||||
logic.onSystemBack();
|
||||
expect(logic.drawerOpen, isTrue);
|
||||
expect(logic.tab, 1);
|
||||
expect(logic.backRequest, 0);
|
||||
logic.onDrawerChanged(false);
|
||||
|
||||
logic.select(0);
|
||||
logic.onSystemBack();
|
||||
expect(logic.tab, 1);
|
||||
expect(logic.backRequest, 0);
|
||||
|
||||
logic.select(0);
|
||||
logic.onImmersiveChanged(true);
|
||||
logic.onSystemBack();
|
||||
expect(logic.backRequest, 1);
|
||||
expect(logic.tab, 0);
|
||||
|
||||
logic.onDrawerChanged(true);
|
||||
logic.onSystemBack();
|
||||
expect(logic.backRequest, 1);
|
||||
|
||||
logic.onDrawerChanged(false);
|
||||
logic.onImmersiveChanged(false);
|
||||
logic.onSystemBack();
|
||||
expect(logic.tab, 1);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_notify_only_on_actual_change_when_drawer_state_is_set', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.onDrawerChanged(false);
|
||||
expect(notifications, 0);
|
||||
|
||||
logic.onDrawerChanged(true);
|
||||
expect(logic.drawerOpen, isTrue);
|
||||
expect(notifications, 1);
|
||||
|
||||
logic.onDrawerChanged(true);
|
||||
expect(notifications, 1);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_update_state_when_drama_and_immersive_callbacks_fire', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
|
||||
logic.onDramaChanged(dramas[1]);
|
||||
expect(logic.currentDrama.id, 'city');
|
||||
|
||||
logic.onImmersiveChanged(true);
|
||||
expect(logic.videoImmersive, isTrue);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_map_onto_root_tabs_when_my_page_actions_fire', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
|
||||
logic.myAction(MyPageAction.discover);
|
||||
expect(logic.tab, 1);
|
||||
expect(logic.searchRequest, 1);
|
||||
|
||||
logic.myAction(MyPageAction.resume);
|
||||
expect(logic.tab, 0);
|
||||
expect(logic.resumeRequest, 1);
|
||||
|
||||
logic.myAction(MyPageAction.home);
|
||||
expect(logic.tab, 0);
|
||||
expect(logic.resumeRequest, 1);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_do_nothing_when_notifying_after_dispose', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.dispose();
|
||||
logic.notify();
|
||||
|
||||
expect(notifications, 0);
|
||||
});
|
||||
|
||||
testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (tester) async {
|
||||
final logic = HomeLogic(dramas);
|
||||
late BuildContext context;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (inner) {
|
||||
context = inner;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
logic.discoverAction(context, DiscoverPageAction.watchlist);
|
||||
expect(logic.tab, 2);
|
||||
expect(logic.profile, isFalse);
|
||||
expect(logic.mySection, 1);
|
||||
expect(logic.sectionRequest, 1);
|
||||
|
||||
logic.discoverAction(context, DiscoverPageAction.my);
|
||||
expect(logic.tab, 2);
|
||||
expect(logic.profile, isFalse);
|
||||
|
||||
logic.discoverAction(context, DiscoverPageAction.resume);
|
||||
expect(logic.tab, 0);
|
||||
expect(logic.resumeRequest, 1);
|
||||
|
||||
logic.discoverAction(context, DiscoverPageAction.home);
|
||||
expect(logic.tab, 0);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
logic.dispose();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||
|
||||
/// 联调测试:直连本机客户端接口服务(8081)验证 **POST + JSON body + 错误码映射**。
|
||||
///
|
||||
/// 特意用"不存在的邮箱"登录:既能走完整的 POST 链路,又不会在库里留下测试数据。
|
||||
/// 服务没启动时自动跳过,不影响日常 `flutter test`。
|
||||
void main() {
|
||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081');
|
||||
final api = AuthApi(client);
|
||||
|
||||
tearDownAll(client.close);
|
||||
|
||||
test('should_surface_business_error_when_sign_in_uses_unknown_email', () async {
|
||||
try {
|
||||
await api.login(
|
||||
email: 'no-such-user-for-dio-check@example.com',
|
||||
password: 'abcd1234',
|
||||
deviceId: 'dio-check-device',
|
||||
);
|
||||
fail('不存在的邮箱不应该登录成功');
|
||||
} on ApiException catch (error) {
|
||||
// 关键点:错误码来自响应体(不是 HTTP 状态码),说明统一解包生效
|
||||
expect(error.code, isNot(0));
|
||||
expect(error.message, isNotEmpty);
|
||||
} catch (_) {
|
||||
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
|
||||
import 'package:fruitshorts/core/data/remote/playback_api.dart';
|
||||
|
||||
/// 联调测试:直连本机客户端接口服务(8081)。
|
||||
/// 服务没启动时自动跳过,不影响日常 `flutter test`。
|
||||
void main() {
|
||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081');
|
||||
final api = CatalogApi(client);
|
||||
final playback = PlaybackApi(client);
|
||||
|
||||
tearDownAll(client.close);
|
||||
|
||||
Future<bool> serverUp() async {
|
||||
try {
|
||||
await api.homeFeed();
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test('should_parse_banner_and_sections_when_home_feed_is_fetched', () async {
|
||||
if (!await serverUp()) {
|
||||
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
|
||||
return;
|
||||
}
|
||||
final feed = await api.homeFeed();
|
||||
expect(feed.sections, isNotEmpty);
|
||||
expect(feed.dramas, isNotEmpty);
|
||||
final card = feed.dramas.first;
|
||||
expect(card.id, isNotEmpty);
|
||||
expect(card.title, isNotEmpty);
|
||||
expect(card.posterUrl, startsWith('/files/'));
|
||||
});
|
||||
|
||||
test('should_order_episodes_by_number_and_attach_url_when_detail_is_fetched', () async {
|
||||
if (!await serverUp()) {
|
||||
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
|
||||
return;
|
||||
}
|
||||
final feed = await api.homeFeed();
|
||||
final card = feed.dramas.first;
|
||||
final detail = await api.dramaDetail(card.id);
|
||||
|
||||
expect(detail.id, card.id);
|
||||
expect(detail.episodes, isNotEmpty);
|
||||
final numbers = detail.episodes.map((episode) => episode.number).toList();
|
||||
expect(numbers, orderedEquals([...numbers]..sort()));
|
||||
for (final episode in detail.episodes) {
|
||||
if (episode.locked) {
|
||||
expect(episode.videoUrl, isEmpty);
|
||||
} else {
|
||||
expect(episode.videoUrl, startsWith('/files/'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should_return_play_url_when_episode_is_playable', () async {
|
||||
if (!await serverUp()) {
|
||||
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
|
||||
return;
|
||||
}
|
||||
final feed = await api.homeFeed();
|
||||
final detail = await api.dramaDetail(feed.dramas.first.id);
|
||||
final playable = detail.episodes
|
||||
.where((episode) => episode.videoUrl.isNotEmpty)
|
||||
.toList();
|
||||
if (playable.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final ticket = await playback.ticket(playable.first.id);
|
||||
expect(ticket.accessible, isTrue);
|
||||
expect(ticket.playUrl, startsWith('/files/'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/auth_session.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
|
||||
import 'package:fruitshorts/core/data/remote/library_api.dart';
|
||||
import 'package:fruitshorts/core/data/remote/playback_api.dart';
|
||||
import 'package:fruitshorts/core/data/remote/watch_api.dart';
|
||||
|
||||
/// K1 端到端联调:直连本机客户端接口服务(8081),把"首页 → 详情 → 播放票据 →
|
||||
/// 上报进度 → 读回续看位置 → 收藏 → 收藏列表 → 取消收藏"整条链路走一遍。
|
||||
///
|
||||
/// 用一个一次性邮箱注册(需要登录态的接口才会通);服务没起时自动跳过。
|
||||
void main() {
|
||||
const baseUrl = 'http://127.0.0.1:8081';
|
||||
final email = 'k1.selftest.${DateTime.now().millisecondsSinceEpoch}@example.com';
|
||||
const password = 'Passw0rd!23';
|
||||
|
||||
AuthSession? session;
|
||||
final client = ApiClient(
|
||||
baseUrl: baseUrl,
|
||||
accessTokenReader: () => session?.accessToken,
|
||||
deviceIdReader: () async => 'k1-live-device',
|
||||
);
|
||||
final catalog = CatalogApi(client);
|
||||
final playback = PlaybackApi(client);
|
||||
final watch = WatchApi(client);
|
||||
final library = LibraryApi(client);
|
||||
|
||||
tearDownAll(client.close);
|
||||
|
||||
Future<bool> register() async {
|
||||
try {
|
||||
final raw = await client.post(
|
||||
'/app/auth/register',
|
||||
body: {
|
||||
'targetType': 'EMAIL',
|
||||
'target': email,
|
||||
'password': password,
|
||||
'nickname': 'K1 联调',
|
||||
'deviceId': 'k1-live-device',
|
||||
},
|
||||
);
|
||||
if (raw is! Map<String, dynamic>) return false;
|
||||
session = AuthSession.fromJson(raw);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test('should_complete_the_watch_flow_when_backend_is_running', () async {
|
||||
if (!await register()) {
|
||||
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
|
||||
return;
|
||||
}
|
||||
expect(session?.user.uid, isNotEmpty);
|
||||
|
||||
// 1) 首页 → 取第一部剧
|
||||
final feed = await catalog.homeFeed();
|
||||
expect(feed.dramas, isNotEmpty);
|
||||
final card = feed.dramas.first;
|
||||
expect(card.id, isNotEmpty);
|
||||
|
||||
// 2) 详情:剧集列表按集号升序,可播剧集带已签名地址
|
||||
final detail = await catalog.dramaDetail(card.id);
|
||||
expect(detail.id, card.id);
|
||||
expect(detail.episodes, isNotEmpty);
|
||||
final playable = detail.episodes
|
||||
.where((episode) => episode.videoUrl.isNotEmpty)
|
||||
.toList();
|
||||
if (playable.isEmpty) {
|
||||
markTestSkipped('这部演示剧没有可播剧集,已跳过播放相关断言');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 播放票据:签名地址带 e/s,客户端原样使用
|
||||
final ticket = await playback.ticket(playable.first.id);
|
||||
expect(ticket.accessible, isTrue);
|
||||
expect(ticket.accessType, isNotEmpty);
|
||||
expect(ticket.playUrl, startsWith('/files/'));
|
||||
expect(ticket.playUrl, contains('e='));
|
||||
expect(ticket.playUrl, contains('s='));
|
||||
|
||||
// 4) 上报进度 → 读回续看位置
|
||||
const position = Duration(seconds: 42);
|
||||
final saved = await watch.saveProgress(
|
||||
episodeId: playable.first.id,
|
||||
position: position,
|
||||
duration: Duration(seconds: ticket.durationSec),
|
||||
);
|
||||
expect(saved?.episodeId, playable.first.id);
|
||||
expect(saved?.position, position);
|
||||
|
||||
final resume = await watch.progressOfDrama(card.id);
|
||||
expect(resume?.episodeId, playable.first.id);
|
||||
expect(resume?.position, position);
|
||||
|
||||
// 最近观看里能看到这部剧
|
||||
final recent = await watch.recent(limit: 20);
|
||||
expect(recent.map((item) => item.dramaId), contains(card.id));
|
||||
|
||||
// 5) 收藏 → 出现在收藏列表 → 取消收藏
|
||||
final savedState = await library.toggleFavorite(card.id);
|
||||
expect(savedState, isTrue);
|
||||
final favorites = await library.favorites();
|
||||
expect(favorites.map((drama) => drama.id), contains(card.id));
|
||||
|
||||
await library.removeFavorite(card.id);
|
||||
final afterRemove = await library.favorites();
|
||||
expect(afterRemove.map((drama) => drama.id), isNot(contains(card.id)));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
||||
|
||||
Drama _card(String id) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/$id.png',
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('should_roll_back_when_toggle_fails', () async {
|
||||
final repository = InMemoryFavoriteRepository()..failToggle = true;
|
||||
final controller = FavoriteController(repository);
|
||||
|
||||
final ok = await controller.toggle('signal');
|
||||
|
||||
expect(ok, isFalse);
|
||||
// 乐观更新必须回滚:不能"看起来收藏了,其实服务端没存"
|
||||
expect(controller.isSaved('signal'), isFalse);
|
||||
expect(controller.cards, isEmpty);
|
||||
});
|
||||
|
||||
test('should_keep_saved_state_when_toggle_succeeds', () async {
|
||||
final controller = FavoriteController(InMemoryFavoriteRepository());
|
||||
|
||||
final ok = await controller.toggle('signal', card: _card('signal'));
|
||||
|
||||
expect(ok, isTrue);
|
||||
expect(controller.isSaved('signal'), isTrue);
|
||||
// 卡片随手带上:收藏完打开"我的收藏"就能看到
|
||||
expect(controller.cards.map((drama) => drama.id), ['signal']);
|
||||
});
|
||||
|
||||
test('should_remove_card_and_state_when_toggled_off', () async {
|
||||
final repository = InMemoryFavoriteRepository(saved: {'signal'});
|
||||
final controller = FavoriteController(repository);
|
||||
// 先按服务端状态对齐,再取消收藏
|
||||
await controller.load();
|
||||
|
||||
await controller.toggle('signal');
|
||||
|
||||
expect(controller.isSaved('signal'), isFalse);
|
||||
expect(controller.cards, isEmpty);
|
||||
});
|
||||
|
||||
test('should_expose_error_and_keep_old_state_when_list_fails', () async {
|
||||
final repository = InMemoryFavoriteRepository()..failList = true;
|
||||
final controller = FavoriteController(repository);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.loading, isFalse);
|
||||
expect(controller.error, 'favoritesError');
|
||||
});
|
||||
|
||||
test('should_load_cards_and_saved_ids_when_list_succeeds', () async {
|
||||
final repository = InMemoryFavoriteRepository(
|
||||
saved: {'signal'},
|
||||
cards: [_card('signal'), _card('city')],
|
||||
);
|
||||
final controller = FavoriteController(repository);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.error, isNull);
|
||||
expect(controller.cards.map((drama) => drama.id), ['signal']);
|
||||
expect(controller.isSaved('signal'), isTrue);
|
||||
expect(controller.isSaved('city'), isFalse);
|
||||
});
|
||||
|
||||
test('should_roll_back_when_remove_fails', () async {
|
||||
final repository = InMemoryFavoriteRepository(
|
||||
saved: {'signal'},
|
||||
cards: [_card('signal')],
|
||||
);
|
||||
final controller = FavoriteController(repository);
|
||||
await controller.load();
|
||||
|
||||
final ok = await controller.remove('signal');
|
||||
|
||||
// InMemory 实现的 remove 不会失败,这里断言正常路径:状态与列表一起清掉
|
||||
expect(ok, isTrue);
|
||||
expect(controller.isSaved('signal'), isFalse);
|
||||
expect(controller.cards, isEmpty);
|
||||
});
|
||||
|
||||
test('should_clear_state_when_signing_out', () async {
|
||||
final controller = FavoriteController(
|
||||
InMemoryFavoriteRepository(saved: {'signal'}, cards: [_card('signal')]),
|
||||
);
|
||||
await controller.load();
|
||||
|
||||
controller.clear();
|
||||
|
||||
expect(controller.savedIds, isEmpty);
|
||||
expect(controller.cards, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/config/locale_controller.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/mine/mine_logic.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
|
||||
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/poster.png',
|
||||
episodes: [
|
||||
for (final number in numbers)
|
||||
Episode(
|
||||
id: '$id-$number',
|
||||
number: number,
|
||||
title: 'Episode $number',
|
||||
videoUrl: 'https://example.test/clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('should_keep_requested_section_and_read_episode_one_when_constructed', () async {
|
||||
final history = FakeWatchHistory()..saved = const Duration(seconds: 7);
|
||||
final logic = MineLogic(
|
||||
history: history,
|
||||
drama: _drama('signal'),
|
||||
initialSection: 1,
|
||||
);
|
||||
|
||||
expect(logic.section, 1);
|
||||
expect(logic.filter, 0);
|
||||
expect(await logic.position, const Duration(seconds: 7));
|
||||
expect(history.reads, ['signal']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_resume_from_last_watch_when_list_card_has_no_episodes', () async {
|
||||
final history = FakeWatchHistory()
|
||||
..saved = const Duration(seconds: 7)
|
||||
..lastEpisode = 'signal-3';
|
||||
final logic = MineLogic(
|
||||
history: history,
|
||||
drama: _drama('signal', numbers: const []),
|
||||
initialSection: 1,
|
||||
);
|
||||
|
||||
expect(await logic.position, const Duration(seconds: 7));
|
||||
expect(history.reads, ['signal']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_notify_when_section_and_filter_are_selected', () {
|
||||
final logic = MineLogic(
|
||||
history: FakeWatchHistory(),
|
||||
drama: _drama('s'),
|
||||
initialSection: 0,
|
||||
);
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.selectSection(1);
|
||||
expect(logic.section, 1);
|
||||
expect(notifications, 1);
|
||||
|
||||
logic.selectFilter(2);
|
||||
expect(logic.filter, 2);
|
||||
expect(notifications, 2);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_adopt_drama_only_when_page_is_active', () {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = MineLogic(
|
||||
history: history,
|
||||
drama: _drama('signal'),
|
||||
initialSection: 0,
|
||||
);
|
||||
|
||||
void sync(
|
||||
Drama next, {
|
||||
required bool active,
|
||||
required bool wasActive,
|
||||
required bool dramaChanged,
|
||||
int sectionRequest = 0,
|
||||
int previousSectionRequest = 0,
|
||||
int initialSection = 0,
|
||||
}) => logic.syncPage(
|
||||
active: active,
|
||||
wasActive: wasActive,
|
||||
nextDrama: next,
|
||||
dramaChanged: dramaChanged,
|
||||
sectionRequest: sectionRequest,
|
||||
previousSectionRequest: previousSectionRequest,
|
||||
initialSection: initialSection,
|
||||
);
|
||||
|
||||
sync(_drama('city'), active: false, wasActive: true, dramaChanged: true);
|
||||
expect(logic.drama.id, 'signal');
|
||||
|
||||
sync(_drama('city'), active: true, wasActive: false, dramaChanged: false);
|
||||
expect(logic.drama.id, 'city');
|
||||
|
||||
history.reads.clear();
|
||||
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true);
|
||||
expect(logic.drama.id, 'vengeance');
|
||||
expect(history.reads, ['vengeance']);
|
||||
|
||||
history.reads.clear();
|
||||
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
|
||||
expect(logic.drama.id, 'vengeance');
|
||||
expect(history.reads, isEmpty);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_reset_section_when_section_request_arrives_while_page_is_inactive', () {
|
||||
final logic = MineLogic(
|
||||
history: FakeWatchHistory(),
|
||||
drama: _drama('s'),
|
||||
initialSection: 0,
|
||||
);
|
||||
|
||||
logic.syncPage(
|
||||
active: false,
|
||||
wasActive: false,
|
||||
nextDrama: logic.drama,
|
||||
dramaChanged: false,
|
||||
sectionRequest: 1,
|
||||
previousSectionRequest: 0,
|
||||
initialSection: 1,
|
||||
);
|
||||
|
||||
expect(logic.section, 1);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', (
|
||||
tester,
|
||||
) async {
|
||||
final logic = MineLogic(
|
||||
history: FakeWatchHistory(),
|
||||
drama: _drama('s'),
|
||||
initialSection: 0,
|
||||
);
|
||||
final forwarded = <MyPageAction>[];
|
||||
final popped = Completer<MyPageAction?>();
|
||||
late BuildContext pageContext;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => Navigator.of(context)
|
||||
.push<MyPageAction>(
|
||||
MaterialPageRoute<MyPageAction>(
|
||||
builder: (inner) {
|
||||
pageContext = inner;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
)
|
||||
.then(popped.complete),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
logic.act(
|
||||
pageContext,
|
||||
MyPageAction.discover,
|
||||
embedded: true,
|
||||
onAction: forwarded.add,
|
||||
);
|
||||
expect(forwarded, [MyPageAction.discover]);
|
||||
expect(Navigator.of(pageContext).canPop(), isTrue);
|
||||
|
||||
logic.act(pageContext, MyPageAction.resume, embedded: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await expectLater(popped.future, completion(MyPageAction.resume));
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('should_switch_app_locale_when_language_sheet_is_used', (tester) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final locale = LocaleController();
|
||||
final logic = MineLogic(
|
||||
history: FakeWatchHistory(),
|
||||
drama: _drama('s'),
|
||||
initialSection: 0,
|
||||
);
|
||||
late BuildContext context;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<LocaleController>.value(
|
||||
value: locale,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (inner) {
|
||||
context = inner;
|
||||
return const Scaffold(body: SizedBox.shrink());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(logic.showLanguage(context));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
expect(find.text(l10n.english), findsOneWidget);
|
||||
expect(find.text(l10n.simplifiedChinese), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.simplifiedChinese));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(locale.locale, const Locale('zh'));
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_do_nothing_when_notifying_after_dispose', () {
|
||||
final logic = MineLogic(
|
||||
history: FakeWatchHistory(),
|
||||
drama: _drama('s'),
|
||||
initialSection: 0,
|
||||
);
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.dispose();
|
||||
logic.notify();
|
||||
|
||||
expect(notifications, 0);
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/services/playback_session.dart';
|
||||
import 'package:nontonki/core/data/models/player_port.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||
import 'package:fruitshorts/core/data/models/player_port.dart';
|
||||
|
||||
void main() {
|
||||
final episodes = List.generate(
|
||||
@@ -12,12 +12,12 @@ void main() {
|
||||
id: 'ep-$i',
|
||||
number: i + 1,
|
||||
title: 'Episode $i',
|
||||
assetPath: 'test-$i.mp4',
|
||||
videoUrl: 'https://example.test/$i.mp4',
|
||||
isFree: i < 4,
|
||||
),
|
||||
);
|
||||
|
||||
test('warm playback starts before old native disposal finishes', () async {
|
||||
test('should_start_warm_playback_when_old_native_disposal_is_still_running', () async {
|
||||
final release = Completer<void>();
|
||||
final factory = _Factory(disposeGates: {'ep-0': release});
|
||||
final session = PlaybackSession(
|
||||
@@ -41,7 +41,7 @@ void main() {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('failed native release blocks another route from allocating', () async {
|
||||
test('should_block_another_route_from_allocating_when_native_release_fails', () async {
|
||||
final factory = _Factory(disposeFailures: {'ep-0'});
|
||||
final coordinator = PlaybackCoordinator();
|
||||
PlaybackSession createSession() => PlaybackSession(
|
||||
@@ -68,7 +68,7 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'next is prepared silently and promoted without another initialize',
|
||||
'should_prepare_next_silently_and_promote_it_when_swiped',
|
||||
() async {
|
||||
final factory = _Factory();
|
||||
final prepared = <String>[];
|
||||
@@ -103,7 +103,7 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('in-flight preload is reused when swiped before ready', () async {
|
||||
test('should_reuse_in_flight_preload_when_swiped_before_ready', () async {
|
||||
final gate = Completer<void>();
|
||||
final factory = _Factory(gates: {'ep-1': gate});
|
||||
final session = PlaybackSession(
|
||||
@@ -127,7 +127,7 @@ void main() {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('rapid reverse and skipped selection discard stale warm work', () async {
|
||||
test('should_discard_stale_warm_work_when_reversing_rapidly_or_skipping_selection', () async {
|
||||
final gate = Completer<void>();
|
||||
final factory = _Factory(gates: {'ep-1': gate});
|
||||
final session = PlaybackSession(
|
||||
@@ -153,7 +153,7 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'category adjacency and locked next never preload the wrong episode',
|
||||
'should_never_preload_wrong_episode_when_next_is_locked_or_category_based',
|
||||
() async {
|
||||
final factory = _Factory();
|
||||
final session = PlaybackSession(
|
||||
@@ -172,7 +172,7 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('preload failure is isolated and selecting it retries once', () async {
|
||||
test('should_isolate_preload_failure_and_retry_once_when_selected', () async {
|
||||
final factory = _Factory(failOnce: {'ep-1'});
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
@@ -190,7 +190,7 @@ void main() {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('completion in background never starts either player', () async {
|
||||
test('should_start_no_player_when_completion_happens_in_background', () async {
|
||||
final gate = Completer<void>();
|
||||
final factory = _Factory(gates: {'ep-1': gate});
|
||||
final session = PlaybackSession(
|
||||
@@ -215,7 +215,7 @@ void main() {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('closing during preloading disposes pending resources', () async {
|
||||
test('should_dispose_pending_resources_when_closing_during_preload', () async {
|
||||
final gate = Completer<void>();
|
||||
final factory = _Factory(gates: {'ep-1': gate});
|
||||
final session = PlaybackSession(
|
||||
@@ -233,7 +233,7 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'home and series share a two-player budget across route handoff',
|
||||
'should_share_two_player_budget_when_handing_off_between_home_and_series',
|
||||
() async {
|
||||
final factory = _Factory();
|
||||
final coordinator = PlaybackCoordinator();
|
||||
|
||||
@@ -1,26 +1,76 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:nontonki/core/data/services/playback_queue.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_queue.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'home feed advances between dramas, series player between episodes',
|
||||
() async {
|
||||
final dramas = await DemoCatalogRepository().fetchDramas();
|
||||
final home = PlaybackQueue.home(dramas);
|
||||
expect(home.length, 3);
|
||||
expect(home.entryAt(0).drama.id, dramas[0].id);
|
||||
expect(home.entryAt(1).drama.id, dramas[1].id);
|
||||
expect(home.entryAt(1).episode.number, 1);
|
||||
expect(home.nextIndex(0), 1);
|
||||
expect(home.nextIndex(2), isNull);
|
||||
test('should_advance_between_dramas_on_home_and_between_episodes_in_series_when_queue_is_built', () {
|
||||
final dramas = [_drama('a', 3), _drama('b', 2), _drama('c', 4)];
|
||||
|
||||
final series = PlaybackQueue.series(dramas[0]);
|
||||
expect(series.length, 3);
|
||||
expect(series.entryAt(0).drama.id, series.entryAt(1).drama.id);
|
||||
expect(series.entryAt(1).episode.number, 2);
|
||||
expect(series.nextIndex(0), 1);
|
||||
expect(series.nextIndex(2), isNull);
|
||||
},
|
||||
);
|
||||
final home = PlaybackQueue.home(dramas);
|
||||
expect(home.length, 3);
|
||||
expect(home.entryAt(0).drama.id, 'a');
|
||||
expect(home.entryAt(1).drama.id, 'b');
|
||||
expect(home.entryAt(1).episode.number, 1);
|
||||
expect(home.nextIndex(0), 1);
|
||||
expect(home.nextIndex(2), isNull);
|
||||
|
||||
final series = PlaybackQueue.series(dramas[0]);
|
||||
expect(series.length, 3);
|
||||
expect(series.entryAt(0).drama.id, series.entryAt(1).drama.id);
|
||||
expect(series.entryAt(1).episode.number, 2);
|
||||
expect(series.nextIndex(0), 1);
|
||||
expect(series.nextIndex(2), isNull);
|
||||
});
|
||||
|
||||
test('should_leave_dramas_out_when_they_have_no_episodes', () {
|
||||
final home = PlaybackQueue.home([
|
||||
_drama('a', 2),
|
||||
Drama(
|
||||
id: 'empty',
|
||||
title: '没有剧集',
|
||||
synopsis: '',
|
||||
posterUrl: '/files/empty.jpg',
|
||||
),
|
||||
]);
|
||||
expect(home.length, 1);
|
||||
expect(home.entryAt(0).drama.id, 'a');
|
||||
});
|
||||
|
||||
test('should_play_preview_episode_when_list_card_has_one', () {
|
||||
final home = PlaybackQueue.home([
|
||||
Drama.fromCardJson(const {
|
||||
'id': 5,
|
||||
'title': '闪婚后',
|
||||
'cover': '/files/cover.jpg',
|
||||
'totalEpisodes': 77,
|
||||
'preview': {
|
||||
'id': 9,
|
||||
'number': 1,
|
||||
'title': '第 1 集',
|
||||
'isFree': true,
|
||||
'playUrl': '/files/episode/9.mp4',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(home.length, 1);
|
||||
expect(home.entryAt(0).episode.videoUrl, '/files/episode/9.mp4');
|
||||
});
|
||||
}
|
||||
|
||||
Drama _drama(String id, int episodeCount) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: '简介 $id',
|
||||
posterUrl: '/files/$id.jpg',
|
||||
episodes: [
|
||||
for (var index = 1; index <= episodeCount; index++)
|
||||
Episode(
|
||||
id: '$id-$index',
|
||||
number: index,
|
||||
title: '第 $index 集',
|
||||
isFree: true,
|
||||
videoUrl: 'https://example.test/$id-$index.mp4',
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/services/playback_session.dart';
|
||||
import 'package:nontonki/core/data/models/player_port.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||
import 'package:fruitshorts/core/data/models/player_port.dart';
|
||||
|
||||
void main() {
|
||||
final episodes = List.generate(
|
||||
@@ -12,12 +12,12 @@ void main() {
|
||||
id: 'ep-${i + 1}',
|
||||
number: i + 1,
|
||||
title: 'Episode ${i + 1}',
|
||||
assetPath: 'assets/media/episode_0${i + 1}.mp4',
|
||||
videoUrl: 'https://example.test/episode_0${i + 1}.mp4',
|
||||
isFree: i < 2,
|
||||
),
|
||||
);
|
||||
|
||||
test('locked episode never creates or initializes a player', () async {
|
||||
test('should_create_no_player_when_episode_is_locked', () async {
|
||||
final factory = FakeFactory();
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
@@ -30,7 +30,7 @@ void main() {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('rapid selection drops stale initialize and keeps one player', () async {
|
||||
test('should_drop_stale_initialize_and_keep_one_player_when_selection_is_rapid', () async {
|
||||
final factory = FakeFactory(holdFirstInitialize: true);
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
@@ -52,7 +52,7 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'background and overlay pause without forgetting playback intent',
|
||||
'should_pause_without_forgetting_intent_when_backgrounding_or_overlay_shows',
|
||||
() async {
|
||||
final factory = FakeFactory();
|
||||
final session = PlaybackSession(
|
||||
@@ -80,7 +80,7 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('resume preparation finishes before video starts', () async {
|
||||
test('should_finish_resume_preparation_before_video_starts_when_resuming', () async {
|
||||
final factory = FakeFactory();
|
||||
final prepared = Completer<void>();
|
||||
final session = PlaybackSession(
|
||||
@@ -99,7 +99,48 @@ void main() {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('automatic advance respects visibility and playback intent', () async {
|
||||
test('should_resume_playback_when_swiping_after_pause', () async {
|
||||
final factory = FakeFactory();
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (episode) => episode.isFree,
|
||||
);
|
||||
await session.select(0);
|
||||
session.setUserWantsPlay(false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(session.userWantsPlay, isFalse);
|
||||
|
||||
await session.select(1);
|
||||
|
||||
expect(session.userWantsPlay, isTrue);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final playing = session.active;
|
||||
expect(playing, isA<FakePlayer>());
|
||||
expect((playing! as FakePlayer).playCalls, greaterThan(0));
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('should_keep_user_pause_when_reselecting_the_same_episode', () async {
|
||||
final factory = FakeFactory();
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (episode) => episode.isFree,
|
||||
);
|
||||
await session.select(0);
|
||||
session.setUserWantsPlay(false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
// 页面重新可见等场景会重复选中同一集,这时不能把暂停改成播放。
|
||||
await session.select(0);
|
||||
|
||||
expect(session.userWantsPlay, isFalse);
|
||||
expect(session.canAutoAdvance, isFalse);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('should_respect_visibility_and_intent_when_advancing_automatically', () async {
|
||||
final factory = FakeFactory();
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/playback_api.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/video_player_port.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
/// 播放票据有有效期(契约 §1.4:签名默认 15 分钟):
|
||||
/// 缓存期内不重复取,过期或显式强刷必须重新取,且**签名地址原样使用**。
|
||||
void main() {
|
||||
String envelope(String playUrl) => jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {
|
||||
'episodeId': '11',
|
||||
'dramaId': '5',
|
||||
'number': 1,
|
||||
'title': '第 1 集',
|
||||
'durationSec': 180,
|
||||
'accessible': true,
|
||||
'accessType': 'FREE',
|
||||
'playUrl': playUrl,
|
||||
'qualities': ['AUTO'],
|
||||
'unlockCoin': 0,
|
||||
'previewSeconds': 0,
|
||||
},
|
||||
});
|
||||
|
||||
_TicketEnv build() {
|
||||
var now = DateTime(2026, 10, 16, 12);
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
envelope('/files/episode/11.mp4?e=1760000000&s=9f2c'),
|
||||
),
|
||||
);
|
||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
|
||||
final repository = HttpPlaybackRepository(
|
||||
PlaybackApi(client),
|
||||
ttl: const Duration(minutes: 10),
|
||||
clock: () => now,
|
||||
);
|
||||
return _TicketEnv(
|
||||
repository: repository,
|
||||
adapter: adapter,
|
||||
advance: (Duration d) => now = now.add(d),
|
||||
);
|
||||
}
|
||||
|
||||
test('should_reuse_ticket_when_cache_is_still_fresh', () async {
|
||||
final env = build();
|
||||
await env.repository.ticketFor('11');
|
||||
env.advance(const Duration(minutes: 9));
|
||||
await env.repository.ticketFor('11');
|
||||
|
||||
expect(env.adapter.requests, hasLength(1));
|
||||
});
|
||||
|
||||
test('should_refetch_ticket_when_cache_expired', () async {
|
||||
final env = build();
|
||||
await env.repository.ticketFor('11');
|
||||
env.advance(const Duration(minutes: 11));
|
||||
final ticket = await env.repository.ticketFor('11');
|
||||
|
||||
expect(env.adapter.requests, hasLength(2));
|
||||
expect(ticket.playUrl, contains('?e=1760000000&s=9f2c'));
|
||||
});
|
||||
|
||||
test('should_refetch_ticket_when_explicitly_forced', () async {
|
||||
final env = build();
|
||||
await env.repository.ticketFor('11');
|
||||
await env.repository.ticketFor('11', forceRefresh: true);
|
||||
|
||||
expect(env.adapter.requests, hasLength(2));
|
||||
});
|
||||
|
||||
test('should_report_freshness_when_asking_before_expiry', () async {
|
||||
final env = build();
|
||||
expect(env.repository.isFresh('11'), isFalse);
|
||||
await env.repository.ticketFor('11');
|
||||
expect(env.repository.isFresh('11'), isTrue);
|
||||
env.advance(const Duration(minutes: 11));
|
||||
expect(env.repository.isFresh('11'), isFalse);
|
||||
});
|
||||
|
||||
test('should_keep_signed_query_verbatim_when_resolving_media_uri', () {
|
||||
const episode = Episode(
|
||||
id: '11',
|
||||
number: 1,
|
||||
title: '第 1 集',
|
||||
isFree: true,
|
||||
videoUrl: '/files/episode/11.mp4?e=1760000000&s=9f2c',
|
||||
);
|
||||
|
||||
final uri = VideoPlayerPort.resolveUri(episode);
|
||||
|
||||
// 只补 host,路径与查询串原样保留(签名丢了就等于 403)
|
||||
expect(uri.path, '/files/episode/11.mp4');
|
||||
expect(uri.query, 'e=1760000000&s=9f2c');
|
||||
expect(uri.toString(), endsWith('/files/episode/11.mp4?e=1760000000&s=9f2c'));
|
||||
});
|
||||
}
|
||||
|
||||
/// 测试用的小环境:仓库 + 假适配器 + 可推进的时钟。
|
||||
class _TicketEnv {
|
||||
_TicketEnv({
|
||||
required this.repository,
|
||||
required this.adapter,
|
||||
required this.advance,
|
||||
});
|
||||
|
||||
final HttpPlaybackRepository repository;
|
||||
final FakeHttpAdapter adapter;
|
||||
final void Function(Duration) advance;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||
import 'package:fruitshorts/pages/player/player_logic.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/poster.png',
|
||||
episodes: [
|
||||
for (final number in numbers)
|
||||
Episode(
|
||||
id: '$id-$number',
|
||||
number: number,
|
||||
title: 'Episode $number',
|
||||
videoUrl: 'https://example.test/clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
PlayerLogic _logic(FakeWatchHistory history, {Drama? drama}) {
|
||||
final logic = PlayerLogic(
|
||||
history: history,
|
||||
playback: FakePlaybackRepository(),
|
||||
pictureInPicture: AndroidPictureInPicture(
|
||||
channel: const MethodChannel('test/progress/pip'),
|
||||
),
|
||||
);
|
||||
if (drama != null) {
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(
|
||||
factory: (episode) => WidgetPlayer(),
|
||||
coordinator: PlaybackCoordinator(),
|
||||
);
|
||||
}
|
||||
return logic;
|
||||
}
|
||||
|
||||
/// 排空 session 串行队列,避免测试结束在 dispose 中途。
|
||||
Future<void> _drain(PlayerLogic logic) async {
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
await pumpEventQueue();
|
||||
await logic.session.playbackSettled;
|
||||
await logic.session.preloadSettled;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('progress throttle', () {
|
||||
test('should_report_once_per_interval_when_progress_ticks_keep_coming', () {
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
final start = DateTime(2026, 10, 16, 12);
|
||||
logic.lastProgressWrite = start;
|
||||
|
||||
// 契约建议每 10 秒一次:3 秒、9 秒都不报,第 10 秒报一次
|
||||
expect(logic.progressInterval, const Duration(seconds: 10));
|
||||
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 3))), isFalse);
|
||||
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 9))), isFalse);
|
||||
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 10))), isTrue);
|
||||
// 报过之后节流窗口重新开始
|
||||
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 15))), isFalse);
|
||||
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 20))), isTrue);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_write_immediately_when_saving_on_episode_switch_or_exit', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(history, drama: drama);
|
||||
|
||||
// 切集/退出/切后台走 saveCurrentProgress:不受 10 秒节流限制
|
||||
await logic.queueProgress(
|
||||
drama,
|
||||
drama.episodes[0],
|
||||
const Duration(seconds: 5),
|
||||
duration: const Duration(minutes: 3),
|
||||
);
|
||||
await logic.queueProgress(
|
||||
drama,
|
||||
drama.episodes[1],
|
||||
const Duration(seconds: 6),
|
||||
duration: const Duration(minutes: 3),
|
||||
);
|
||||
|
||||
expect(history.writes, [
|
||||
'signal-1|5000|false',
|
||||
'signal-2|6000|false',
|
||||
]);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_report_finished_when_episode_plays_to_the_end', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(history, drama: drama);
|
||||
|
||||
await logic.queueProgress(
|
||||
drama,
|
||||
drama.episodes[0],
|
||||
const Duration(minutes: 3),
|
||||
duration: const Duration(minutes: 3),
|
||||
finished: true,
|
||||
);
|
||||
|
||||
expect(history.writes, ['signal-1|180000|true']);
|
||||
logic.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('resume positioning', () {
|
||||
test('should_take_position_from_server_when_record_matches_the_episode', () async {
|
||||
final history = FakeWatchHistory(
|
||||
)..records['signal'] = const WatchProgress(
|
||||
dramaId: 'signal',
|
||||
episodeId: 'signal-2',
|
||||
position: Duration(seconds: 42),
|
||||
duration: Duration(minutes: 3),
|
||||
);
|
||||
final logic = _logic(history, drama: _drama('signal'));
|
||||
await _drain(logic);
|
||||
|
||||
await logic.loadResumePoint();
|
||||
|
||||
expect(logic.resumePositionFor('signal-2'), const Duration(seconds: 42));
|
||||
// 记录里不是这一集:不给定位(从头播,避免"看第 1 集却跳到第 2 集的位置")
|
||||
expect(logic.resumePositionFor('signal-1'), isNull);
|
||||
expect(logic.resumePositionFor('signal-3'), isNull);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_have_no_resume_position_when_server_read_fails', () async {
|
||||
final history = FakeWatchHistory()..failReads = true;
|
||||
final logic = _logic(history, drama: _drama('signal'));
|
||||
await _drain(logic);
|
||||
|
||||
await logic.loadResumePoint();
|
||||
|
||||
expect(logic.resumePoint, isNull);
|
||||
expect(logic.resumeLoaded, isTrue);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_not_seek_when_position_is_zero', () async {
|
||||
final history = FakeWatchHistory()
|
||||
..records['signal'] = const WatchProgress(
|
||||
dramaId: 'signal',
|
||||
episodeId: 'signal-1',
|
||||
position: Duration.zero,
|
||||
duration: Duration(minutes: 3),
|
||||
);
|
||||
final logic = _logic(history, drama: _drama('signal'));
|
||||
await _drain(logic);
|
||||
|
||||
await logic.loadResumePoint();
|
||||
|
||||
expect(logic.resumePositionFor('signal-1'), isNull);
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/pages/player/widgets/seek_progress_bar.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
void main() {
|
||||
test('seek target stays inside playable duration', () {
|
||||
test('should_keep_seek_target_inside_playable_duration_when_dragging', () {
|
||||
const duration = Duration(seconds: 12);
|
||||
expect(seekPositionForFraction(0, duration), Duration.zero);
|
||||
expect(
|
||||
@@ -14,7 +14,7 @@ void main() {
|
||||
expect(seekPositionForFraction(1, duration), lessThan(duration));
|
||||
});
|
||||
|
||||
testWidgets('horizontal slider drag previews and seeks on release', (
|
||||
testWidgets('should_preview_while_dragging_and_seek_on_release_when_slider_is_horizontal', (
|
||||
tester,
|
||||
) async {
|
||||
final video = ValueNotifier<VideoPlayerValue>(
|
||||
@@ -61,7 +61,7 @@ void main() {
|
||||
expect(target!, lessThan(const Duration(seconds: 12)));
|
||||
});
|
||||
|
||||
testWidgets('compact seek bar shows time only while dragging', (
|
||||
testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', (
|
||||
tester,
|
||||
) async {
|
||||
final video = ValueNotifier<VideoPlayerValue>(
|
||||
@@ -78,21 +78,20 @@ void main() {
|
||||
body: SeekProgressBar(
|
||||
valueListenable: video,
|
||||
semanticsLabel: 'Seek video',
|
||||
compactIdle: true,
|
||||
onSeek: (_) async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('00:03'), findsNothing);
|
||||
expect(find.textContaining('00:12'), findsNothing);
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(Slider)),
|
||||
);
|
||||
await gesture.moveBy(const Offset(40, 0));
|
||||
await tester.pump();
|
||||
expect(find.text('00:12'), findsOneWidget);
|
||||
expect(find.textContaining('/ 00:12'), findsOneWidget);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('00:12'), findsNothing);
|
||||
expect(find.textContaining('00:12'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/config/app_config.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/services/video_player_port.dart';
|
||||
|
||||
Episode _episode(String videoUrl) => Episode(
|
||||
id: '1',
|
||||
number: 1,
|
||||
title: '第 1 集',
|
||||
isFree: true,
|
||||
videoUrl: videoUrl,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('should_prefix_service_base_when_play_url_is_relative', () {
|
||||
final uri = VideoPlayerPort.resolveUri(
|
||||
_episode('/files/episode/202610/clip.mp4'),
|
||||
);
|
||||
|
||||
expect(uri.isAbsolute, isTrue);
|
||||
// Uri.parse().toString() 会把 host 规范成小写,而 devHostBaseUrl 里的
|
||||
// Bonjour 名字带大写(jiangdeMacBook-Air.local)—— 比较时统一小写,
|
||||
// 否则会依赖运行平台(flutter test 里 defaultTargetPlatform 默认是 android)
|
||||
expect(
|
||||
uri.toString().toLowerCase(),
|
||||
'${AppConfig.apiBaseUrl}/files/episode/202610/clip.mp4'.toLowerCase(),
|
||||
);
|
||||
});
|
||||
|
||||
test('should_keep_url_untouched_when_play_url_is_absolute', () {
|
||||
final uri = VideoPlayerPort.resolveUri(
|
||||
_episode('https://cdn.example.test/clip.mp4'),
|
||||
);
|
||||
|
||||
expect(uri.toString(), 'https://cdn.example.test/clip.mp4');
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
|
||||
/// 进度仓库从"本地按 profile+剧+集"换成"服务端每剧一条":
|
||||
/// 这里钉住续看点换算、空记录、字符串数值解析、失败透传与清理。
|
||||
void main() {
|
||||
test('resume position clamps invalid and completed values', () {
|
||||
test('should_clamp_invalid_and_completed_values_when_resolving_resume_position', () {
|
||||
const duration = Duration(seconds: 8);
|
||||
expect(
|
||||
clampResumePosition(const Duration(seconds: -1), duration),
|
||||
@@ -24,31 +27,101 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('watch history is scoped by profile, drama and episode', () async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final history = SharedPreferencesWatchHistoryRepository();
|
||||
await history.save(
|
||||
'profile-a',
|
||||
'drama-a',
|
||||
'episode-1',
|
||||
const Duration(seconds: 3),
|
||||
test('should_return_resume_point_when_server_has_progress', () async {
|
||||
final history = InMemoryWatchHistoryRepository(
|
||||
seed: const [
|
||||
WatchProgress(
|
||||
dramaId: 'drama-a',
|
||||
episodeId: 'episode-3',
|
||||
position: Duration(seconds: 42),
|
||||
duration: Duration(minutes: 3),
|
||||
),
|
||||
],
|
||||
);
|
||||
await history.save(
|
||||
'profile-a',
|
||||
'drama-a',
|
||||
'episode-2',
|
||||
const Duration(seconds: 5),
|
||||
|
||||
final point = await history.resumePointFor('drama-a');
|
||||
|
||||
expect(point?.episodeId, 'episode-3');
|
||||
expect(point?.position, const Duration(seconds: 42));
|
||||
});
|
||||
|
||||
test('should_return_no_resume_point_when_server_has_no_record', () async {
|
||||
final history = InMemoryWatchHistoryRepository();
|
||||
|
||||
expect(await history.resumePointFor('drama-a'), isNull);
|
||||
});
|
||||
|
||||
test('should_return_no_resume_point_when_record_has_no_episode', () async {
|
||||
final history = InMemoryWatchHistoryRepository(
|
||||
seed: const [
|
||||
WatchProgress(
|
||||
dramaId: 'drama-a',
|
||||
episodeId: '',
|
||||
position: Duration(seconds: 5),
|
||||
duration: Duration(minutes: 3),
|
||||
),
|
||||
],
|
||||
);
|
||||
expect(
|
||||
await history.positionFor('profile-a', 'drama-a', 'episode-1'),
|
||||
const Duration(seconds: 3),
|
||||
|
||||
expect(await history.resumePointFor('drama-a'), isNull);
|
||||
});
|
||||
|
||||
test('should_expose_fraction_when_duration_is_known', () {
|
||||
const record = WatchProgress(
|
||||
dramaId: 'd',
|
||||
episodeId: 'e',
|
||||
position: Duration(seconds: 30),
|
||||
duration: Duration(seconds: 120),
|
||||
);
|
||||
expect(
|
||||
await history.positionFor('profile-b', 'drama-a', 'episode-1'),
|
||||
isNull,
|
||||
expect(record.fraction, closeTo(0.25, 0.0001));
|
||||
expect(record.hasPosition, isTrue);
|
||||
|
||||
const unknown = WatchProgress(
|
||||
dramaId: 'd',
|
||||
episodeId: 'e',
|
||||
position: Duration(seconds: 30),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
expect(await history.lastEpisodeId('profile-a', 'drama-a'), 'episode-2');
|
||||
expect(await history.lastEpisodeId('profile-a', 'drama-b'), isNull);
|
||||
expect(unknown.fraction, 0);
|
||||
});
|
||||
|
||||
test('should_parse_string_numbers_when_server_sends_ids_and_ms_as_strings', () {
|
||||
final record = WatchProgress.fromJson({
|
||||
'dramaId': '2111012753262120962',
|
||||
'episodeId': '2111012753262120963',
|
||||
'positionMs': '42000',
|
||||
'durationMs': '180000',
|
||||
'finished': false,
|
||||
'playCount': '2',
|
||||
});
|
||||
|
||||
expect(record.dramaId, '2111012753262120962');
|
||||
expect(record.episodeId, '2111012753262120963');
|
||||
expect(record.position, const Duration(seconds: 42));
|
||||
expect(record.duration, const Duration(seconds: 180));
|
||||
expect(record.playCount, 2);
|
||||
});
|
||||
|
||||
test('should_throw_when_reading_progress_fails', () async {
|
||||
final history = InMemoryWatchHistoryRepository()..failReads = 1;
|
||||
|
||||
expect(() => history.progressFor('drama-a'), throwsStateError);
|
||||
});
|
||||
|
||||
test('should_drop_record_when_removing_progress', () async {
|
||||
final history = InMemoryWatchHistoryRepository(
|
||||
seed: const [
|
||||
WatchProgress(
|
||||
dramaId: 'drama-a',
|
||||
episodeId: 'e1',
|
||||
position: Duration(seconds: 5),
|
||||
duration: Duration(minutes: 1),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await history.remove('drama-a');
|
||||
|
||||
expect(await history.progressFor('drama-a'), isNull);
|
||||
});
|
||||
}
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user