Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c381be5e8 | ||
|
|
cf03178cc5 | ||
|
|
99f64ab870 | ||
|
|
45c3c645ff | ||
|
|
6cc5bfc3be | ||
|
|
5058853af3 | ||
|
|
3e8703b840 | ||
|
|
b870956665 | ||
|
|
b7b1c50427 | ||
|
|
732483465c | ||
|
|
ac706a42a5 | ||
|
|
cc037a2956 | ||
|
|
0cedaab795 | ||
|
|
8a481d5c62 | ||
|
|
e609e3ad8d | ||
|
|
e52d81881c | ||
|
|
10d448c8ba | ||
|
|
e1147cfcba | ||
|
|
1488ee8f85 | ||
|
|
9db6935308 | ||
|
|
d361034226 | ||
|
|
45f5f25640 | ||
|
|
cc4d7c5a9d | ||
|
|
00280ca0da | ||
|
|
84a1f58a0a | ||
|
|
d7eff4ca59 | ||
|
|
94ff9f1177 | ||
|
|
61454e36b1 | ||
|
|
0e5d550b50 | ||
|
|
1853e1845c | ||
|
|
ae49b10407 | ||
|
|
feecc349a0 | ||
|
|
e1b98df509 | ||
|
|
77d5748dbb |
No files matched your search
@@ -5,13 +5,46 @@
|
|||||||
- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。
|
- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。
|
||||||
- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。
|
- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。
|
||||||
- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。
|
- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。
|
||||||
- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。
|
- **互动与多语言**:本地演示评论、系统分享,以及英文、简体中文和印尼语界面。
|
||||||
- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。
|
- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。
|
||||||
|
|
||||||
## 技术实现
|
## 技术实现
|
||||||
|
|
||||||
项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。
|
项目使用 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,已从打包清单移除)。
|
||||||
|
|
||||||
## 当前说明
|
## 当前说明
|
||||||
|
|
||||||
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
|
收藏、点赞、评论与观看进度仍保存在本机(后端互动接口尚未开发);
|
||||||
|
金币与会员相关界面仍是占位数据。
|
||||||
@@ -6,4 +6,6 @@
|
|||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
||||||
|
<application android:usesCleartextTraffic="true"/>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<!-- Author: fruit.com.cn -->
|
<!-- Author: fruit.com.cn -->
|
||||||
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<application
|
<application
|
||||||
android:label="Nontonki"
|
android:label="FruitShorts"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -6,4 +6,6 @@
|
|||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
||||||
|
<application android:usesCleartextTraffic="true"/>
|
||||||
</manifest>
|
</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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -487,7 +487,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -538,7 +538,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
|||||||
@@ -6,11 +6,27 @@
|
|||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
<PreActions>
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Bootstrap Flutter SPM Package"
|
||||||
|
scriptText = "#!/bin/sh set -e FLUTTER="" if [ -n "${FLUTTER_ROOT:-}" ] && [ -x "$FLUTTER_ROOT/bin/flutter" ]; then FLUTTER="$FLUTTER_ROOT/bin/flutter" elif FLUTTER="$(command -v flutter)"; then : elif [ -x "$HOME/flutter/bin/flutter" ]; then FLUTTER="$HOME/flutter/bin/flutter" elif [ -x "/opt/homebrew/bin/flutter" ]; then FLUTTER="/opt/homebrew/bin/flutter" fi if [ -z "$FLUTTER" ]; then echo "error: Flutter executable not found. Set FLUTTER_ROOT or add Flutter to PATH." >&2 exit 1 fi if [ -z "${SRCROOT:-}" ]; then echo "error: SRCROOT is not set; cannot locate the Flutter project." >&2 exit 1 fi cd "$SRCROOT/.." "$FLUTTER" pub get ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
<ExecutionAction
|
<ExecutionAction
|
||||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
<ActionContent
|
<ActionContent
|
||||||
title = "Run Prepare Flutter Framework Script"
|
title = "Run Prepare Flutter Framework Script"
|
||||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
scriptText = "#!/bin/sh set -e FLUTTER="" if [ -n "${FLUTTER_ROOT:-}" ] && [ -x "$FLUTTER_ROOT/bin/flutter" ]; then FLUTTER="$FLUTTER_ROOT/bin/flutter" elif FLUTTER="$(command -v flutter)"; then : elif [ -x "$HOME/flutter/bin/flutter" ]; then FLUTTER="$HOME/flutter/bin/flutter" elif [ -x "/opt/homebrew/bin/flutter" ]; then FLUTTER="/opt/homebrew/bin/flutter" fi if [ -z "$FLUTTER" ]; then echo "error: Flutter executable not found. Set FLUTTER_ROOT or add Flutter to PATH." >&2 exit 1 fi FLUTTER_ROOT="$(cd "$(dirname "$FLUTTER")/.." && pwd -P)" if [ -z "${SRCROOT:-}" ]; then echo "error: SRCROOT is not set; cannot locate the Flutter project." >&2 exit 1 fi cd "$SRCROOT" "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
<EnvironmentBuildable>
|
<EnvironmentBuildable>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Nontonki</string>
|
<string>FruitShorts</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Nontonki</string>
|
<string>FruitShorts</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -26,6 +26,13 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
+182
-34
@@ -1,38 +1,205 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'core/config/locale_controller.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/category_api.dart';
|
||||||
|
import 'core/data/remote/comment_api.dart';
|
||||||
|
import 'core/data/remote/download_api.dart';
|
||||||
|
import 'core/data/remote/interaction_api.dart';
|
||||||
|
import 'core/data/remote/library_api.dart';
|
||||||
|
import 'core/data/remote/notice_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/catalog_repository.dart';
|
||||||
import 'core/data/repositories/engagement_repository.dart';
|
import 'core/data/repositories/category_repository.dart';
|
||||||
|
import 'core/data/repositories/comment_repository.dart';
|
||||||
|
import 'core/data/repositories/download_repository.dart';
|
||||||
|
import 'core/data/repositories/favorite_repository.dart';
|
||||||
|
import 'core/data/repositories/interaction_repository.dart';
|
||||||
|
import 'core/data/repositories/notice_repository.dart';
|
||||||
|
import 'core/data/repositories/playback_repository.dart';
|
||||||
|
import 'core/data/repositories/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/catalog_controller.dart';
|
||||||
import 'core/data/services/engagement_controller.dart';
|
import 'core/data/services/category_controller.dart';
|
||||||
|
import 'core/data/services/download_controller.dart';
|
||||||
|
import 'core/data/services/favorite_controller.dart';
|
||||||
|
import 'core/data/services/interaction_controller.dart';
|
||||||
import 'core/data/services/playback_session.dart';
|
import 'core/data/services/playback_session.dart';
|
||||||
import 'core/data/services/video_player_port.dart';
|
import 'core/data/services/video_player_port.dart';
|
||||||
|
import 'core/platform/haptics.dart';
|
||||||
|
import 'core/theme/app_theme.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'pages/home/home_view.dart';
|
import 'pages/home/home_view.dart';
|
||||||
|
|
||||||
class LumaDramaApp extends StatelessWidget {
|
class LumaDramaApp extends StatefulWidget {
|
||||||
const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new});
|
const LumaDramaApp({
|
||||||
|
super.key,
|
||||||
|
this.playerFactory = VideoPlayerPort.new,
|
||||||
|
this.catalogRepository,
|
||||||
|
this.categoryRepository,
|
||||||
|
this.downloadRepository,
|
||||||
|
this.watchHistoryRepository,
|
||||||
|
this.favoriteRepository,
|
||||||
|
this.playbackRepository,
|
||||||
|
this.commentRepository,
|
||||||
|
this.interactionRepository,
|
||||||
|
this.noticeRepository,
|
||||||
|
this.haptics = const FlutterHaptics(),
|
||||||
|
});
|
||||||
|
|
||||||
final PlayerFactory playerFactory;
|
final PlayerFactory playerFactory;
|
||||||
|
|
||||||
|
/// 测试注入用;生产走 HTTP 仓库。
|
||||||
|
final CatalogRepository? catalogRepository;
|
||||||
|
final CategoryRepository? categoryRepository;
|
||||||
|
final DownloadRepository? downloadRepository;
|
||||||
|
final WatchHistoryRepository? watchHistoryRepository;
|
||||||
|
final FavoriteRepository? favoriteRepository;
|
||||||
|
final PlaybackRepository? playbackRepository;
|
||||||
|
final CommentRepository? commentRepository;
|
||||||
|
final InteractionRepository? interactionRepository;
|
||||||
|
final NoticeRepository? noticeRepository;
|
||||||
|
final Haptics haptics;
|
||||||
|
|
||||||
|
@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 CatalogRepository _catalogRepository =
|
||||||
|
widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient));
|
||||||
|
|
||||||
|
late final CategoryRepository _categoryRepository =
|
||||||
|
widget.categoryRepository ??
|
||||||
|
HttpCategoryRepository(CategoryApi(_apiClient), _catalogRepository);
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
late final DownloadRepository _downloadRepository =
|
||||||
|
widget.downloadRepository ??
|
||||||
|
HttpDownloadRepository(DownloadApi(_apiClient));
|
||||||
|
|
||||||
|
late final DownloadController _downloadController = DownloadController(
|
||||||
|
_downloadRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
late final CommentRepository _commentRepository =
|
||||||
|
widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient));
|
||||||
|
|
||||||
|
late final InteractionRepository _interactionRepository =
|
||||||
|
widget.interactionRepository ??
|
||||||
|
HttpInteractionRepository(InteractionApi(_apiClient));
|
||||||
|
|
||||||
|
late final NoticeRepository _noticeRepository =
|
||||||
|
widget.noticeRepository ?? HttpNoticeRepository(NoticeApi(_apiClient));
|
||||||
|
|
||||||
|
late final InteractionController _interactionController =
|
||||||
|
InteractionController(_interactionRepository);
|
||||||
|
|
||||||
|
@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;
|
||||||
|
_interactionController.clear();
|
||||||
|
if (_auth.isSignedIn) {
|
||||||
|
unawaited(_favoriteController.load());
|
||||||
|
} else {
|
||||||
|
_favoriteController.clear();
|
||||||
|
_downloadController.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_auth.removeListener(_onAuthChanged);
|
||||||
|
_favoriteController.dispose();
|
||||||
|
_downloadController.dispose();
|
||||||
|
_interactionController.dispose();
|
||||||
|
_apiClient.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
Provider(create: (_) => PlaybackCoordinator()),
|
Provider(create: (_) => PlaybackCoordinator()),
|
||||||
Provider<PlayerFactory>(create: (_) => playerFactory),
|
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
|
||||||
|
Provider<CatalogRepository>.value(value: _catalogRepository),
|
||||||
|
Provider<PlaybackRepository>.value(value: _playback),
|
||||||
|
Provider<WatchHistoryRepository>.value(value: _watchHistory),
|
||||||
|
Provider<FavoriteRepository>.value(value: _favorites),
|
||||||
|
Provider<CommentRepository>.value(value: _commentRepository),
|
||||||
|
Provider<InteractionRepository>.value(value: _interactionRepository),
|
||||||
|
Provider<NoticeRepository>.value(value: _noticeRepository),
|
||||||
|
Provider<Haptics>.value(value: widget.haptics),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => CatalogController(DemoCatalogRepository())..load(),
|
create: (_) => CatalogController(_catalogRepository)..load(),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (_) => CategoryController(_categoryRepository)..load(),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
|
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider.value(value: _favoriteController),
|
||||||
create: (_) =>
|
ChangeNotifierProvider.value(value: _downloadController),
|
||||||
EngagementController(SharedPreferencesEngagementRepository())
|
ChangeNotifierProvider.value(value: _interactionController),
|
||||||
..load(),
|
ChangeNotifierProvider.value(value: _auth),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
child: Consumer<LocaleController>(
|
child: Consumer<LocaleController>(
|
||||||
builder: (context, locale, _) => MaterialApp(
|
builder: (context, locale, _) => MaterialApp(
|
||||||
@@ -40,31 +207,12 @@ class LumaDramaApp extends StatelessWidget {
|
|||||||
locale: locale.locale,
|
locale: locale.locale,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
localeResolutionCallback: (deviceLocale, supported) {
|
localeResolutionCallback: (deviceLocale, _) =>
|
||||||
final resolved = locale.locale ?? deviceLocale;
|
resolveAppLocale(locale.locale, deviceLocale),
|
||||||
if (resolved == null) return supported.first;
|
|
||||||
for (final item in supported) {
|
|
||||||
if (item.languageCode == resolved.languageCode) return item;
|
|
||||||
}
|
|
||||||
return supported.first;
|
|
||||||
},
|
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
theme: ThemeData(
|
theme: AppTheme.light(),
|
||||||
useMaterial3: true,
|
darkTheme: AppTheme.dark(),
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: const Color(0xFF6D4AFF),
|
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: const Color(0xFFF8F7FC),
|
|
||||||
),
|
|
||||||
darkTheme: ThemeData(
|
|
||||||
useMaterial3: true,
|
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: const Color(0xFF9B86FF),
|
|
||||||
brightness: Brightness.dark,
|
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: const Color(0xFF101019),
|
|
||||||
),
|
|
||||||
home: const HomePage(),
|
home: const HomePage(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,19 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Resolves the selected or system language to one of the supported app locales.
|
||||||
|
///
|
||||||
|
/// Indonesian is the launch-market fallback when a device uses an unsupported
|
||||||
|
/// language. The legacy `in` code is treated as Indonesian as well.
|
||||||
|
Locale resolveAppLocale(Locale? selected, Locale? deviceLocale) {
|
||||||
|
final code = (selected ?? deviceLocale)?.languageCode;
|
||||||
|
return switch (code) {
|
||||||
|
'zh' => const Locale('zh'),
|
||||||
|
'en' => const Locale('en'),
|
||||||
|
_ => const Locale('id'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class LocaleController extends ChangeNotifier {
|
class LocaleController extends ChangeNotifier {
|
||||||
LocaleController({SharedPreferencesAsync? preferences})
|
LocaleController({SharedPreferencesAsync? preferences})
|
||||||
: _preferences = preferences ?? SharedPreferencesAsync();
|
: _preferences = preferences ?? SharedPreferencesAsync();
|
||||||
@@ -17,6 +30,7 @@ class LocaleController extends ChangeNotifier {
|
|||||||
_locale = switch (code) {
|
_locale = switch (code) {
|
||||||
'en' => const Locale('en'),
|
'en' => const Locale('en'),
|
||||||
'zh' => const Locale('zh'),
|
'zh' => const Locale('zh'),
|
||||||
|
'id' => const Locale('id'),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -29,6 +43,7 @@ class LocaleController extends ChangeNotifier {
|
|||||||
_locale = switch (code) {
|
_locale = switch (code) {
|
||||||
'en' => const Locale('en'),
|
'en' => const Locale('en'),
|
||||||
'zh' => const Locale('zh'),
|
'zh' => const Locale('zh'),
|
||||||
|
'id' => const Locale('id'),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 评论游标分页大小,契约 §1.7 使用游标而非页码。
|
||||||
|
const int commentPageSize = 20;
|
||||||
|
|
||||||
|
/// 评论列表页。
|
||||||
|
class CommentPage {
|
||||||
|
const CommentPage({
|
||||||
|
this.comments = const [],
|
||||||
|
this.nextCursor,
|
||||||
|
this.hasMore = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<DramaComment> comments;
|
||||||
|
final String? nextCursor;
|
||||||
|
final bool hasMore;
|
||||||
|
|
||||||
|
/// 解析后端 `CursorResult{list,nextCursor,hasMore}`。
|
||||||
|
factory CommentPage.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawList = json['list'];
|
||||||
|
return CommentPage(
|
||||||
|
comments: [
|
||||||
|
if (rawList is List)
|
||||||
|
for (final item in rawList)
|
||||||
|
if (item is Map<String, dynamic>) DramaComment.fromJson(item),
|
||||||
|
],
|
||||||
|
nextCursor: json['nextCursor'] == null
|
||||||
|
? null
|
||||||
|
: jsonToId(json['nextCursor']),
|
||||||
|
hasMore: json['hasMore'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 服务端评论视图,字段对应 `CommentRespVO`。
|
||||||
|
class DramaComment {
|
||||||
|
const DramaComment({
|
||||||
|
required this.id,
|
||||||
|
required this.dramaId,
|
||||||
|
required this.episodeId,
|
||||||
|
required this.parentId,
|
||||||
|
required this.rootId,
|
||||||
|
required this.userId,
|
||||||
|
required this.uid,
|
||||||
|
required this.nickname,
|
||||||
|
required this.avatarUrl,
|
||||||
|
required this.content,
|
||||||
|
required this.status,
|
||||||
|
required this.auditReason,
|
||||||
|
required this.likeCount,
|
||||||
|
required this.replyCount,
|
||||||
|
required this.reportCount,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.mine,
|
||||||
|
required this.liked,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String dramaId;
|
||||||
|
final String episodeId;
|
||||||
|
final String parentId;
|
||||||
|
final String rootId;
|
||||||
|
final String userId;
|
||||||
|
final String uid;
|
||||||
|
final String nickname;
|
||||||
|
final String avatarUrl;
|
||||||
|
final String content;
|
||||||
|
final String status;
|
||||||
|
final String auditReason;
|
||||||
|
final int likeCount;
|
||||||
|
final int replyCount;
|
||||||
|
final int reportCount;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final bool mine;
|
||||||
|
final bool liked;
|
||||||
|
|
||||||
|
/// 待人工审核的评论仅本人可见。
|
||||||
|
bool get isPending => status == 'pending';
|
||||||
|
|
||||||
|
/// 顶层评论没有父评论。
|
||||||
|
bool get isTopLevel => parentId.isEmpty || parentId == '0';
|
||||||
|
|
||||||
|
/// 解析 `CommentRespVO`,所有 ID 字段只走字符串解析。
|
||||||
|
factory DramaComment.fromJson(Map<String, dynamic> json) => DramaComment(
|
||||||
|
id: jsonToId(json['id']),
|
||||||
|
dramaId: jsonToId(json['dramaId']),
|
||||||
|
episodeId: jsonToId(json['episodeId'], fallback: '0'),
|
||||||
|
parentId: jsonToId(json['parentId'], fallback: '0'),
|
||||||
|
rootId: jsonToId(json['rootId'], fallback: '0'),
|
||||||
|
userId: jsonToId(json['userId']),
|
||||||
|
uid: json['uid']?.toString() ?? '',
|
||||||
|
nickname: json['nickname']?.toString() ?? '',
|
||||||
|
avatarUrl: json['avatarUrl']?.toString() ?? '',
|
||||||
|
content: json['content']?.toString() ?? '',
|
||||||
|
status: json['status']?.toString() ?? '',
|
||||||
|
auditReason: json['auditReason']?.toString() ?? '',
|
||||||
|
likeCount: jsonToInt(json['likeCount']),
|
||||||
|
replyCount: jsonToInt(json['replyCount']),
|
||||||
|
reportCount: jsonToInt(json['reportCount']),
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
|
||||||
|
mine: json['mine'] == true,
|
||||||
|
liked: json['liked'] == true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 局部更新点赞或回复数,列表中的其他字段保持不变。
|
||||||
|
DramaComment copyWith({
|
||||||
|
int? likeCount,
|
||||||
|
int? replyCount,
|
||||||
|
bool? liked,
|
||||||
|
}) => DramaComment(
|
||||||
|
id: id,
|
||||||
|
dramaId: dramaId,
|
||||||
|
episodeId: episodeId,
|
||||||
|
parentId: parentId,
|
||||||
|
rootId: rootId,
|
||||||
|
userId: userId,
|
||||||
|
uid: uid,
|
||||||
|
nickname: nickname,
|
||||||
|
avatarUrl: avatarUrl,
|
||||||
|
content: content,
|
||||||
|
status: status,
|
||||||
|
auditReason: auditReason,
|
||||||
|
likeCount: likeCount ?? this.likeCount,
|
||||||
|
replyCount: replyCount ?? this.replyCount,
|
||||||
|
reportCount: reportCount,
|
||||||
|
createdAt: createdAt,
|
||||||
|
mine: mine,
|
||||||
|
liked: liked ?? this.liked,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
/// 服务端内容分类。id 按契约保持字符串,避免雪花 ID 精度丢失。
|
||||||
|
class ContentCategory {
|
||||||
|
const ContentCategory({required this.id, required this.name});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// 解析 `GET /app/categories` 的单项。
|
||||||
|
factory ContentCategory.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ContentCategory(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 下载授权与任务进度合并视图,对应 `GET /app/downloads` 的列表项。
|
||||||
|
class DownloadLicense {
|
||||||
|
const DownloadLicense({
|
||||||
|
required this.licenseId,
|
||||||
|
required this.dramaId,
|
||||||
|
required this.dramaTitle,
|
||||||
|
required this.episodeId,
|
||||||
|
required this.episodeNo,
|
||||||
|
required this.deviceId,
|
||||||
|
required this.quality,
|
||||||
|
required this.playUrl,
|
||||||
|
required this.licenseKey,
|
||||||
|
required this.status,
|
||||||
|
required this.grantedAt,
|
||||||
|
required this.expireAt,
|
||||||
|
required this.taskStatus,
|
||||||
|
required this.progressPercent,
|
||||||
|
required this.downloadedBytes,
|
||||||
|
required this.totalBytes,
|
||||||
|
required this.errorMsg,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String licenseId;
|
||||||
|
final String dramaId;
|
||||||
|
final String dramaTitle;
|
||||||
|
final String episodeId;
|
||||||
|
final int episodeNo;
|
||||||
|
final String deviceId;
|
||||||
|
final String quality;
|
||||||
|
final String playUrl;
|
||||||
|
final String licenseKey;
|
||||||
|
final String status;
|
||||||
|
final DateTime? grantedAt;
|
||||||
|
final DateTime? expireAt;
|
||||||
|
final String taskStatus;
|
||||||
|
final int progressPercent;
|
||||||
|
final int downloadedBytes;
|
||||||
|
final int totalBytes;
|
||||||
|
final String errorMsg;
|
||||||
|
|
||||||
|
/// 解析授权响应;所有 ID 与 Long 字段兼容字符串下发。
|
||||||
|
factory DownloadLicense.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DownloadLicense(
|
||||||
|
licenseId: (json['licenseId'] ?? '').toString(),
|
||||||
|
dramaId: (json['dramaId'] ?? '').toString(),
|
||||||
|
dramaTitle: json['dramaTitle'] as String? ?? '',
|
||||||
|
episodeId: (json['episodeId'] ?? '').toString(),
|
||||||
|
episodeNo: jsonToInt(json['episodeNo']),
|
||||||
|
deviceId: json['deviceId'] as String? ?? '',
|
||||||
|
quality: json['quality'] as String? ?? '',
|
||||||
|
playUrl: json['playUrl'] as String? ?? '',
|
||||||
|
licenseKey: json['licenseKey'] as String? ?? '',
|
||||||
|
status: json['status'] as String? ?? '',
|
||||||
|
grantedAt: DateTime.tryParse(json['grantedAt'] as String? ?? ''),
|
||||||
|
expireAt: DateTime.tryParse(json['expireAt'] as String? ?? ''),
|
||||||
|
taskStatus: json['taskStatus'] as String? ?? '',
|
||||||
|
progressPercent: jsonToInt(json['progressPercent']),
|
||||||
|
downloadedBytes: jsonToInt(json['downloadedBytes']),
|
||||||
|
totalBytes: jsonToInt(json['totalBytes']),
|
||||||
|
errorMsg: json['errorMsg'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 下载策略与当前设备配额,对应 `GET /app/downloads/policy`。
|
||||||
|
class DownloadPolicy {
|
||||||
|
const DownloadPolicy({
|
||||||
|
required this.maxPerDevice,
|
||||||
|
required this.licenseDays,
|
||||||
|
required this.wifiOnly,
|
||||||
|
required this.maxQuality,
|
||||||
|
required this.maxStorageBytes,
|
||||||
|
required this.used,
|
||||||
|
required this.remaining,
|
||||||
|
required this.expirePolicyText,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int maxPerDevice;
|
||||||
|
final int licenseDays;
|
||||||
|
final bool wifiOnly;
|
||||||
|
final String maxQuality;
|
||||||
|
final int maxStorageBytes;
|
||||||
|
final int used;
|
||||||
|
final int remaining;
|
||||||
|
final String expirePolicyText;
|
||||||
|
|
||||||
|
/// 设置更新期间用于本地乐观展示。
|
||||||
|
DownloadPolicy copyWith({bool? wifiOnly, String? maxQuality}) {
|
||||||
|
return DownloadPolicy(
|
||||||
|
maxPerDevice: maxPerDevice,
|
||||||
|
licenseDays: licenseDays,
|
||||||
|
wifiOnly: wifiOnly ?? this.wifiOnly,
|
||||||
|
maxQuality: maxQuality ?? this.maxQuality,
|
||||||
|
maxStorageBytes: maxStorageBytes,
|
||||||
|
used: used,
|
||||||
|
remaining: remaining,
|
||||||
|
expirePolicyText: expirePolicyText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析策略响应;Long 字段兼容字符串。
|
||||||
|
factory DownloadPolicy.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DownloadPolicy(
|
||||||
|
maxPerDevice: jsonToInt(json['maxPerDevice']),
|
||||||
|
licenseDays: jsonToInt(json['licenseDays']),
|
||||||
|
wifiOnly: json['wifiOnly'] == true,
|
||||||
|
maxQuality: json['maxQuality'] as String? ?? '',
|
||||||
|
maxStorageBytes: jsonToInt(json['maxStorageBytes']),
|
||||||
|
used: jsonToInt(json['used']),
|
||||||
|
remaining: jsonToInt(json['remaining']),
|
||||||
|
expirePolicyText: json['expirePolicyText'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,55 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
enum DramaFormat { animated, liveAction }
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。
|
||||||
|
///
|
||||||
|
/// 封面与播放地址都是后端返回的相对路径(`/files/...`),
|
||||||
|
/// 渲染前用 `AppConfig.mediaUrl` 补上服务地址。
|
||||||
class Episode {
|
class Episode {
|
||||||
const Episode({
|
const Episode({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.number,
|
required this.number,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.assetPath,
|
|
||||||
required this.isFree,
|
required this.isFree,
|
||||||
|
this.videoUrl = '',
|
||||||
|
this.durationSec = 0,
|
||||||
|
this.locked = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final int number;
|
final int number;
|
||||||
final String title;
|
final String title;
|
||||||
final String assetPath;
|
|
||||||
final bool isFree;
|
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 {
|
class Drama {
|
||||||
@@ -23,15 +57,74 @@ class Drama {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.synopsis,
|
required this.synopsis,
|
||||||
required this.posterAsset,
|
required this.posterUrl,
|
||||||
required this.episodes,
|
this.episodes = const [],
|
||||||
this.format = DramaFormat.liveAction,
|
this.category = '',
|
||||||
|
this.genre = '',
|
||||||
|
this.totalEpisodes = 0,
|
||||||
|
this.freeEpisodes = 0,
|
||||||
|
this.isHot = false,
|
||||||
|
this.viewCount = 0,
|
||||||
|
this.detailLoaded = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String title;
|
final String title;
|
||||||
final String synopsis;
|
final String synopsis;
|
||||||
final String posterAsset;
|
final String posterUrl;
|
||||||
final List<Episode> episodes;
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
class EngagementSnapshot {
|
|
||||||
const EngagementSnapshot({
|
|
||||||
this.savedDramaIds = const {},
|
|
||||||
this.likedDramaIds = const {},
|
|
||||||
this.commentsByDrama = const {},
|
|
||||||
});
|
|
||||||
|
|
||||||
final Set<String> savedDramaIds;
|
|
||||||
final Set<String> likedDramaIds;
|
|
||||||
final Map<String, List<String>> commentsByDrama;
|
|
||||||
}
|
|
||||||
@@ -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,70 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 互动目标类型,契约只允许短剧与评论。
|
||||||
|
enum InteractionTargetType {
|
||||||
|
drama('DRAMA'),
|
||||||
|
comment('COMMENT');
|
||||||
|
|
||||||
|
const InteractionTargetType(this.apiValue);
|
||||||
|
|
||||||
|
final String apiValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户端固定举报原因,后端当前未限制枚举值。
|
||||||
|
enum ReportReason {
|
||||||
|
spam('SPAM'),
|
||||||
|
abuse('ABUSE'),
|
||||||
|
porn('PORN'),
|
||||||
|
ad('AD'),
|
||||||
|
other('OTHER');
|
||||||
|
|
||||||
|
const ReportReason(this.apiValue);
|
||||||
|
|
||||||
|
final String apiValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次性互动请求,供仓库和测试断言使用。
|
||||||
|
class ReportRequest {
|
||||||
|
const ReportRequest({
|
||||||
|
required this.targetType,
|
||||||
|
required this.targetId,
|
||||||
|
required this.reasonCode,
|
||||||
|
this.description = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final InteractionTargetType targetType;
|
||||||
|
final String targetId;
|
||||||
|
final String reasonCode;
|
||||||
|
final String description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞切换后的服务端状态。
|
||||||
|
class LikeToggleResult {
|
||||||
|
const LikeToggleResult({required this.liked, required this.count});
|
||||||
|
|
||||||
|
final bool liked;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
/// 计数按 Long 字符串兼容解析。
|
||||||
|
factory LikeToggleResult.fromJson(Map<String, dynamic> json) =>
|
||||||
|
LikeToggleResult(
|
||||||
|
liked: json['liked'] == true,
|
||||||
|
count: jsonToInt(json['count']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 控制器统一返回结果;失败消息可直接展示后端 `message`。
|
||||||
|
class OperationResult<T> {
|
||||||
|
const OperationResult._(this.data, this.errorMessage);
|
||||||
|
|
||||||
|
const OperationResult.success([T? data]) : this._(data, null);
|
||||||
|
|
||||||
|
const OperationResult.failure(this.errorMessage) : data = null;
|
||||||
|
|
||||||
|
final T? data;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
bool get isSuccess => errorMessage == null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ID 字段统一转字符串;后端按契约下发字符串,同时兼容测试或旧数据的数字形式。
|
||||||
|
String jsonToId(dynamic value, {String fallback = ''}) {
|
||||||
|
if (value is String) return value;
|
||||||
|
if (value is num) return value.toString();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// Server notice list page.
|
||||||
|
class NoticePage {
|
||||||
|
const NoticePage({required this.total, this.notices = const []});
|
||||||
|
|
||||||
|
final int total;
|
||||||
|
final List<AppNotice> notices;
|
||||||
|
|
||||||
|
/// Parses the anonymous `/app/notices` page shell.
|
||||||
|
factory NoticePage.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawList = json['list'];
|
||||||
|
return NoticePage(
|
||||||
|
total: jsonToInt(json['total']),
|
||||||
|
notices: [
|
||||||
|
if (rawList is List)
|
||||||
|
for (final item in rawList)
|
||||||
|
if (item is Map<String, dynamic>) AppNotice.fromJson(item),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System notice returned by `/app/notices`.
|
||||||
|
class AppNotice {
|
||||||
|
const AppNotice({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
required this.type,
|
||||||
|
required this.publishAt,
|
||||||
|
this.expireAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String content;
|
||||||
|
final String type;
|
||||||
|
final DateTime? publishAt;
|
||||||
|
final DateTime? expireAt;
|
||||||
|
|
||||||
|
/// Parses string IDs without narrowing them through `int`.
|
||||||
|
factory AppNotice.fromJson(Map<String, dynamic> json) => AppNotice(
|
||||||
|
id: jsonToId(json['id']),
|
||||||
|
title: json['title']?.toString() ?? '',
|
||||||
|
content: json['content']?.toString() ?? '',
|
||||||
|
type: json['type']?.toString() ?? '',
|
||||||
|
publishAt: DateTime.tryParse(json['publishAt']?.toString() ?? ''),
|
||||||
|
expireAt: DateTime.tryParse(json['expireAt']?.toString() ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,65 @@
|
|||||||
|
// 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,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String dramaId;
|
||||||
|
final String episodeId;
|
||||||
|
final Duration position;
|
||||||
|
final Duration duration;
|
||||||
|
final bool finished;
|
||||||
|
final int playCount;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
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']),
|
||||||
|
updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'dramaId': dramaId,
|
||||||
|
'episodeId': episodeId,
|
||||||
|
'positionMs': position.inMilliseconds,
|
||||||
|
'durationMs': duration.inMilliseconds,
|
||||||
|
'finished': finished,
|
||||||
|
'playCount': playCount,
|
||||||
|
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 续看点:从哪一集的哪个位置继续看。
|
||||||
|
class ResumePoint {
|
||||||
|
const ResumePoint({required this.episodeId, required this.position});
|
||||||
|
|
||||||
|
final String episodeId;
|
||||||
|
final Duration position;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'drama.dart';
|
||||||
|
import 'watch_progress.dart';
|
||||||
|
|
||||||
|
/// 观看记录页的一条数据:进度记录与按 dramaId 补取的详情卡片。
|
||||||
|
class WatchRecord {
|
||||||
|
const WatchRecord({
|
||||||
|
required this.progress,
|
||||||
|
this.drama,
|
||||||
|
this.detailLoading = true,
|
||||||
|
this.detailFailed = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final WatchProgress progress;
|
||||||
|
final Drama? drama;
|
||||||
|
final bool detailLoading;
|
||||||
|
final bool detailFailed;
|
||||||
|
|
||||||
|
String get dramaId => progress.dramaId;
|
||||||
|
|
||||||
|
/// 详情中按 episodeId 定位集号;缺失时返回 0。
|
||||||
|
int get episodeNumber {
|
||||||
|
final episodes = drama?.episodes ?? const <Episode>[];
|
||||||
|
for (var index = 0; index < episodes.length; index++) {
|
||||||
|
if (episodes[index].id == progress.episodeId) return index + 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成卡片加载中、成功或失败后的新状态。
|
||||||
|
WatchRecord copyWith({
|
||||||
|
Drama? drama,
|
||||||
|
bool? detailLoading,
|
||||||
|
bool? detailFailed,
|
||||||
|
}) {
|
||||||
|
return WatchRecord(
|
||||||
|
progress: progress,
|
||||||
|
drama: drama ?? this.drama,
|
||||||
|
detailLoading: detailLoading ?? this.detailLoading,
|
||||||
|
detailFailed: detailFailed ?? this.detailFailed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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?> put(
|
||||||
|
String path, {
|
||||||
|
Object? body,
|
||||||
|
Map<String, String>? query,
|
||||||
|
bool skipAuthRetry = false,
|
||||||
|
}) {
|
||||||
|
return _request(
|
||||||
|
'PUT',
|
||||||
|
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,21 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/content_category.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
/// 内容分类接口,对应契约 `GET /app/categories`。
|
||||||
|
class CategoryApi {
|
||||||
|
const CategoryApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
/// 拉取已启用分类,id 全程按字符串解析。
|
||||||
|
Future<List<ContentCategory>> list() async {
|
||||||
|
final data = await _client.get('/app/categories');
|
||||||
|
if (data is! List) return const [];
|
||||||
|
return [
|
||||||
|
for (final item in data)
|
||||||
|
if (item is Map<String, dynamic>) ContentCategory.fromJson(item),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/comment.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
/// 评论接口,对应契约 §1.7 的 `/app/comments*`。
|
||||||
|
class CommentApi {
|
||||||
|
const CommentApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
/// 拉取游标分页;`rootId` 用于加载某一楼层的回复。
|
||||||
|
Future<CommentPage> page({
|
||||||
|
required String dramaId,
|
||||||
|
String? rootId,
|
||||||
|
String? cursor,
|
||||||
|
int size = commentPageSize,
|
||||||
|
}) async {
|
||||||
|
final data = await _client.get(
|
||||||
|
'/app/comments',
|
||||||
|
query: {
|
||||||
|
'dramaId': dramaId,
|
||||||
|
'rootId': ?rootId,
|
||||||
|
'cursor': ?cursor,
|
||||||
|
'size': '$size',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (data is! Map<String, dynamic>) return const CommentPage();
|
||||||
|
return CommentPage.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。
|
||||||
|
Future<DramaComment> publish({
|
||||||
|
required String dramaId,
|
||||||
|
String? episodeId,
|
||||||
|
required String content,
|
||||||
|
String? parentId,
|
||||||
|
}) async {
|
||||||
|
final data = await _client.post(
|
||||||
|
'/app/comments',
|
||||||
|
body: {
|
||||||
|
'dramaId': dramaId,
|
||||||
|
if (episodeId != null && episodeId.isNotEmpty)
|
||||||
|
'episodeId': episodeId,
|
||||||
|
'content': content,
|
||||||
|
if (parentId != null && parentId.isNotEmpty) 'parentId': parentId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (data is! Map<String, dynamic>) {
|
||||||
|
throw const ApiException(500, '评论响应格式错误');
|
||||||
|
}
|
||||||
|
return DramaComment.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除自己的评论。
|
||||||
|
Future<void> delete(String commentId) async {
|
||||||
|
await _client.delete('/app/comments/$commentId');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/download_license.dart';
|
||||||
|
import '../models/download_policy.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
/// 离线下载授权接口,对应契约 §1.6 的全部 8 个接口。
|
||||||
|
///
|
||||||
|
/// 设备号由 [ApiClient] 统一通过 `X-Device-Id` 发送,本类不另造设备参数。
|
||||||
|
class DownloadApi {
|
||||||
|
const DownloadApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
/// 拉取配额和偏好策略。
|
||||||
|
Future<DownloadPolicy> policy() async {
|
||||||
|
return DownloadPolicy.fromJson(
|
||||||
|
_asMap(await _client.get('/app/downloads/policy')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新仅 Wi-Fi 与最高清晰度偏好。
|
||||||
|
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) async {
|
||||||
|
await _client.put(
|
||||||
|
'/app/downloads/settings',
|
||||||
|
body: {'wifiOnly': ?wifiOnly, 'maxQuality': ?maxQuality},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 申请下载授权;同集重复申请由服务端保证幂等。
|
||||||
|
Future<DownloadLicense> grant(String episodeId, {String? quality}) async {
|
||||||
|
return DownloadLicense.fromJson(
|
||||||
|
_asMap(
|
||||||
|
await _client.post(
|
||||||
|
'/app/downloads',
|
||||||
|
body: {'episodeId': episodeId, 'quality': ?quality},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取当前设备的授权列表。
|
||||||
|
Future<List<DownloadLicense>> list() async {
|
||||||
|
final data = await _client.get('/app/downloads');
|
||||||
|
if (data is! List) return const [];
|
||||||
|
return [
|
||||||
|
for (final item in data)
|
||||||
|
if (item is Map<String, dynamic>) DownloadLicense.fromJson(item),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 续传前刷新短时效下载地址。
|
||||||
|
Future<DownloadLicense> refreshUrl(String episodeId) async {
|
||||||
|
return DownloadLicense.fromJson(
|
||||||
|
_asMap(await _client.get('/app/downloads/$episodeId/url')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上报下载进度;本批只提供方法,不启动文件落盘。
|
||||||
|
Future<DownloadLicense> reportProgress(
|
||||||
|
String episodeId, {
|
||||||
|
String? deviceId,
|
||||||
|
String? status,
|
||||||
|
int? progressPercent,
|
||||||
|
int? downloadedBytes,
|
||||||
|
int? totalBytes,
|
||||||
|
String? errorMsg,
|
||||||
|
}) async {
|
||||||
|
return DownloadLicense.fromJson(
|
||||||
|
_asMap(
|
||||||
|
await _client.post(
|
||||||
|
'/app/downloads/$episodeId/progress',
|
||||||
|
body: {
|
||||||
|
'deviceId': ?deviceId,
|
||||||
|
'status': ?status,
|
||||||
|
'progressPercent': ?progressPercent,
|
||||||
|
'downloadedBytes': ?downloadedBytes,
|
||||||
|
'totalBytes': ?totalBytes,
|
||||||
|
'errorMsg': ?errorMsg,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 吊销单集授权。
|
||||||
|
Future<void> remove(String episodeId) async {
|
||||||
|
await _client.delete('/app/downloads/$episodeId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空当前设备授权并返回清除条数。
|
||||||
|
Future<int> clear() async {
|
||||||
|
final data = await _client.delete('/app/downloads');
|
||||||
|
return data is int ? data : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _asMap(Object? data) {
|
||||||
|
if (data is Map<String, dynamic>) return data;
|
||||||
|
throw const ApiException(-1, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/interaction.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
/// 点赞与举报接口,对应契约 §1.7。
|
||||||
|
class InteractionApi {
|
||||||
|
const InteractionApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
/// 切换点赞,服务端返回的状态与计数为最终真相。
|
||||||
|
Future<LikeToggleResult> toggleLike({
|
||||||
|
required InteractionTargetType targetType,
|
||||||
|
required String targetId,
|
||||||
|
}) async {
|
||||||
|
final data = await _client.post(
|
||||||
|
'/app/likes/toggle',
|
||||||
|
body: {
|
||||||
|
'targetType': targetType.apiValue,
|
||||||
|
'targetId': targetId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (data is! Map<String, dynamic>) {
|
||||||
|
throw const ApiException(500, '点赞响应格式错误');
|
||||||
|
}
|
||||||
|
return LikeToggleResult.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交举报,返回工单 ID。
|
||||||
|
Future<String> report(ReportRequest request) async {
|
||||||
|
final data = await _client.post(
|
||||||
|
'/app/reports',
|
||||||
|
body: {
|
||||||
|
'targetType': request.targetType.apiValue,
|
||||||
|
'targetId': request.targetId,
|
||||||
|
'reasonCode': request.reasonCode,
|
||||||
|
if (request.description.isNotEmpty)
|
||||||
|
'description': request.description,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data?.toString() ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,23 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/notice.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
/// Anonymous system notice API.
|
||||||
|
class NoticeApi {
|
||||||
|
const NoticeApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
/// Loads one page from `GET /app/notices`.
|
||||||
|
Future<NoticePage> page({required int pageIndex, int pageSize = 20}) async {
|
||||||
|
final data = await _client.get(
|
||||||
|
'/app/notices',
|
||||||
|
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
|
||||||
|
);
|
||||||
|
if (data is! Map<String, dynamic>) {
|
||||||
|
return const NoticePage(total: 0);
|
||||||
|
}
|
||||||
|
return NoticePage.fromJson(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,64 @@
|
|||||||
|
// 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空当前账号的全部观看记录。
|
||||||
|
Future<void> clearProgress() async {
|
||||||
|
await _client.delete('/app/watch/progress');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,106 +1,33 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import '../models/drama.dart';
|
import '../models/drama.dart';
|
||||||
|
import '../models/home_feed.dart';
|
||||||
|
import '../remote/catalog_api.dart';
|
||||||
|
|
||||||
|
/// 目录数据来源:首页/发现页聚合、详情。
|
||||||
|
///
|
||||||
|
/// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。
|
||||||
|
/// **播放票据不在这里**:它属于 [PlaybackRepository](票据有有效期,需要单独缓存策略)。
|
||||||
abstract interface class CatalogRepository {
|
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
|
@override
|
||||||
Future<List<Drama>> fetchDramas() async => const [
|
Future<HomeFeed> fetchHomeFeed() => _api.homeFeed();
|
||||||
Drama(
|
|
||||||
id: 'the-signal-demo',
|
@override
|
||||||
title: 'The Signal',
|
Future<HomeFeed> fetchDiscoverFeed() => _api.discoverFeed();
|
||||||
synopsis:
|
|
||||||
'A mock series for testing vertical episode playback with one reused local clip.',
|
@override
|
||||||
posterAsset: 'assets/images/stitch_signal_poster_clean.png',
|
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/content_category.dart';
|
||||||
|
import '../models/home_feed.dart';
|
||||||
|
import '../remote/category_api.dart';
|
||||||
|
import 'catalog_repository.dart';
|
||||||
|
|
||||||
|
/// 发现页分类数据来源:分类清单与发现 feed。
|
||||||
|
abstract interface class CategoryRepository {
|
||||||
|
/// 拉取服务端分类。
|
||||||
|
Future<List<ContentCategory>> fetchCategories();
|
||||||
|
|
||||||
|
/// 拉取发现页分区;分类内容只从这里读取。
|
||||||
|
Future<HomeFeed> fetchDiscoverFeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 实现。发现 feed 复用既有目录仓库,不新增接口。
|
||||||
|
class HttpCategoryRepository implements CategoryRepository {
|
||||||
|
const HttpCategoryRepository(this._categoryApi, this._catalogRepository);
|
||||||
|
|
||||||
|
final CategoryApi _categoryApi;
|
||||||
|
final CatalogRepository _catalogRepository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ContentCategory>> fetchCategories() => _categoryApi.list();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomeFeed> fetchDiscoverFeed() =>
|
||||||
|
_catalogRepository.fetchDiscoverFeed();
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/comment.dart';
|
||||||
|
import '../remote/comment_api.dart';
|
||||||
|
|
||||||
|
/// 评论数据来源抽象,页面与控制器不直接依赖 HTTP。
|
||||||
|
abstract interface class CommentRepository {
|
||||||
|
/// 拉取评论或回复的分页。
|
||||||
|
Future<CommentPage> page({
|
||||||
|
required String dramaId,
|
||||||
|
String? rootId,
|
||||||
|
String? cursor,
|
||||||
|
int size = commentPageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 发表评论或回复。
|
||||||
|
Future<DramaComment> publish({
|
||||||
|
required String dramaId,
|
||||||
|
String? episodeId,
|
||||||
|
required String content,
|
||||||
|
String? parentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 删除指定评论。
|
||||||
|
Future<void> delete(String commentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 评论仓库。
|
||||||
|
class HttpCommentRepository implements CommentRepository {
|
||||||
|
const HttpCommentRepository(this._api);
|
||||||
|
|
||||||
|
final CommentApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CommentPage> page({
|
||||||
|
required String dramaId,
|
||||||
|
String? rootId,
|
||||||
|
String? cursor,
|
||||||
|
int size = commentPageSize,
|
||||||
|
}) => _api.page(
|
||||||
|
dramaId: dramaId,
|
||||||
|
rootId: rootId,
|
||||||
|
cursor: cursor,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DramaComment> publish({
|
||||||
|
required String dramaId,
|
||||||
|
String? episodeId,
|
||||||
|
required String content,
|
||||||
|
String? parentId,
|
||||||
|
}) => _api.publish(
|
||||||
|
dramaId: dramaId,
|
||||||
|
episodeId: episodeId,
|
||||||
|
content: content,
|
||||||
|
parentId: parentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String commentId) => _api.delete(commentId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/download_license.dart';
|
||||||
|
import '../models/download_policy.dart';
|
||||||
|
import '../remote/download_api.dart';
|
||||||
|
|
||||||
|
/// 下载授权数据来源,页面只依赖该抽象。
|
||||||
|
abstract interface class DownloadRepository {
|
||||||
|
Future<DownloadPolicy> fetchPolicy();
|
||||||
|
|
||||||
|
Future<List<DownloadLicense>> list();
|
||||||
|
|
||||||
|
Future<DownloadLicense> grant(String episodeId, {String? quality});
|
||||||
|
|
||||||
|
Future<void> updateSettings({bool? wifiOnly, String? maxQuality});
|
||||||
|
|
||||||
|
Future<DownloadLicense> refreshUrl(String episodeId);
|
||||||
|
|
||||||
|
Future<DownloadLicense> reportProgress(
|
||||||
|
String episodeId, {
|
||||||
|
String? deviceId,
|
||||||
|
String? status,
|
||||||
|
int? progressPercent,
|
||||||
|
int? downloadedBytes,
|
||||||
|
int? totalBytes,
|
||||||
|
String? errorMsg,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> remove(String episodeId);
|
||||||
|
|
||||||
|
Future<int> clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 下载授权仓库。
|
||||||
|
class HttpDownloadRepository implements DownloadRepository {
|
||||||
|
const HttpDownloadRepository(this._api);
|
||||||
|
|
||||||
|
final DownloadApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DownloadPolicy> fetchPolicy() => _api.policy();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<DownloadLicense>> list() => _api.list();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DownloadLicense> grant(String episodeId, {String? quality}) =>
|
||||||
|
_api.grant(episodeId, quality: quality);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) =>
|
||||||
|
_api.updateSettings(wifiOnly: wifiOnly, maxQuality: maxQuality);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DownloadLicense> refreshUrl(String episodeId) =>
|
||||||
|
_api.refreshUrl(episodeId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DownloadLicense> reportProgress(
|
||||||
|
String episodeId, {
|
||||||
|
String? deviceId,
|
||||||
|
String? status,
|
||||||
|
int? progressPercent,
|
||||||
|
int? downloadedBytes,
|
||||||
|
int? totalBytes,
|
||||||
|
String? errorMsg,
|
||||||
|
}) => _api.reportProgress(
|
||||||
|
episodeId,
|
||||||
|
deviceId: deviceId,
|
||||||
|
status: status,
|
||||||
|
progressPercent: progressPercent,
|
||||||
|
downloadedBytes: downloadedBytes,
|
||||||
|
totalBytes: totalBytes,
|
||||||
|
errorMsg: errorMsg,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> remove(String episodeId) => _api.remove(episodeId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> clear() => _api.clear();
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import '../models/engagement_snapshot.dart';
|
|
||||||
|
|
||||||
abstract interface class EngagementRepository {
|
|
||||||
Future<EngagementSnapshot> load();
|
|
||||||
Future<void> save(EngagementSnapshot snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
class SharedPreferencesEngagementRepository implements EngagementRepository {
|
|
||||||
SharedPreferencesEngagementRepository({SharedPreferencesAsync? preferences})
|
|
||||||
: _preferences = preferences ?? SharedPreferencesAsync();
|
|
||||||
|
|
||||||
static const _key = 'engagement.v1.demo-local-v1';
|
|
||||||
final SharedPreferencesAsync _preferences;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<EngagementSnapshot> load() async {
|
|
||||||
final raw = await _preferences.getString(_key);
|
|
||||||
if (raw == null) return const EngagementSnapshot();
|
|
||||||
try {
|
|
||||||
final json = jsonDecode(raw);
|
|
||||||
if (json is! Map<String, dynamic>) return const EngagementSnapshot();
|
|
||||||
Set<String> ids(Object? value) =>
|
|
||||||
value is List ? value.whereType<String>().toSet() : <String>{};
|
|
||||||
final comments = <String, List<String>>{};
|
|
||||||
final rawComments = json['comments'];
|
|
||||||
if (rawComments is Map<String, dynamic>) {
|
|
||||||
for (final entry in rawComments.entries) {
|
|
||||||
if (entry.value is List) {
|
|
||||||
comments[entry.key] = (entry.value as List)
|
|
||||||
.whereType<String>()
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return EngagementSnapshot(
|
|
||||||
savedDramaIds: ids(json['saved']),
|
|
||||||
likedDramaIds: ids(json['liked']),
|
|
||||||
commentsByDrama: comments,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return const EngagementSnapshot();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> save(EngagementSnapshot snapshot) => _preferences.setString(
|
|
||||||
_key,
|
|
||||||
jsonEncode({
|
|
||||||
'saved': snapshot.savedDramaIds.toList(),
|
|
||||||
'liked': snapshot.likedDramaIds.toList(),
|
|
||||||
'comments': snapshot.commentsByDrama,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class InMemoryEngagementRepository implements EngagementRepository {
|
|
||||||
EngagementSnapshot snapshot = const EngagementSnapshot();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<EngagementSnapshot> load() async => snapshot;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> save(EngagementSnapshot value) async => snapshot = value;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// 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;
|
||||||
|
bool failRemove = false;
|
||||||
|
int toggleCalls = 0;
|
||||||
|
int listCalls = 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 {
|
||||||
|
listCalls++;
|
||||||
|
if (failList) throw StateError('favorite unavailable');
|
||||||
|
return _cards.where((card) => _saved.contains(card.id)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> remove(String dramaId) async {
|
||||||
|
if (failRemove) throw StateError('favorite unavailable');
|
||||||
|
_saved.remove(dramaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/interaction.dart';
|
||||||
|
import '../remote/interaction_api.dart';
|
||||||
|
|
||||||
|
/// 点赞与举报数据来源抽象。
|
||||||
|
abstract interface class InteractionRepository {
|
||||||
|
/// 切换目标点赞状态。
|
||||||
|
Future<LikeToggleResult> toggleLike({
|
||||||
|
required InteractionTargetType targetType,
|
||||||
|
required String targetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 提交举报并返回工单 ID。
|
||||||
|
Future<String> report(ReportRequest request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 互动仓库。
|
||||||
|
class HttpInteractionRepository implements InteractionRepository {
|
||||||
|
const HttpInteractionRepository(this._api);
|
||||||
|
|
||||||
|
final InteractionApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeToggleResult> toggleLike({
|
||||||
|
required InteractionTargetType targetType,
|
||||||
|
required String targetId,
|
||||||
|
}) => _api.toggleLike(targetType: targetType, targetId: targetId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> report(ReportRequest request) => _api.report(request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/notice.dart';
|
||||||
|
import '../remote/notice_api.dart';
|
||||||
|
|
||||||
|
/// Notice data source used by the page and controller.
|
||||||
|
abstract interface class NoticeRepository {
|
||||||
|
/// Loads one notice page.
|
||||||
|
Future<NoticePage> page({required int pageIndex, int pageSize = 20});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP-backed notice repository.
|
||||||
|
class HttpNoticeRepository implements NoticeRepository {
|
||||||
|
const HttpNoticeRepository(this._api);
|
||||||
|
|
||||||
|
final NoticeApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<NoticePage> page({required int pageIndex, int pageSize = 20}) =>
|
||||||
|
_api.page(pageIndex: pageIndex, pageSize: pageSize);
|
||||||
|
}
|
||||||
@@ -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,67 +1,156 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import '../models/watch_progress.dart';
|
||||||
|
import '../remote/watch_api.dart';
|
||||||
const demoProfileId = 'demo-local-v1';
|
|
||||||
|
|
||||||
|
/// 观看进度来源。
|
||||||
|
///
|
||||||
|
/// **服务端按 `(user, drama)` 只留一条续看记录**(契约 §1.5),所以这里不再是
|
||||||
|
/// "本地按 profile+剧+集存位置",而是"这部剧看到哪一集、哪一秒"。
|
||||||
|
/// 本地实现已删除:进度以服务端为准,换设备也能续看。
|
||||||
abstract interface class WatchHistoryRepository {
|
abstract interface class WatchHistoryRepository {
|
||||||
Future<Duration?> positionFor(
|
/// 某部剧的续看记录;没有记录返回 null(不是异常)。
|
||||||
String profileId,
|
Future<WatchProgress?> progressFor(String dramaId);
|
||||||
String dramaId,
|
|
||||||
String episodeId,
|
/// 上报进度(客户端每 10 秒一次,切集/退出/切后台各补一次)。
|
||||||
);
|
Future<WatchProgress?> save({
|
||||||
Future<String?> lastEpisodeId(String profileId, String dramaId);
|
required String episodeId,
|
||||||
Future<void> save(
|
required Duration position,
|
||||||
String profileId,
|
Duration? duration,
|
||||||
String dramaId,
|
bool finished = false,
|
||||||
String episodeId,
|
});
|
||||||
Duration position,
|
|
||||||
);
|
/// 最近观看(默认 20 条)。
|
||||||
|
Future<List<WatchProgress>> recent({int limit = 20});
|
||||||
|
|
||||||
|
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
|
||||||
|
Future<void> remove(String dramaId);
|
||||||
|
|
||||||
|
/// 清空当前账号的全部观看记录。
|
||||||
|
Future<void> clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
class SharedPreferencesWatchHistoryRepository
|
class HttpWatchHistoryRepository implements WatchHistoryRepository {
|
||||||
implements WatchHistoryRepository {
|
const HttpWatchHistoryRepository(this._api, {this.isSignedIn});
|
||||||
SharedPreferencesWatchHistoryRepository({SharedPreferencesAsync? preferences})
|
|
||||||
: _preferences = preferences ?? SharedPreferencesAsync();
|
|
||||||
|
|
||||||
final SharedPreferencesAsync _preferences;
|
final WatchApi _api;
|
||||||
|
|
||||||
String _positionKey(String profile, String drama, String episode) =>
|
/// 未登录时这些接口必然 401:直接短路成"没有记录",让浏览/试看保持匿名可用。
|
||||||
'watch.v1.$profile.$drama.$episode.positionMs';
|
final bool Function()? isSignedIn;
|
||||||
String _lastKey(String profile, String drama) =>
|
|
||||||
'watch.v1.$profile.$drama.lastEpisode';
|
bool get _signedIn => isSignedIn?.call() ?? true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Duration?> positionFor(
|
Future<WatchProgress?> progressFor(String dramaId) async {
|
||||||
String profileId,
|
if (!_signedIn) return null;
|
||||||
String dramaId,
|
return _api.progressOfDrama(dramaId);
|
||||||
String episodeId,
|
|
||||||
) async {
|
|
||||||
final milliseconds = await _preferences.getInt(
|
|
||||||
_positionKey(profileId, dramaId, episodeId),
|
|
||||||
);
|
|
||||||
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String?> lastEpisodeId(String profileId, String dramaId) =>
|
Future<WatchProgress?> save({
|
||||||
_preferences.getString(_lastKey(profileId, dramaId));
|
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
|
@override
|
||||||
Future<void> save(
|
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
||||||
String profileId,
|
if (!_signedIn) return const [];
|
||||||
String dramaId,
|
return _api.recent(limit: limit);
|
||||||
String episodeId,
|
}
|
||||||
Duration position,
|
|
||||||
) async {
|
@override
|
||||||
await _preferences.setInt(
|
Future<void> remove(String dramaId) async {
|
||||||
_positionKey(profileId, dramaId, episodeId),
|
if (!_signedIn) return;
|
||||||
position.inMilliseconds,
|
await _api.removeProgress(dramaId);
|
||||||
);
|
}
|
||||||
await _preferences.setString(_lastKey(profileId, dramaId), episodeId);
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {
|
||||||
|
if (!_signedIn) return;
|
||||||
|
await _api.clearProgress();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 内存实现:单测与 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);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async => _records.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。
|
||||||
|
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) {
|
Duration clampResumePosition(Duration saved, Duration duration) {
|
||||||
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
|
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
|
||||||
if (saved <= Duration.zero) 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,28 +1,67 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
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 {
|
class CatalogController extends ChangeNotifier {
|
||||||
CatalogController(this.repository);
|
CatalogController(this.repository);
|
||||||
|
|
||||||
final CatalogRepository repository;
|
final CatalogRepository repository;
|
||||||
|
|
||||||
List<Drama> dramas = const [];
|
HomeFeed feed = const HomeFeed();
|
||||||
bool loading = false;
|
bool loading = false;
|
||||||
String? error;
|
String? error;
|
||||||
|
|
||||||
|
List<Drama> get dramas => feed.dramas;
|
||||||
|
List<FeedBanner> get banners => feed.banners;
|
||||||
|
List<FeedSection> get sections => feed.sections;
|
||||||
|
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
dramas = await repository.fetchDramas();
|
feed = await repository.fetchHomeFeed();
|
||||||
|
} on ApiException catch (exception) {
|
||||||
|
error = exception.message;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
error = 'The catalog could not be loaded.';
|
error = 'catalogError';
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
notifyListeners();
|
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,82 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/content_category.dart';
|
||||||
|
import '../models/drama.dart';
|
||||||
|
import '../models/home_feed.dart';
|
||||||
|
import '../repositories/category_repository.dart';
|
||||||
|
|
||||||
|
/// 发现页分类状态:服务端分类清单、发现 feed 与当前筛选。
|
||||||
|
class CategoryController extends ChangeNotifier {
|
||||||
|
CategoryController(this._repository);
|
||||||
|
|
||||||
|
static const String allCode = 'all';
|
||||||
|
static const String hotCode = 'hot';
|
||||||
|
static const String newCode = 'new';
|
||||||
|
|
||||||
|
final CategoryRepository _repository;
|
||||||
|
|
||||||
|
List<ContentCategory> categories = const [];
|
||||||
|
HomeFeed feed = const HomeFeed();
|
||||||
|
String selectedCode = allCode;
|
||||||
|
bool loading = false;
|
||||||
|
String? error;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// 当前筛选对应的分区 code;全部分类为空字符串语义。
|
||||||
|
bool get selectedSectionMissing =>
|
||||||
|
selectedCode != allCode && _selectedSection == null;
|
||||||
|
|
||||||
|
/// 当前筛选应展示的剧集;全部分类按 feed 顺序去重。
|
||||||
|
List<Drama> get visibleDramas {
|
||||||
|
if (selectedCode == allCode) return feed.dramas;
|
||||||
|
return _selectedSection?.dramas ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
FeedSection? get _selectedSection {
|
||||||
|
for (final section in feed.sections) {
|
||||||
|
if (section.code == selectedCode) return section;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载分类与发现 feed;失败保留可重试错误态。
|
||||||
|
Future<void> load() async {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final results = await Future.wait<Object>([
|
||||||
|
_repository.fetchCategories(),
|
||||||
|
_repository.fetchDiscoverFeed(),
|
||||||
|
]);
|
||||||
|
categories = results[0] as List<ContentCategory>;
|
||||||
|
feed = results[1] as HomeFeed;
|
||||||
|
} catch (_) {
|
||||||
|
error = 'categoriesError';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择服务端分类,内容只匹配 `category-{id}`。
|
||||||
|
void selectCategory(String id) => selectCode('category-$id');
|
||||||
|
|
||||||
|
/// 选择内置或分区 code。
|
||||||
|
void selectCode(String code) {
|
||||||
|
selectedCode = code;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/comment.dart';
|
||||||
|
import '../models/interaction.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
import '../repositories/comment_repository.dart';
|
||||||
|
import '../repositories/interaction_repository.dart';
|
||||||
|
|
||||||
|
/// 评论列表、发布、回复、删除与评论点赞的状态控制器。
|
||||||
|
class CommentController extends ChangeNotifier {
|
||||||
|
CommentController({
|
||||||
|
required CommentRepository comments,
|
||||||
|
required InteractionRepository interactions,
|
||||||
|
required this.dramaId,
|
||||||
|
this.episodeId,
|
||||||
|
}) : _commentRepository = comments,
|
||||||
|
_interactionRepository = interactions;
|
||||||
|
|
||||||
|
final CommentRepository _commentRepository;
|
||||||
|
final InteractionRepository _interactionRepository;
|
||||||
|
final String dramaId;
|
||||||
|
final String? episodeId;
|
||||||
|
final List<DramaComment> _comments = [];
|
||||||
|
final Map<String, List<DramaComment>> _replies = {};
|
||||||
|
final Map<String, String?> _replyCursors = {};
|
||||||
|
final Map<String, bool> _replyHasMore = {};
|
||||||
|
final Set<String> _expandedRoots = {};
|
||||||
|
final Set<String> _loadingReplies = {};
|
||||||
|
final Set<String> _loadingMoreReplies = {};
|
||||||
|
final Set<String> _likingCommentIds = {};
|
||||||
|
final Set<String> _deletingCommentIds = {};
|
||||||
|
bool _disposed = false;
|
||||||
|
bool loading = false;
|
||||||
|
bool loadingMore = false;
|
||||||
|
bool submitting = false;
|
||||||
|
bool hasMore = false;
|
||||||
|
String? nextCursor;
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
/// 当前已加载的顶层评论。
|
||||||
|
List<DramaComment> get comments => List.unmodifiable(_comments);
|
||||||
|
|
||||||
|
/// 某一楼层已加载的回复。
|
||||||
|
List<DramaComment> repliesFor(String rootId) =>
|
||||||
|
List.unmodifiable(_replies[rootId] ?? const []);
|
||||||
|
|
||||||
|
/// 楼层回复是否已展开。
|
||||||
|
bool repliesExpanded(String rootId) => _expandedRoots.contains(rootId);
|
||||||
|
|
||||||
|
/// 楼层回复是否正在首屏加载。
|
||||||
|
bool repliesLoading(String rootId) => _loadingReplies.contains(rootId);
|
||||||
|
|
||||||
|
/// 楼层回复是否还有下一页。
|
||||||
|
bool repliesHasMore(String rootId) => _replyHasMore[rootId] ?? false;
|
||||||
|
|
||||||
|
/// 评论是否正在提交点赞,用于防连点。
|
||||||
|
bool likingComment(String id) => _likingCommentIds.contains(id);
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载首屏评论,成功后替换当前顶层列表。
|
||||||
|
Future<void> load() async {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _commentRepository.page(
|
||||||
|
dramaId: dramaId,
|
||||||
|
size: commentPageSize,
|
||||||
|
);
|
||||||
|
_comments
|
||||||
|
..clear()
|
||||||
|
..addAll(page.comments);
|
||||||
|
nextCursor = page.nextCursor;
|
||||||
|
hasMore = page.hasMore;
|
||||||
|
} catch (failure) {
|
||||||
|
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 `nextCursor` 续拉顶层评论。
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
final cursor = nextCursor;
|
||||||
|
if (loadingMore || !hasMore || cursor == null) return;
|
||||||
|
loadingMore = true;
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _commentRepository.page(
|
||||||
|
dramaId: dramaId,
|
||||||
|
cursor: cursor,
|
||||||
|
size: commentPageSize,
|
||||||
|
);
|
||||||
|
_comments.addAll(page.comments);
|
||||||
|
nextCursor = page.nextCursor;
|
||||||
|
hasMore = page.hasMore;
|
||||||
|
} catch (failure) {
|
||||||
|
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||||
|
} finally {
|
||||||
|
loadingMore = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展开并用 `rootId` 加载一层楼层回复。
|
||||||
|
Future<void> loadReplies(String rootId) async {
|
||||||
|
_expandedRoots.add(rootId);
|
||||||
|
if (_replies.containsKey(rootId) || _loadingReplies.contains(rootId)) {
|
||||||
|
_notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_loadingReplies.add(rootId);
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _commentRepository.page(
|
||||||
|
dramaId: dramaId,
|
||||||
|
rootId: rootId,
|
||||||
|
size: commentPageSize,
|
||||||
|
);
|
||||||
|
_replies[rootId] = List.of(page.comments);
|
||||||
|
_replyCursors[rootId] = page.nextCursor;
|
||||||
|
_replyHasMore[rootId] = page.hasMore;
|
||||||
|
} catch (failure) {
|
||||||
|
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||||
|
} finally {
|
||||||
|
_loadingReplies.remove(rootId);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收起楼层回复,不丢弃已加载数据。
|
||||||
|
void hideReplies(String rootId) {
|
||||||
|
if (_expandedRoots.remove(rootId)) _notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 续拉楼层回复。
|
||||||
|
Future<void> loadMoreReplies(String rootId) async {
|
||||||
|
final cursor = _replyCursors[rootId];
|
||||||
|
if (_loadingMoreReplies.contains(rootId) || cursor == null) return;
|
||||||
|
if (!(_replyHasMore[rootId] ?? false)) return;
|
||||||
|
_loadingMoreReplies.add(rootId);
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _commentRepository.page(
|
||||||
|
dramaId: dramaId,
|
||||||
|
rootId: rootId,
|
||||||
|
cursor: cursor,
|
||||||
|
size: commentPageSize,
|
||||||
|
);
|
||||||
|
(_replies[rootId] ??= []).addAll(page.comments);
|
||||||
|
_replyCursors[rootId] = page.nextCursor;
|
||||||
|
_replyHasMore[rootId] = page.hasMore;
|
||||||
|
} catch (failure) {
|
||||||
|
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||||
|
} finally {
|
||||||
|
_loadingMoreReplies.remove(rootId);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布评论或回复,输入框是否清空由调用方根据成功结果决定。
|
||||||
|
Future<OperationResult<DramaComment>> publish(
|
||||||
|
String rawContent, {
|
||||||
|
DramaComment? replyTo,
|
||||||
|
}) async {
|
||||||
|
final content = rawContent.trim();
|
||||||
|
if (content.isEmpty || submitting) {
|
||||||
|
return const OperationResult.failure(null);
|
||||||
|
}
|
||||||
|
submitting = true;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final comment = await _commentRepository.publish(
|
||||||
|
dramaId: dramaId,
|
||||||
|
episodeId: episodeId,
|
||||||
|
content: content,
|
||||||
|
parentId: replyTo?.id,
|
||||||
|
);
|
||||||
|
_insertPublished(comment, replyTo);
|
||||||
|
return OperationResult.success(comment);
|
||||||
|
} catch (failure) {
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 乐观删除自己的评论,失败时恢复到原位置。
|
||||||
|
Future<OperationResult<void>> delete(DramaComment comment) async {
|
||||||
|
if (!comment.mine || _deletingCommentIds.contains(comment.id)) {
|
||||||
|
return const OperationResult.failure(null);
|
||||||
|
}
|
||||||
|
_deletingCommentIds.add(comment.id);
|
||||||
|
final location = _removeComment(comment.id);
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
await _commentRepository.delete(comment.id);
|
||||||
|
return const OperationResult.success();
|
||||||
|
} catch (failure) {
|
||||||
|
_restoreComment(comment, location);
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
} finally {
|
||||||
|
_deletingCommentIds.remove(comment.id);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论点赞先乐观更新,再用服务端 `{liked,count}` 校准。
|
||||||
|
Future<OperationResult<LikeToggleResult>> toggleLike(
|
||||||
|
DramaComment comment,
|
||||||
|
) async {
|
||||||
|
if (_likingCommentIds.contains(comment.id)) {
|
||||||
|
return const OperationResult.failure(null);
|
||||||
|
}
|
||||||
|
final current = _findComment(comment.id) ?? comment;
|
||||||
|
_likingCommentIds.add(comment.id);
|
||||||
|
_replaceComment(
|
||||||
|
current.copyWith(
|
||||||
|
liked: !current.liked,
|
||||||
|
likeCount: current.liked
|
||||||
|
? (current.likeCount > 0 ? current.likeCount - 1 : 0)
|
||||||
|
: current.likeCount + 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final result = await _interactionRepository.toggleLike(
|
||||||
|
targetType: InteractionTargetType.comment,
|
||||||
|
targetId: comment.id,
|
||||||
|
);
|
||||||
|
_replaceComment(
|
||||||
|
current.copyWith(liked: result.liked, likeCount: result.count),
|
||||||
|
);
|
||||||
|
return OperationResult.success(result);
|
||||||
|
} catch (failure) {
|
||||||
|
_replaceComment(current);
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
} finally {
|
||||||
|
_likingCommentIds.remove(comment.id);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报评论,失败消息原样返回给界面展示。
|
||||||
|
Future<OperationResult<String>> report(
|
||||||
|
DramaComment comment, {
|
||||||
|
required ReportReason reason,
|
||||||
|
String description = '',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final reportId = await _interactionRepository.report(
|
||||||
|
ReportRequest(
|
||||||
|
targetType: InteractionTargetType.comment,
|
||||||
|
targetId: comment.id,
|
||||||
|
reasonCode: reason.apiValue,
|
||||||
|
description: description.trim(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return OperationResult.success(reportId);
|
||||||
|
} catch (failure) {
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _insertPublished(DramaComment comment, DramaComment? replyTo) {
|
||||||
|
final rootId = comment.rootId != '0'
|
||||||
|
? comment.rootId
|
||||||
|
: replyTo?.isTopLevel == true
|
||||||
|
? replyTo!.id
|
||||||
|
: replyTo?.rootId;
|
||||||
|
if (replyTo == null || rootId == null || rootId == '0') {
|
||||||
|
if (!_comments.any((item) => item.id == comment.id)) {
|
||||||
|
_comments.insert(0, comment);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final replies = _replies.putIfAbsent(rootId, () => []);
|
||||||
|
if (!replies.any((item) => item.id == comment.id)) {
|
||||||
|
replies.insert(0, comment);
|
||||||
|
}
|
||||||
|
_expandedRoots.add(rootId);
|
||||||
|
final rootIndex = _comments.indexWhere((item) => item.id == rootId);
|
||||||
|
if (rootIndex >= 0) {
|
||||||
|
final root = _comments[rootIndex];
|
||||||
|
_comments[rootIndex] = root.copyWith(replyCount: root.replyCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_CommentLocation? _removeComment(String id) {
|
||||||
|
final topIndex = _comments.indexWhere((item) => item.id == id);
|
||||||
|
if (topIndex >= 0) {
|
||||||
|
final removed = _comments.removeAt(topIndex);
|
||||||
|
return _CommentLocation(comment: removed, topIndex: topIndex);
|
||||||
|
}
|
||||||
|
for (final entry in _replies.entries) {
|
||||||
|
final index = entry.value.indexWhere((item) => item.id == id);
|
||||||
|
if (index < 0) continue;
|
||||||
|
final removed = entry.value.removeAt(index);
|
||||||
|
return _CommentLocation(
|
||||||
|
comment: removed,
|
||||||
|
rootId: entry.key,
|
||||||
|
replyIndex: index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restoreComment(DramaComment comment, _CommentLocation? location) {
|
||||||
|
if (location == null) return;
|
||||||
|
if (location.topIndex != null) {
|
||||||
|
final index = location.topIndex!.clamp(0, _comments.length);
|
||||||
|
_comments.insert(index, comment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final replies = _replies.putIfAbsent(location.rootId!, () => []);
|
||||||
|
final index = location.replyIndex!.clamp(0, replies.length);
|
||||||
|
replies.insert(index, comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
DramaComment? _findComment(String id) {
|
||||||
|
for (final comment in _comments) {
|
||||||
|
if (comment.id == id) return comment;
|
||||||
|
}
|
||||||
|
for (final replies in _replies.values) {
|
||||||
|
for (final comment in replies) {
|
||||||
|
if (comment.id == id) return comment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _replaceComment(DramaComment replacement) {
|
||||||
|
final topIndex = _comments.indexWhere((item) => item.id == replacement.id);
|
||||||
|
if (topIndex >= 0) {
|
||||||
|
_comments[topIndex] = replacement;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final replies in _replies.values) {
|
||||||
|
final index = replies.indexWhere((item) => item.id == replacement.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
replies[index] = replacement;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _errorMessage(Object failure) =>
|
||||||
|
failure is ApiException ? failure.message : null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CommentLocation {
|
||||||
|
const _CommentLocation({
|
||||||
|
required this.comment,
|
||||||
|
this.topIndex,
|
||||||
|
this.rootId,
|
||||||
|
this.replyIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DramaComment comment;
|
||||||
|
final int? topIndex;
|
||||||
|
final String? rootId;
|
||||||
|
final int? replyIndex;
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/download_license.dart';
|
||||||
|
import '../models/download_policy.dart';
|
||||||
|
import '../remote/api_exception.dart';
|
||||||
|
import '../repositories/download_repository.dart';
|
||||||
|
|
||||||
|
/// 一次下载授权申请的结果;后端业务拒绝时保留原始 message。
|
||||||
|
class DownloadActionResult {
|
||||||
|
const DownloadActionResult({required this.ok, this.license, this.message});
|
||||||
|
|
||||||
|
final bool ok;
|
||||||
|
final DownloadLicense? license;
|
||||||
|
final String? message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载授权与策略状态。
|
||||||
|
class DownloadController extends ChangeNotifier {
|
||||||
|
DownloadController(this._repository);
|
||||||
|
|
||||||
|
final DownloadRepository _repository;
|
||||||
|
|
||||||
|
DownloadPolicy? policy;
|
||||||
|
List<DownloadLicense> licenses = const [];
|
||||||
|
bool loading = false;
|
||||||
|
bool clearing = false;
|
||||||
|
String? error;
|
||||||
|
String? actionError;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// 同时拉取策略与当前设备授权列表。
|
||||||
|
Future<void> load() async {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final results = await Future.wait<Object>([
|
||||||
|
_repository.fetchPolicy(),
|
||||||
|
_repository.list(),
|
||||||
|
]);
|
||||||
|
policy = results[0] as DownloadPolicy;
|
||||||
|
licenses = results[1] as List<DownloadLicense>;
|
||||||
|
} catch (_) {
|
||||||
|
error = 'downloadsError';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。
|
||||||
|
Future<DownloadActionResult> grant(
|
||||||
|
String episodeId, {
|
||||||
|
String? quality,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final license = await _repository.grant(episodeId, quality: quality);
|
||||||
|
licenses = [
|
||||||
|
license,
|
||||||
|
for (final item in licenses)
|
||||||
|
if (item.episodeId != license.episodeId) item,
|
||||||
|
];
|
||||||
|
actionError = null;
|
||||||
|
_notify();
|
||||||
|
return DownloadActionResult(ok: true, license: license);
|
||||||
|
} on ApiException catch (exception) {
|
||||||
|
actionError = exception.message;
|
||||||
|
_notify();
|
||||||
|
return DownloadActionResult(ok: false, message: exception.message);
|
||||||
|
} catch (_) {
|
||||||
|
actionError = 'downloadGrantFailed';
|
||||||
|
_notify();
|
||||||
|
return const DownloadActionResult(ok: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新设置,先本地展示,失败恢复服务端旧值。
|
||||||
|
Future<bool> updateSettings({bool? wifiOnly, String? maxQuality}) async {
|
||||||
|
final previous = policy;
|
||||||
|
if (previous != null) {
|
||||||
|
policy = previous.copyWith(wifiOnly: wifiOnly, maxQuality: maxQuality);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _repository.updateSettings(
|
||||||
|
wifiOnly: wifiOnly,
|
||||||
|
maxQuality: maxQuality,
|
||||||
|
);
|
||||||
|
actionError = null;
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
policy = previous;
|
||||||
|
actionError = 'downloadSettingsFailed';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除单条授权,失败按原位置恢复。
|
||||||
|
Future<bool> remove(String episodeId) async {
|
||||||
|
final index = licenses.indexWhere((item) => item.episodeId == episodeId);
|
||||||
|
if (index < 0) return true;
|
||||||
|
final removed = licenses[index];
|
||||||
|
licenses = [...licenses]..removeAt(index);
|
||||||
|
actionError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
await _repository.remove(episodeId);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
final restored = [...licenses];
|
||||||
|
restored.insert(
|
||||||
|
index > restored.length ? restored.length : index,
|
||||||
|
removed,
|
||||||
|
);
|
||||||
|
licenses = restored;
|
||||||
|
actionError = 'downloadDeleteFailed';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空授权,失败恢复原列表。
|
||||||
|
Future<bool> clear() async {
|
||||||
|
if (licenses.isEmpty) return true;
|
||||||
|
final previous = licenses;
|
||||||
|
licenses = const [];
|
||||||
|
clearing = true;
|
||||||
|
actionError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
await _repository.clear();
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
licenses = previous;
|
||||||
|
actionError = 'downloadClearFailed';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearing = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 退出登录时清空账号相关状态,避免跨账号串联。
|
||||||
|
void reset() {
|
||||||
|
policy = null;
|
||||||
|
licenses = const [];
|
||||||
|
loading = false;
|
||||||
|
clearing = false;
|
||||||
|
error = null;
|
||||||
|
actionError = null;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
import '../models/engagement_snapshot.dart';
|
|
||||||
import '../repositories/engagement_repository.dart';
|
|
||||||
|
|
||||||
/// Local demo interactions, indexed by drama ID. No public/social backend.
|
|
||||||
class EngagementController extends ChangeNotifier {
|
|
||||||
EngagementController(this._repository);
|
|
||||||
|
|
||||||
final EngagementRepository _repository;
|
|
||||||
final Set<String> _saved = {};
|
|
||||||
final Set<String> _liked = {};
|
|
||||||
final Map<String, List<String>> _comments = {};
|
|
||||||
Future<void> _loadFuture = Future<void>.value();
|
|
||||||
Future<void> _pendingSave = Future<void>.value();
|
|
||||||
|
|
||||||
bool isSaved(String dramaId) => _saved.contains(dramaId);
|
|
||||||
bool isLiked(String dramaId) => _liked.contains(dramaId);
|
|
||||||
List<String> commentsFor(String dramaId) =>
|
|
||||||
List.unmodifiable(_comments[dramaId] ?? const []);
|
|
||||||
|
|
||||||
Future<void> load() => _loadFuture = _loadFromRepository();
|
|
||||||
|
|
||||||
Future<void> _loadFromRepository() async {
|
|
||||||
try {
|
|
||||||
final snapshot = await _repository.load();
|
|
||||||
_saved.addAll(snapshot.savedDramaIds);
|
|
||||||
_liked.addAll(snapshot.likedDramaIds);
|
|
||||||
for (final entry in snapshot.commentsByDrama.entries) {
|
|
||||||
_comments[entry.key] = List.of(entry.value);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
} catch (_) {
|
|
||||||
// Local preferences are best effort for this demo.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> toggleSaved(String dramaId) async {
|
|
||||||
await _loadFuture;
|
|
||||||
if (!_saved.add(dramaId)) _saved.remove(dramaId);
|
|
||||||
notifyListeners();
|
|
||||||
await _persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> toggleLiked(String dramaId) async {
|
|
||||||
await _loadFuture;
|
|
||||||
if (!_liked.add(dramaId)) _liked.remove(dramaId);
|
|
||||||
notifyListeners();
|
|
||||||
await _persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addComment(String dramaId, String text) async {
|
|
||||||
await _loadFuture;
|
|
||||||
final clean = text.trim();
|
|
||||||
if (clean.isEmpty) return;
|
|
||||||
(_comments[dramaId] ??= []).add(
|
|
||||||
clean.length > 300 ? clean.substring(0, 300) : clean,
|
|
||||||
);
|
|
||||||
notifyListeners();
|
|
||||||
await _persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _persist() {
|
|
||||||
final snapshot = EngagementSnapshot(
|
|
||||||
savedDramaIds: Set.of(_saved),
|
|
||||||
likedDramaIds: Set.of(_liked),
|
|
||||||
commentsByDrama: {
|
|
||||||
for (final entry in _comments.entries) entry.key: List.of(entry.value),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
_pendingSave = _pendingSave
|
|
||||||
.then((_) => _repository.save(snapshot))
|
|
||||||
.catchError((Object _) {
|
|
||||||
// Keep the current in-memory state if local persistence fails.
|
|
||||||
});
|
|
||||||
return _pendingSave;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// 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 index = cards.indexWhere((drama) => drama.id == dramaId);
|
||||||
|
if (index < 0) return true;
|
||||||
|
final removed = cards[index];
|
||||||
|
_saved.remove(dramaId);
|
||||||
|
final next = [...cards]..removeAt(index);
|
||||||
|
cards = next;
|
||||||
|
notify();
|
||||||
|
try {
|
||||||
|
await _repository.remove(dramaId);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
final restored = [...cards];
|
||||||
|
restored.insert(
|
||||||
|
index > restored.length ? restored.length : index,
|
||||||
|
removed,
|
||||||
|
);
|
||||||
|
cards = restored;
|
||||||
|
_saved.add(dramaId);
|
||||||
|
notify();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
|
||||||
|
void clear() {
|
||||||
|
_saved.clear();
|
||||||
|
cards = const [];
|
||||||
|
error = null;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/interaction.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
import '../repositories/interaction_repository.dart';
|
||||||
|
|
||||||
|
/// 剧集点赞与举报的会话状态控制器。
|
||||||
|
///
|
||||||
|
/// 剧集契约暂未下发初始点赞状态与计数,本批只维护当前会话内的 liked 状态,
|
||||||
|
/// 不展示数字,也不伪造初始值。
|
||||||
|
class InteractionController extends ChangeNotifier {
|
||||||
|
InteractionController(this._repository);
|
||||||
|
|
||||||
|
final InteractionRepository _repository;
|
||||||
|
final Set<String> _likedDramaIds = {};
|
||||||
|
final Set<String> _busyDramaIds = {};
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// 当前会话内该剧是否已点赞。
|
||||||
|
bool isLiked(String dramaId) => _likedDramaIds.contains(dramaId);
|
||||||
|
|
||||||
|
/// 点赞请求是否进行中,用于防连点。
|
||||||
|
bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId);
|
||||||
|
|
||||||
|
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
|
||||||
|
Future<OperationResult<LikeToggleResult>> toggleDramaLike(
|
||||||
|
String dramaId,
|
||||||
|
) async {
|
||||||
|
if (_busyDramaIds.contains(dramaId)) {
|
||||||
|
return const OperationResult.failure(null);
|
||||||
|
}
|
||||||
|
final wasLiked = _likedDramaIds.contains(dramaId);
|
||||||
|
_busyDramaIds.add(dramaId);
|
||||||
|
if (wasLiked) {
|
||||||
|
_likedDramaIds.remove(dramaId);
|
||||||
|
} else {
|
||||||
|
_likedDramaIds.add(dramaId);
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final result = await _repository.toggleLike(
|
||||||
|
targetType: InteractionTargetType.drama,
|
||||||
|
targetId: dramaId,
|
||||||
|
);
|
||||||
|
if (result.liked) {
|
||||||
|
_likedDramaIds.add(dramaId);
|
||||||
|
} else {
|
||||||
|
_likedDramaIds.remove(dramaId);
|
||||||
|
}
|
||||||
|
return OperationResult.success(result);
|
||||||
|
} catch (failure) {
|
||||||
|
if (wasLiked) {
|
||||||
|
_likedDramaIds.add(dramaId);
|
||||||
|
} else {
|
||||||
|
_likedDramaIds.remove(dramaId);
|
||||||
|
}
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
} finally {
|
||||||
|
_busyDramaIds.remove(dramaId);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报剧集,失败消息原样返回给界面。
|
||||||
|
Future<OperationResult<String>> reportDrama(
|
||||||
|
String dramaId, {
|
||||||
|
required ReportReason reason,
|
||||||
|
String description = '',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final reportId = await _repository.report(
|
||||||
|
ReportRequest(
|
||||||
|
targetType: InteractionTargetType.drama,
|
||||||
|
targetId: dramaId,
|
||||||
|
reasonCode: reason.apiValue,
|
||||||
|
description: description.trim(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return OperationResult.success(reportId);
|
||||||
|
} catch (failure) {
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录账号变化时清理会话内状态,避免跨账号串联。
|
||||||
|
void clear() {
|
||||||
|
if (_likedDramaIds.isEmpty && _busyDramaIds.isEmpty) return;
|
||||||
|
_likedDramaIds.clear();
|
||||||
|
_busyDramaIds.clear();
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _errorMessage(Object failure) =>
|
||||||
|
failure is ApiException ? failure.message : null;
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/notice.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
import '../repositories/notice_repository.dart';
|
||||||
|
|
||||||
|
/// Pagination and state controller for the anonymous notices page.
|
||||||
|
class NoticesController extends ChangeNotifier {
|
||||||
|
NoticesController(this._repository, {this.pageSize = 20});
|
||||||
|
|
||||||
|
final NoticeRepository _repository;
|
||||||
|
final int pageSize;
|
||||||
|
final List<AppNotice> _notices = [];
|
||||||
|
int _pageIndex = 0;
|
||||||
|
int _total = 0;
|
||||||
|
bool _disposed = false;
|
||||||
|
bool loading = false;
|
||||||
|
bool loadingMore = false;
|
||||||
|
bool refreshing = false;
|
||||||
|
String? error;
|
||||||
|
String? loadMoreError;
|
||||||
|
|
||||||
|
/// Loaded notices in server order.
|
||||||
|
List<AppNotice> get notices => List.unmodifiable(_notices);
|
||||||
|
|
||||||
|
/// Whether another page is expected from the latest total.
|
||||||
|
bool get hasMore => _notices.length < _total;
|
||||||
|
|
||||||
|
/// Loads the first page.
|
||||||
|
Future<void> load() async {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
loadMoreError = null;
|
||||||
|
_notify();
|
||||||
|
await _replaceFirstPage();
|
||||||
|
loading = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes from page one while keeping the current page visible.
|
||||||
|
Future<void> refresh() async {
|
||||||
|
refreshing = true;
|
||||||
|
error = null;
|
||||||
|
loadMoreError = null;
|
||||||
|
_notify();
|
||||||
|
await _replaceFirstPage();
|
||||||
|
refreshing = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends the next page when loaded count is below total.
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (loading || loadingMore || refreshing || !hasMore) return;
|
||||||
|
loadingMore = true;
|
||||||
|
loadMoreError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _repository.page(
|
||||||
|
pageIndex: _pageIndex + 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
);
|
||||||
|
_appendUnique(page.notices);
|
||||||
|
_pageIndex++;
|
||||||
|
_total = page.total;
|
||||||
|
} catch (failure, stackTrace) {
|
||||||
|
debugPrint('[NoticesController] load more failed: $failure');
|
||||||
|
debugPrintStack(
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
label: '[NoticesController] load more failed',
|
||||||
|
);
|
||||||
|
loadMoreError = _message(failure) ?? 'noticesLoadFailed';
|
||||||
|
} finally {
|
||||||
|
loadingMore = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _replaceFirstPage() async {
|
||||||
|
try {
|
||||||
|
final page = await _repository.page(pageIndex: 1, pageSize: pageSize);
|
||||||
|
if (_disposed) return;
|
||||||
|
_notices
|
||||||
|
..clear()
|
||||||
|
..addAll(page.notices);
|
||||||
|
_pageIndex = 1;
|
||||||
|
_total = page.total;
|
||||||
|
} catch (failure, stackTrace) {
|
||||||
|
debugPrint('[NoticesController] load failed: $failure');
|
||||||
|
debugPrintStack(
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
label: '[NoticesController] load failed',
|
||||||
|
);
|
||||||
|
if (!_disposed) error = _message(failure) ?? 'noticesLoadFailed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _appendUnique(List<AppNotice> incoming) {
|
||||||
|
final ids = _notices.map((notice) => notice.id).toSet();
|
||||||
|
for (final notice in incoming) {
|
||||||
|
if (ids.add(notice.id)) _notices.add(notice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _message(Object failure) =>
|
||||||
|
failure is ApiException ? failure.message : null;
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,6 +130,9 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
if (!slot.retiring && !_closed) slot.prepared = true;
|
if (!slot.retiring && !_closed) slot.prepared = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
slot.failure = error;
|
slot.failure = error;
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('片源准备失败 ${episodes[index].videoUrl}: $error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}();
|
}();
|
||||||
return slot;
|
return slot;
|
||||||
@@ -151,14 +154,23 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
/// use exactly the same mechanism without warming an unrelated episode.
|
/// use exactly the same mechanism without warming an unrelated episode.
|
||||||
Future<void> select(int index, {int? preloadIndex}) async {
|
Future<void> select(int index, {int? preloadIndex}) async {
|
||||||
if (_closed || index < 0 || index >= episodes.length) return;
|
if (_closed || index < 0 || index >= episodes.length) return;
|
||||||
|
// 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。
|
||||||
|
// 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。
|
||||||
|
if (!_selected || index != _currentIndex) {
|
||||||
|
_userWantsPlay = true;
|
||||||
|
}
|
||||||
final request = ++_generation;
|
final request = ++_generation;
|
||||||
_selected = true;
|
_selected = true;
|
||||||
_currentIndex = index;
|
_currentIndex = index;
|
||||||
_nextIndex = preloadIndex;
|
_nextIndex = preloadIndex;
|
||||||
_error = null;
|
_error = null;
|
||||||
|
if (!_visible) {
|
||||||
|
_loading = false;
|
||||||
|
_notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_loading = preparedPlayerAt(index) == null && !isLocked;
|
_loading = preparedPlayerAt(index) == null && !isLocked;
|
||||||
_notify();
|
_notify();
|
||||||
if (!_visible) return;
|
|
||||||
final acquired = await _coordinator._acquire(
|
final acquired = await _coordinator._acquire(
|
||||||
this,
|
this,
|
||||||
() => _visible && request == _generation,
|
() => _visible && request == _generation,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../../config/app_config.dart';
|
||||||
import '../models/drama.dart';
|
import '../models/drama.dart';
|
||||||
import '../models/player_port.dart';
|
import '../models/player_port.dart';
|
||||||
|
|
||||||
@@ -9,11 +10,25 @@ class VideoPlayerPort implements PlayerPort {
|
|||||||
VideoPlayerPort(
|
VideoPlayerPort(
|
||||||
Episode episode, {
|
Episode episode, {
|
||||||
Future<ClosedCaptionFile>? closedCaptionFile,
|
Future<ClosedCaptionFile>? closedCaptionFile,
|
||||||
}) : controller = VideoPlayerController.asset(
|
}) : controller = _open(episode, closedCaptionFile);
|
||||||
episode.assetPath,
|
|
||||||
closedCaptionFile: closedCaptionFile,
|
/// 后端下发的是相对路径(`/files/...`),交给播放器前补上服务地址。
|
||||||
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false),
|
/// 否则 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;
|
final VideoPlayerController controller;
|
||||||
Future<void>? _initialization;
|
Future<void>? _initialization;
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/drama.dart';
|
||||||
|
import '../models/watch_progress.dart';
|
||||||
|
import '../models/watch_record.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
import '../repositories/catalog_repository.dart';
|
||||||
|
import '../repositories/watch_history_repository.dart';
|
||||||
|
|
||||||
|
/// 独立观看记录页状态。
|
||||||
|
///
|
||||||
|
/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。
|
||||||
|
class WatchRecordsController extends ChangeNotifier {
|
||||||
|
factory WatchRecordsController({
|
||||||
|
required WatchHistoryRepository history,
|
||||||
|
required CatalogRepository catalog,
|
||||||
|
int limit = defaultLimit,
|
||||||
|
}) => WatchRecordsController._(history, catalog, limit);
|
||||||
|
|
||||||
|
WatchRecordsController._(this._history, this._catalog, this.limit);
|
||||||
|
|
||||||
|
static const int defaultLimit = 20;
|
||||||
|
static const int detailConcurrency = 4;
|
||||||
|
|
||||||
|
final WatchHistoryRepository _history;
|
||||||
|
final CatalogRepository _catalog;
|
||||||
|
final int limit;
|
||||||
|
final Map<String, Drama> _detailCache = {};
|
||||||
|
|
||||||
|
List<WatchRecord> records = const [];
|
||||||
|
bool loading = false;
|
||||||
|
bool clearing = false;
|
||||||
|
String? error;
|
||||||
|
String? actionError;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
|
||||||
|
Future<void> load() async {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
actionError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final recent = await _history.recent(limit: limit);
|
||||||
|
if (_disposed) return;
|
||||||
|
records = _uniqueRecords(recent);
|
||||||
|
_notify();
|
||||||
|
await _hydrate(records.map((record) => record.dramaId).toList());
|
||||||
|
} catch (failure, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
'[WatchRecordsController] recent failed '
|
||||||
|
'${failure.runtimeType}: $failure',
|
||||||
|
);
|
||||||
|
debugPrintStack(
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
label: '[WatchRecordsController] recent failed',
|
||||||
|
);
|
||||||
|
if (!_disposed) {
|
||||||
|
error = failure is ApiException ? failure.message : 'progressError';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!_disposed) {
|
||||||
|
loading = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只重试一条详情失败记录。
|
||||||
|
Future<void> retryDetail(String dramaId) async {
|
||||||
|
final index = _recordIndex(dramaId);
|
||||||
|
if (index < 0) return;
|
||||||
|
_replaceAt(
|
||||||
|
index,
|
||||||
|
records[index].copyWith(detailLoading: true, detailFailed: false),
|
||||||
|
);
|
||||||
|
_notify();
|
||||||
|
await _loadDetail(dramaId);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除单条记录,先更新 UI,失败按原位置回滚。
|
||||||
|
Future<bool> remove(String dramaId) async {
|
||||||
|
final index = _recordIndex(dramaId);
|
||||||
|
if (index < 0) return true;
|
||||||
|
final removed = records[index];
|
||||||
|
records = [...records]..removeAt(index);
|
||||||
|
actionError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
await _history.remove(dramaId);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
final next = [...records];
|
||||||
|
next.insert(index > next.length ? next.length : index, removed);
|
||||||
|
records = next;
|
||||||
|
actionError = 'recordDeleteFailed';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空全部记录,先更新 UI,失败恢复原列表。
|
||||||
|
Future<bool> clear() async {
|
||||||
|
if (records.isEmpty) return true;
|
||||||
|
final previous = records;
|
||||||
|
records = const [];
|
||||||
|
clearing = true;
|
||||||
|
actionError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
await _history.clear();
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
records = previous;
|
||||||
|
actionError = 'recordClearFailed';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearing = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) {
|
||||||
|
final seen = <String>{};
|
||||||
|
return [
|
||||||
|
for (final progress in recent)
|
||||||
|
if (seen.add(progress.dramaId)) WatchRecord(progress: progress),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _hydrate(List<String> dramaIds) async {
|
||||||
|
final queue = [
|
||||||
|
for (final id in dramaIds)
|
||||||
|
if (!_detailCache.containsKey(id)) id,
|
||||||
|
];
|
||||||
|
final workerCount = queue.length < detailConcurrency
|
||||||
|
? queue.length
|
||||||
|
: detailConcurrency;
|
||||||
|
Future<void> worker() async {
|
||||||
|
while (queue.isNotEmpty && !_disposed) {
|
||||||
|
await _loadDetail(queue.removeAt(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future.wait([
|
||||||
|
for (var index = 0; index < workerCount; index++) worker(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDetail(String dramaId) async {
|
||||||
|
final index = _recordIndex(dramaId);
|
||||||
|
if (index < 0) return;
|
||||||
|
final cached = _detailCache[dramaId];
|
||||||
|
if (cached != null) {
|
||||||
|
_replaceAt(
|
||||||
|
index,
|
||||||
|
records[index].copyWith(
|
||||||
|
drama: cached,
|
||||||
|
detailLoading: false,
|
||||||
|
detailFailed: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final detail = await _catalog.fetchDrama(dramaId);
|
||||||
|
if (_disposed) return;
|
||||||
|
_detailCache[dramaId] = detail;
|
||||||
|
final current = _recordIndex(dramaId);
|
||||||
|
if (current < 0) return;
|
||||||
|
_replaceAt(
|
||||||
|
current,
|
||||||
|
records[current].copyWith(
|
||||||
|
drama: detail,
|
||||||
|
detailLoading: false,
|
||||||
|
detailFailed: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (failure, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
||||||
|
'${failure.runtimeType}: $failure',
|
||||||
|
);
|
||||||
|
debugPrintStack(
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||||
|
);
|
||||||
|
if (_disposed) return;
|
||||||
|
final current = _recordIndex(dramaId);
|
||||||
|
if (current < 0) return;
|
||||||
|
_replaceAt(
|
||||||
|
current,
|
||||||
|
records[current].copyWith(detailLoading: false, detailFailed: true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _recordIndex(String dramaId) {
|
||||||
|
for (var index = 0; index < records.length; index++) {
|
||||||
|
if (records[index].dramaId == dramaId) return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _replaceAt(int index, WatchRecord record) {
|
||||||
|
final next = [...records];
|
||||||
|
next[index] = record;
|
||||||
|
records = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// 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,
|
||||||
|
'commentsLoadFailed' => l10n.commentsLoadFailed,
|
||||||
|
'noticesLoadFailed' => l10n.noticesLoadFailed,
|
||||||
|
final value => value,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Formats a timestamp with the active Material locale and 12/24-hour setting.
|
||||||
|
String formatLocalizedDateTime(BuildContext context, DateTime value) {
|
||||||
|
final localizations = MaterialLocalizations.of(context);
|
||||||
|
final local = value.toLocal();
|
||||||
|
final date = localizations.formatCompactDate(local);
|
||||||
|
final time = localizations.formatTimeOfDay(
|
||||||
|
TimeOfDay.fromDateTime(local),
|
||||||
|
alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context),
|
||||||
|
);
|
||||||
|
return '$date $time';
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// User-action haptic feedback boundary.
|
||||||
|
abstract interface class Haptics {
|
||||||
|
/// Feedback for a selection change.
|
||||||
|
Future<void> selectionClick();
|
||||||
|
|
||||||
|
/// Feedback for a primary action.
|
||||||
|
Future<void> lightImpact();
|
||||||
|
|
||||||
|
/// Feedback for a destructive action.
|
||||||
|
Future<void> mediumImpact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default implementation backed by Flutter's platform haptics.
|
||||||
|
class FlutterHaptics implements Haptics {
|
||||||
|
const FlutterHaptics();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> selectionClick() =>
|
||||||
|
_ignoreUnavailable(HapticFeedback.selectionClick);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> lightImpact() => _ignoreUnavailable(HapticFeedback.lightImpact);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> mediumImpact() =>
|
||||||
|
_ignoreUnavailable(HapticFeedback.mediumImpact);
|
||||||
|
|
||||||
|
Future<void> _ignoreUnavailable(Future<void> Function() feedback) async {
|
||||||
|
try {
|
||||||
|
await feedback();
|
||||||
|
} catch (_) {
|
||||||
|
// Haptics are optional and may be unavailable on the target device.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Shared colors. Blue is the primary action color.
|
||||||
|
abstract final class AppColors {
|
||||||
|
/// Klein Blue primary used by actions and selected states.
|
||||||
|
static const blue = Color(0xFF002FA7);
|
||||||
|
|
||||||
|
/// Pressed/active shade for blue surfaces.
|
||||||
|
static const bluePressed = Color(0xFF00227A);
|
||||||
|
|
||||||
|
/// High-contrast blue for text and icons on dark surfaces.
|
||||||
|
static const blueSoft = Color(0xFF5B7BE0);
|
||||||
|
|
||||||
|
/// Text and icons placed directly on [blue].
|
||||||
|
static const onPrimary = Color(0xFFFFFFFF);
|
||||||
|
static const ink = Color(0xFF0B0C10);
|
||||||
|
static const bar = Color(0xFF101114);
|
||||||
|
static const card = Color(0xFF17191F);
|
||||||
|
static const muted = Color(0xFFB7BDC9);
|
||||||
|
static const outlineDark = Color(0xFF3A3C45);
|
||||||
|
static const outlineVariantDark = Color(0xFF242329);
|
||||||
|
static const outlineLight = Color(0xFF8A8E99);
|
||||||
|
static const outlineVariantLight = Color(0xFFD6D9E2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'app_colors.dart';
|
||||||
|
|
||||||
|
/// App-wide light and dark themes built from the approved Klein Blue palette.
|
||||||
|
abstract final class AppTheme {
|
||||||
|
static const _lightBackground = Color(0xFFF8F7FC);
|
||||||
|
static const _darkBackground = Color(0xFF101019);
|
||||||
|
|
||||||
|
/// Light theme for system light mode.
|
||||||
|
static ThemeData light() => _build(
|
||||||
|
brightness: Brightness.light,
|
||||||
|
surface: _lightBackground,
|
||||||
|
accent: AppColors.blue,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Dark theme for system dark mode.
|
||||||
|
static ThemeData dark() => _build(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
surface: _darkBackground,
|
||||||
|
accent: AppColors.blueSoft,
|
||||||
|
);
|
||||||
|
|
||||||
|
static ThemeData _build({
|
||||||
|
required Brightness brightness,
|
||||||
|
required Color surface,
|
||||||
|
required Color accent,
|
||||||
|
}) {
|
||||||
|
final dark = brightness == Brightness.dark;
|
||||||
|
final scheme = dark
|
||||||
|
? const ColorScheme.dark(
|
||||||
|
primary: AppColors.blue,
|
||||||
|
onPrimary: AppColors.onPrimary,
|
||||||
|
secondary: AppColors.blueSoft,
|
||||||
|
onSecondary: AppColors.ink,
|
||||||
|
primaryContainer: AppColors.bluePressed,
|
||||||
|
onPrimaryContainer: AppColors.onPrimary,
|
||||||
|
secondaryContainer: AppColors.bluePressed,
|
||||||
|
onSecondaryContainer: AppColors.onPrimary,
|
||||||
|
surface: _darkBackground,
|
||||||
|
onSurface: Colors.white,
|
||||||
|
outline: AppColors.outlineDark,
|
||||||
|
outlineVariant: AppColors.outlineVariantDark,
|
||||||
|
surfaceTint: AppColors.blueSoft,
|
||||||
|
inversePrimary: AppColors.blueSoft,
|
||||||
|
)
|
||||||
|
: ColorScheme.light(
|
||||||
|
primary: AppColors.blue,
|
||||||
|
onPrimary: AppColors.onPrimary,
|
||||||
|
secondary: AppColors.blue,
|
||||||
|
onSecondary: AppColors.onPrimary,
|
||||||
|
primaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
|
||||||
|
onPrimaryContainer: AppColors.ink,
|
||||||
|
secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
|
||||||
|
onSecondaryContainer: AppColors.ink,
|
||||||
|
surface: _lightBackground,
|
||||||
|
onSurface: AppColors.ink,
|
||||||
|
outline: AppColors.outlineLight,
|
||||||
|
outlineVariant: AppColors.outlineVariantLight,
|
||||||
|
surfaceTint: AppColors.blue,
|
||||||
|
inversePrimary: AppColors.blueSoft,
|
||||||
|
);
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
brightness: brightness,
|
||||||
|
colorScheme: scheme,
|
||||||
|
scaffoldBackgroundColor: surface,
|
||||||
|
progressIndicatorTheme: ProgressIndicatorThemeData(color: accent),
|
||||||
|
sliderTheme: SliderThemeData(
|
||||||
|
activeTrackColor: accent,
|
||||||
|
thumbColor: AppColors.onPrimary,
|
||||||
|
),
|
||||||
|
tabBarTheme: TabBarThemeData(labelColor: accent, indicatorColor: accent),
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(foregroundColor: accent),
|
||||||
|
),
|
||||||
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
|
style: ButtonStyle(
|
||||||
|
backgroundColor: WidgetStateProperty.resolveWith(_buttonColor),
|
||||||
|
foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
switchTheme: SwitchThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(WidgetState.selected)
|
||||||
|
? AppColors.onPrimary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
trackColor: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(WidgetState.selected) ? accent : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
backgroundColor: AppColors.bluePressed,
|
||||||
|
contentTextStyle: const TextStyle(color: AppColors.onPrimary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color _buttonColor(Set<WidgetState> states) {
|
||||||
|
if (states.contains(WidgetState.pressed) ||
|
||||||
|
states.contains(WidgetState.hovered) ||
|
||||||
|
states.contains(WidgetState.focused)) {
|
||||||
|
return AppColors.bluePressed;
|
||||||
|
}
|
||||||
|
return AppColors.blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 页面级登录引导,用于记录、收藏和下载等必须登录的独立页面。
|
||||||
|
class AuthGatePanel extends StatelessWidget {
|
||||||
|
const AuthGatePanel({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return Center(
|
||||||
|
key: const Key('auth-gate-panel'),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.authGateTitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.authGateMessage,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppColors.muted, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => openAuthPage(context),
|
||||||
|
child: Text(l10n.authGateAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../data/services/download_controller.dart';
|
||||||
|
import '../platform/haptics.dart';
|
||||||
|
import 'auth_gate.dart';
|
||||||
|
|
||||||
|
/// 详情页与播放页共用的下载授权入口。
|
||||||
|
///
|
||||||
|
/// 未登录先走登录闸;业务拒绝时直接展示后端 message。
|
||||||
|
Future<void> requestEpisodeDownload(
|
||||||
|
BuildContext context,
|
||||||
|
String episodeId,
|
||||||
|
) async {
|
||||||
|
if (!await ensureSignedIn(context)) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
unawaited(context.read<Haptics>().lightImpact());
|
||||||
|
final result = await context.read<DownloadController>().grant(episodeId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final hasBackendMessage = result.message?.trim().isNotEmpty ?? false;
|
||||||
|
final message = result.ok
|
||||||
|
? l10n.downloadGranted
|
||||||
|
: hasBackendMessage
|
||||||
|
? result.message!
|
||||||
|
: l10n.downloadGrantFailed;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../data/models/drama.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import 'remote_image.dart';
|
||||||
|
|
||||||
|
/// 收藏等账号列表复用的剧集卡片,视觉与"我的"页列表保持一致。
|
||||||
|
class LibraryDramaTile extends StatelessWidget {
|
||||||
|
const LibraryDramaTile({
|
||||||
|
super.key,
|
||||||
|
required this.drama,
|
||||||
|
required this.onTap,
|
||||||
|
this.trailing,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Drama drama;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: const Color(0xFF1B1C21),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(9),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: RemoteImage(drama.posterUrl, width: 74, height: 103),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 11),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
drama.title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 7),
|
||||||
|
Text(
|
||||||
|
drama.genre.isEmpty ? drama.category : drama.genre,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.muted,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
?trailing,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../config/app_config.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。
|
||||||
|
class RemoteImage extends StatelessWidget {
|
||||||
|
const RemoteImage(
|
||||||
|
this.path, {
|
||||||
|
super.key,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
this.showFallbackIcon = false,
|
||||||
|
this.fallbackIconKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String path;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
final BoxFit fit;
|
||||||
|
final bool showFallbackIcon;
|
||||||
|
final Key? fallbackIconKey;
|
||||||
|
|
||||||
|
@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: AppColors.bar,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: showFallbackIcon
|
||||||
|
? Icon(
|
||||||
|
Icons.image_not_supported_outlined,
|
||||||
|
key: fallbackIconKey,
|
||||||
|
color: AppColors.muted,
|
||||||
|
size: 32,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+259
-11
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "en",
|
"@@locale": "en",
|
||||||
"appName": "Nontonki",
|
"appName": "FruitShorts",
|
||||||
"demo": "DEMO",
|
"demo": "DEMO",
|
||||||
"homeHeadline": "Watch now",
|
"homeHeadline": "Watch now",
|
||||||
"homeSubtitle": "Free short drama preview.",
|
"homeSubtitle": "Free short drama preview.",
|
||||||
@@ -34,7 +34,13 @@
|
|||||||
"localDemoProfile": "Local demo profile",
|
"localDemoProfile": "Local demo profile",
|
||||||
"continueWatching": "Continue Watching",
|
"continueWatching": "Continue Watching",
|
||||||
"watchedPercent": "Watched {percent}%",
|
"watchedPercent": "Watched {percent}%",
|
||||||
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
|
"@watchedPercent": {
|
||||||
|
"placeholders": {
|
||||||
|
"percent": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"resume": "Resume",
|
"resume": "Resume",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"watchlist": "Watchlist",
|
"watchlist": "Watchlist",
|
||||||
@@ -49,6 +55,7 @@
|
|||||||
"animatedTab": "Animated",
|
"animatedTab": "Animated",
|
||||||
"recommendedTab": "Recommended",
|
"recommendedTab": "Recommended",
|
||||||
"noCategoryDramas": "No dramas in this category yet.",
|
"noCategoryDramas": "No dramas in this category yet.",
|
||||||
|
"allDramas": "All Dramas",
|
||||||
"trendingTab": "Trending",
|
"trendingTab": "Trending",
|
||||||
"liveActionTab": "Live Action",
|
"liveActionTab": "Live Action",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
@@ -59,10 +66,50 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
"localOnlyComments": "Only visible on this device",
|
|
||||||
"noComments": "No comments yet.",
|
"noComments": "No comments yet.",
|
||||||
"writeComment": "Write a comment",
|
"writeComment": "Write a comment",
|
||||||
"postComment": "Post",
|
"postComment": "Post",
|
||||||
|
"loadMore": "Load more",
|
||||||
|
"commentsLoadFailed": "Comments could not be loaded.",
|
||||||
|
"commentPostFailed": "Could not post your comment.",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteComment": "Delete comment",
|
||||||
|
"deleteCommentTitle": "Delete this comment?",
|
||||||
|
"deleteCommentMessage": "This removes your comment from the conversation.",
|
||||||
|
"commentDeleteFailed": "Could not delete this comment.",
|
||||||
|
"underReview": "Under review",
|
||||||
|
"reply": "Reply",
|
||||||
|
"replyingTo": "Replying to {name}",
|
||||||
|
"@replyingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cancelReply": "Cancel reply",
|
||||||
|
"viewReplies": "View replies ({count})",
|
||||||
|
"@viewReplies": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hideReplies": "Hide replies",
|
||||||
|
"anonymousUser": "Viewer",
|
||||||
|
"more": "More",
|
||||||
|
"likeFailed": "Could not update like. Please retry.",
|
||||||
|
"reportComment": "Report comment",
|
||||||
|
"reportDrama": "Report drama",
|
||||||
|
"reportReason": "Reason",
|
||||||
|
"reportReasonSpam": "Spam",
|
||||||
|
"reportReasonAbuse": "Harassment",
|
||||||
|
"reportReasonPorn": "Sexual content",
|
||||||
|
"reportReasonAd": "Advertising",
|
||||||
|
"reportReasonOther": "Other",
|
||||||
|
"reportDescriptionHint": "Describe the issue (optional)",
|
||||||
|
"reportSubmit": "Submit report",
|
||||||
|
"reportSubmitted": "Report submitted. Thank you.",
|
||||||
|
"reportFailed": "Could not submit report.",
|
||||||
"shareFailed": "Could not open the share sheet.",
|
"shareFailed": "Could not open the share sheet.",
|
||||||
"nowPlaying": "Playing now",
|
"nowPlaying": "Playing now",
|
||||||
"watchFullscreen": "Watch in landscape",
|
"watchFullscreen": "Watch in landscape",
|
||||||
@@ -72,16 +119,40 @@
|
|||||||
"originalTag": "Original",
|
"originalTag": "Original",
|
||||||
"dramaTag": "Short drama",
|
"dramaTag": "Short drama",
|
||||||
"episodeLabel": "Episode {number}",
|
"episodeLabel": "Episode {number}",
|
||||||
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
|
"@episodeLabel": {
|
||||||
|
"placeholders": {
|
||||||
|
"number": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"exploreSeries": "Explore series",
|
"exploreSeries": "Explore series",
|
||||||
"episodeCount": "{count} EPISODES",
|
"episodeCount": "{count} EPISODES",
|
||||||
"@episodeCount": {"placeholders": {"count": {"type": "int"}}},
|
"@episodeCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"technicalDemoCount": "FREE PREVIEW · {count} EPISODES",
|
"technicalDemoCount": "FREE PREVIEW · {count} EPISODES",
|
||||||
"@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}},
|
"@technicalDemoCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"watchEpisodeOne": "Watch episode 1",
|
"watchEpisodeOne": "Watch episode 1",
|
||||||
"continueEpisode": "Continue episode {number}",
|
"continueEpisode": "Continue episode {number}",
|
||||||
"@continueEpisode": {"placeholders": {"number": {"type": "int"}}},
|
"@continueEpisode": {
|
||||||
|
"placeholders": {
|
||||||
|
"number": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"freeTestMedia": "Same demo clip · free",
|
"freeTestMedia": "Same demo clip · free",
|
||||||
"lockedStoreUnavailable": "Locked · store unavailable",
|
"lockedStoreUnavailable": "Locked · store unavailable",
|
||||||
"freeTestClip": "Free test clip",
|
"freeTestClip": "Free test clip",
|
||||||
@@ -89,14 +160,49 @@
|
|||||||
"freeSamplesNotice": "Two free samples · store setup pending",
|
"freeSamplesNotice": "Two free samples · store setup pending",
|
||||||
"demoTestMedia": "FREE · SAME DEMO CLIP",
|
"demoTestMedia": "FREE · SAME DEMO CLIP",
|
||||||
"episodeProgress": "EPISODE {number} / {total}",
|
"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",
|
"testVideoNotice": "Self-generated technical test video",
|
||||||
"lockedTitle": "Episode {number} is locked",
|
"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.",
|
"lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.",
|
||||||
"playbackError": "This clip could not be played.",
|
"playbackError": "This clip could not be played.",
|
||||||
"catalogError": "The catalog could not be loaded.",
|
"catalogError": "The catalog could not be loaded.",
|
||||||
|
"categoriesError": "Categories 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.",
|
"noStories": "No stories available yet.",
|
||||||
|
"notices": "Notices",
|
||||||
|
"noNotices": "No notices yet.",
|
||||||
|
"noticesLoadFailed": "Notices could not be loaded.",
|
||||||
|
"noticeDetails": "Notice details",
|
||||||
|
"noticeAnnouncement": "Notice",
|
||||||
|
"close": "Close",
|
||||||
|
"carouselSemantics": "Banner {current} of {total}",
|
||||||
|
"@carouselSemantics": {
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"backToSeries": "Back to series",
|
"backToSeries": "Back to series",
|
||||||
"nextEpisode": "Next episode",
|
"nextEpisode": "Next episode",
|
||||||
@@ -112,6 +218,7 @@
|
|||||||
"systemLanguage": "System",
|
"systemLanguage": "System",
|
||||||
"english": "English",
|
"english": "English",
|
||||||
"simplifiedChinese": "简体中文",
|
"simplifiedChinese": "简体中文",
|
||||||
|
"bahasaIndonesia": "Bahasa Indonesia",
|
||||||
"episodeOneTitle": "First Light",
|
"episodeOneTitle": "First Light",
|
||||||
"episodeTwoTitle": "A New Frequency",
|
"episodeTwoTitle": "A New Frequency",
|
||||||
"episodeThreeTitle": "Crossing Over",
|
"episodeThreeTitle": "Crossing Over",
|
||||||
@@ -129,6 +236,147 @@
|
|||||||
"genreDramaMystery": "Drama · Mystery",
|
"genreDramaMystery": "Drama · Mystery",
|
||||||
"continueWatchingHeading": "CONTINUE WATCHING",
|
"continueWatchingHeading": "CONTINUE WATCHING",
|
||||||
"preferencesHeading": "PREFERENCES",
|
"preferencesHeading": "PREFERENCES",
|
||||||
"brandMark": "NONTONKI · DEMO",
|
"brandMark": "FRUITSHORTS",
|
||||||
"shareFooter": "Nontonki demo"
|
"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.",
|
||||||
|
"authBrandTagline": "Short dramas made for Indonesia",
|
||||||
|
"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": "Watch history, favorites, comments and downloads need an account.",
|
||||||
|
"authGateAction": "Sign In",
|
||||||
|
"showPassword": "Show password",
|
||||||
|
"hidePassword": "Hide password",
|
||||||
|
"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.",
|
||||||
|
"myFavorites": "My favorites",
|
||||||
|
"noFavorites": "No favorites yet.",
|
||||||
|
"downloads": "Downloads",
|
||||||
|
"downloadsError": "Download settings could not be loaded.",
|
||||||
|
"noDownloads": "No download authorizations yet.",
|
||||||
|
"downloadedItems": "Download authorizations",
|
||||||
|
"downloadQuota": "{remaining} of {max} authorizations available",
|
||||||
|
"@downloadQuota": {
|
||||||
|
"placeholders": {
|
||||||
|
"remaining": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadLicenseDays": "Authorization valid for {days} days",
|
||||||
|
"@downloadLicenseDays": {
|
||||||
|
"placeholders": {
|
||||||
|
"days": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadWifiOnly": "Wi-Fi only",
|
||||||
|
"downloadQuality": "Maximum quality",
|
||||||
|
"downloadStatus": "Authorization: {status}",
|
||||||
|
"@downloadStatus": {
|
||||||
|
"placeholders": {
|
||||||
|
"status": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadTaskStatus": "Task: {status}",
|
||||||
|
"@downloadTaskStatus": {
|
||||||
|
"placeholders": {
|
||||||
|
"status": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadProgress": "{progress}%",
|
||||||
|
"@downloadProgress": {
|
||||||
|
"placeholders": {
|
||||||
|
"progress": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadExpiresAt": "Expires {time}",
|
||||||
|
"@downloadExpiresAt": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clearDownloadsTitle": "Clear download authorizations?",
|
||||||
|
"clearDownloadsMessage": "This revokes every download authorization for this device.",
|
||||||
|
"downloadSettingsFailed": "Could not update download settings.",
|
||||||
|
"downloadDeleteFailed": "Could not remove this download authorization.",
|
||||||
|
"downloadClearFailed": "Could not clear download authorizations.",
|
||||||
|
"downloadGrantFailed": "Could not request this download.",
|
||||||
|
"downloadGranted": "Download authorization is ready.",
|
||||||
|
"noWatchRecords": "No watch history yet.",
|
||||||
|
"recordDeleteFailed": "Could not delete this record. Please retry.",
|
||||||
|
"recordClearFailed": "Could not clear watch history. Please retry.",
|
||||||
|
"clearAll": "Clear all",
|
||||||
|
"clearWatchHistoryTitle": "Clear watch history?",
|
||||||
|
"clearWatchHistoryMessage": "This removes the watch history from your account.",
|
||||||
|
"confirmClear": "Clear",
|
||||||
|
"detailLoadFailed": "Details failed to load",
|
||||||
|
"retryDetail": "Retry",
|
||||||
|
"episodeUnavailable": "Episode unavailable",
|
||||||
|
"updatedAtLabel": "Updated {time}",
|
||||||
|
"@updatedAtLabel": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remove": "Remove",
|
||||||
|
"unlockComingSoon": "Unlocking with coins is coming soon.",
|
||||||
|
"resumeProgress": "Resume at {position} / {total}",
|
||||||
|
"@resumeProgress": {
|
||||||
|
"placeholders": {
|
||||||
|
"position": {},
|
||||||
|
"total": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
{
|
||||||
|
"@@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.",
|
||||||
|
"allDramas": "Semua Drama",
|
||||||
|
"trendingTab": "Sedang Tren",
|
||||||
|
"liveActionTab": "Live Action",
|
||||||
|
"search": "Cari",
|
||||||
|
"free": "Gratis",
|
||||||
|
"mute": "Bisukan",
|
||||||
|
"unmute": "Nyalakan suara",
|
||||||
|
"like": "Suka",
|
||||||
|
"save": "Simpan",
|
||||||
|
"comments": "Komentar",
|
||||||
|
"share": "Bagikan",
|
||||||
|
"noComments": "Belum ada komentar.",
|
||||||
|
"writeComment": "Tulis komentar",
|
||||||
|
"postComment": "Kirim",
|
||||||
|
"loadMore": "Muat lebih banyak",
|
||||||
|
"commentsLoadFailed": "Komentar tidak dapat dimuat.",
|
||||||
|
"commentPostFailed": "Komentar tidak dapat dikirim.",
|
||||||
|
"delete": "Hapus",
|
||||||
|
"deleteComment": "Hapus komentar",
|
||||||
|
"deleteCommentTitle": "Hapus komentar ini?",
|
||||||
|
"deleteCommentMessage": "Komentar ini akan dihapus dari percakapan.",
|
||||||
|
"commentDeleteFailed": "Komentar tidak dapat dihapus.",
|
||||||
|
"underReview": "Sedang ditinjau",
|
||||||
|
"reply": "Balas",
|
||||||
|
"replyingTo": "Membalas {name}",
|
||||||
|
"@replyingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cancelReply": "Batal membalas",
|
||||||
|
"viewReplies": "Lihat balasan ({count})",
|
||||||
|
"@viewReplies": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hideReplies": "Sembunyikan balasan",
|
||||||
|
"anonymousUser": "Penonton",
|
||||||
|
"more": "Lainnya",
|
||||||
|
"likeFailed": "Status suka tidak dapat diperbarui.",
|
||||||
|
"reportComment": "Laporkan komentar",
|
||||||
|
"reportDrama": "Laporkan drama",
|
||||||
|
"reportReason": "Alasan",
|
||||||
|
"reportReasonSpam": "Spam",
|
||||||
|
"reportReasonAbuse": "Pelecehan",
|
||||||
|
"reportReasonPorn": "Konten seksual",
|
||||||
|
"reportReasonAd": "Iklan",
|
||||||
|
"reportReasonOther": "Lainnya",
|
||||||
|
"reportDescriptionHint": "Jelaskan masalah (opsional)",
|
||||||
|
"reportSubmit": "Kirim laporan",
|
||||||
|
"reportSubmitted": "Laporan terkirim. Terima kasih.",
|
||||||
|
"reportFailed": "Laporan tidak dapat dikirim.",
|
||||||
|
"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.",
|
||||||
|
"categoriesError": "Kategori gagal dimuat.",
|
||||||
|
"detailError": "Daftar episode gagal dimuat.",
|
||||||
|
"noMediaNotice": "Episode ini belum punya media yang bisa diputar.",
|
||||||
|
"noStories": "Belum ada cerita.",
|
||||||
|
"notices": "Notifikasi",
|
||||||
|
"noNotices": "Belum ada notifikasi.",
|
||||||
|
"noticesLoadFailed": "Notifikasi tidak dapat dimuat.",
|
||||||
|
"noticeDetails": "Detail notifikasi",
|
||||||
|
"noticeAnnouncement": "Pengumuman",
|
||||||
|
"close": "Tutup",
|
||||||
|
"carouselSemantics": "Banner {current} dari {total}",
|
||||||
|
"@carouselSemantics": {
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "简体中文",
|
||||||
|
"bahasaIndonesia": "Bahasa Indonesia",
|
||||||
|
"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.",
|
||||||
|
"authBrandTagline": "Drama pendek untuk Indonesia",
|
||||||
|
"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": "Riwayat tontonan, favorit, komentar, dan unduhan perlu akun.",
|
||||||
|
"authGateAction": "Masuk",
|
||||||
|
"showPassword": "Tampilkan kata sandi",
|
||||||
|
"hidePassword": "Sembunyikan kata sandi",
|
||||||
|
"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.",
|
||||||
|
"myFavorites": "Favorit saya",
|
||||||
|
"noFavorites": "Belum ada favorit.",
|
||||||
|
"downloads": "Unduhan",
|
||||||
|
"downloadsError": "Pengaturan unduhan gagal dimuat.",
|
||||||
|
"noDownloads": "Belum ada izin unduhan.",
|
||||||
|
"downloadedItems": "Izin unduhan",
|
||||||
|
"downloadQuota": "{remaining} dari {max} izin tersedia",
|
||||||
|
"@downloadQuota": {
|
||||||
|
"placeholders": {
|
||||||
|
"remaining": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadLicenseDays": "Izin berlaku {days} hari",
|
||||||
|
"@downloadLicenseDays": {
|
||||||
|
"placeholders": {
|
||||||
|
"days": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadWifiOnly": "Hanya Wi-Fi",
|
||||||
|
"downloadQuality": "Kualitas maksimum",
|
||||||
|
"downloadStatus": "Izin: {status}",
|
||||||
|
"@downloadStatus": {
|
||||||
|
"placeholders": {
|
||||||
|
"status": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadTaskStatus": "Tugas: {status}",
|
||||||
|
"@downloadTaskStatus": {
|
||||||
|
"placeholders": {
|
||||||
|
"status": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadProgress": "{progress}%",
|
||||||
|
"@downloadProgress": {
|
||||||
|
"placeholders": {
|
||||||
|
"progress": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadExpiresAt": "Berakhir {time}",
|
||||||
|
"@downloadExpiresAt": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clearDownloadsTitle": "Hapus izin unduhan?",
|
||||||
|
"clearDownloadsMessage": "Semua izin unduhan untuk perangkat ini akan dicabut.",
|
||||||
|
"downloadSettingsFailed": "Gagal memperbarui pengaturan unduhan.",
|
||||||
|
"downloadDeleteFailed": "Gagal menghapus izin unduhan ini.",
|
||||||
|
"downloadClearFailed": "Gagal menghapus semua izin unduhan.",
|
||||||
|
"downloadGrantFailed": "Gagal meminta unduhan ini.",
|
||||||
|
"downloadGranted": "Izin unduhan sudah siap.",
|
||||||
|
"noWatchRecords": "Belum ada riwayat tontonan.",
|
||||||
|
"recordDeleteFailed": "Gagal menghapus riwayat ini. Coba lagi.",
|
||||||
|
"recordClearFailed": "Gagal menghapus semua riwayat. Coba lagi.",
|
||||||
|
"clearAll": "Hapus semua",
|
||||||
|
"clearWatchHistoryTitle": "Hapus riwayat tontonan?",
|
||||||
|
"clearWatchHistoryMessage": "Semua riwayat akan dihapus dari akun kamu.",
|
||||||
|
"confirmClear": "Hapus",
|
||||||
|
"detailLoadFailed": "Detail drama gagal dimuat",
|
||||||
|
"retryDetail": "Coba lagi",
|
||||||
|
"episodeUnavailable": "Episode tidak tersedia",
|
||||||
|
"updatedAtLabel": "Diperbarui {time}",
|
||||||
|
"@updatedAtLabel": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remove": "Hapus",
|
||||||
|
"unlockComingSoon": "Pembukaan dengan koin segera hadir.",
|
||||||
|
"resumeProgress": "Lanjut di {position} / {total}",
|
||||||
|
"@resumeProgress": {
|
||||||
|
"placeholders": {
|
||||||
|
"position": {},
|
||||||
|
"total": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+800
-12
@@ -1,5 +1,3 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@@ -8,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
|||||||
import 'package:intl/intl.dart' as intl;
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
|
||||||
import 'app_localizations_en.dart';
|
import 'app_localizations_en.dart';
|
||||||
|
import 'app_localizations_id.dart';
|
||||||
import 'app_localizations_zh.dart';
|
import 'app_localizations_zh.dart';
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -97,13 +96,14 @@ abstract class AppLocalizations {
|
|||||||
/// A list of this localizations delegate's supported locales.
|
/// A list of this localizations delegate's supported locales.
|
||||||
static const List<Locale> supportedLocales = <Locale>[
|
static const List<Locale> supportedLocales = <Locale>[
|
||||||
Locale('en'),
|
Locale('en'),
|
||||||
|
Locale('id'),
|
||||||
Locale('zh'),
|
Locale('zh'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// No description provided for @appName.
|
/// No description provided for @appName.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Nontonki'**
|
/// **'FruitShorts'**
|
||||||
String get appName;
|
String get appName;
|
||||||
|
|
||||||
/// No description provided for @demo.
|
/// No description provided for @demo.
|
||||||
@@ -388,6 +388,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'No dramas in this category yet.'**
|
/// **'No dramas in this category yet.'**
|
||||||
String get noCategoryDramas;
|
String get noCategoryDramas;
|
||||||
|
|
||||||
|
/// No description provided for @allDramas.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All Dramas'**
|
||||||
|
String get allDramas;
|
||||||
|
|
||||||
/// No description provided for @trendingTab.
|
/// No description provided for @trendingTab.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -448,12 +454,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Share'**
|
/// **'Share'**
|
||||||
String get share;
|
String get share;
|
||||||
|
|
||||||
/// No description provided for @localOnlyComments.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Only visible on this device'**
|
|
||||||
String get localOnlyComments;
|
|
||||||
|
|
||||||
/// No description provided for @noComments.
|
/// No description provided for @noComments.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -472,6 +472,180 @@ abstract class AppLocalizations {
|
|||||||
/// **'Post'**
|
/// **'Post'**
|
||||||
String get postComment;
|
String get postComment;
|
||||||
|
|
||||||
|
/// No description provided for @loadMore.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Load more'**
|
||||||
|
String get loadMore;
|
||||||
|
|
||||||
|
/// No description provided for @commentsLoadFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Comments could not be loaded.'**
|
||||||
|
String get commentsLoadFailed;
|
||||||
|
|
||||||
|
/// No description provided for @commentPostFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not post your comment.'**
|
||||||
|
String get commentPostFailed;
|
||||||
|
|
||||||
|
/// No description provided for @delete.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete'**
|
||||||
|
String get delete;
|
||||||
|
|
||||||
|
/// No description provided for @deleteComment.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete comment'**
|
||||||
|
String get deleteComment;
|
||||||
|
|
||||||
|
/// No description provided for @deleteCommentTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete this comment?'**
|
||||||
|
String get deleteCommentTitle;
|
||||||
|
|
||||||
|
/// No description provided for @deleteCommentMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This removes your comment from the conversation.'**
|
||||||
|
String get deleteCommentMessage;
|
||||||
|
|
||||||
|
/// No description provided for @commentDeleteFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not delete this comment.'**
|
||||||
|
String get commentDeleteFailed;
|
||||||
|
|
||||||
|
/// No description provided for @underReview.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Under review'**
|
||||||
|
String get underReview;
|
||||||
|
|
||||||
|
/// No description provided for @reply.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Reply'**
|
||||||
|
String get reply;
|
||||||
|
|
||||||
|
/// No description provided for @replyingTo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Replying to {name}'**
|
||||||
|
String replyingTo(Object name);
|
||||||
|
|
||||||
|
/// No description provided for @cancelReply.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cancel reply'**
|
||||||
|
String get cancelReply;
|
||||||
|
|
||||||
|
/// No description provided for @viewReplies.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'View replies ({count})'**
|
||||||
|
String viewReplies(int count);
|
||||||
|
|
||||||
|
/// No description provided for @hideReplies.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hide replies'**
|
||||||
|
String get hideReplies;
|
||||||
|
|
||||||
|
/// No description provided for @anonymousUser.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Viewer'**
|
||||||
|
String get anonymousUser;
|
||||||
|
|
||||||
|
/// No description provided for @more.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'More'**
|
||||||
|
String get more;
|
||||||
|
|
||||||
|
/// No description provided for @likeFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not update like. Please retry.'**
|
||||||
|
String get likeFailed;
|
||||||
|
|
||||||
|
/// No description provided for @reportComment.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Report comment'**
|
||||||
|
String get reportComment;
|
||||||
|
|
||||||
|
/// No description provided for @reportDrama.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Report drama'**
|
||||||
|
String get reportDrama;
|
||||||
|
|
||||||
|
/// No description provided for @reportReason.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Reason'**
|
||||||
|
String get reportReason;
|
||||||
|
|
||||||
|
/// No description provided for @reportReasonSpam.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Spam'**
|
||||||
|
String get reportReasonSpam;
|
||||||
|
|
||||||
|
/// No description provided for @reportReasonAbuse.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Harassment'**
|
||||||
|
String get reportReasonAbuse;
|
||||||
|
|
||||||
|
/// No description provided for @reportReasonPorn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sexual content'**
|
||||||
|
String get reportReasonPorn;
|
||||||
|
|
||||||
|
/// No description provided for @reportReasonAd.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Advertising'**
|
||||||
|
String get reportReasonAd;
|
||||||
|
|
||||||
|
/// No description provided for @reportReasonOther.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Other'**
|
||||||
|
String get reportReasonOther;
|
||||||
|
|
||||||
|
/// No description provided for @reportDescriptionHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Describe the issue (optional)'**
|
||||||
|
String get reportDescriptionHint;
|
||||||
|
|
||||||
|
/// No description provided for @reportSubmit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Submit report'**
|
||||||
|
String get reportSubmit;
|
||||||
|
|
||||||
|
/// No description provided for @reportSubmitted.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Report submitted. Thank you.'**
|
||||||
|
String get reportSubmitted;
|
||||||
|
|
||||||
|
/// No description provided for @reportFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not submit report.'**
|
||||||
|
String get reportFailed;
|
||||||
|
|
||||||
/// No description provided for @shareFailed.
|
/// No description provided for @shareFailed.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -634,12 +808,72 @@ abstract class AppLocalizations {
|
|||||||
/// **'The catalog could not be loaded.'**
|
/// **'The catalog could not be loaded.'**
|
||||||
String get catalogError;
|
String get catalogError;
|
||||||
|
|
||||||
|
/// No description provided for @categoriesError.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Categories could not be loaded.'**
|
||||||
|
String get categoriesError;
|
||||||
|
|
||||||
|
/// 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.
|
/// No description provided for @noStories.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'No stories available yet.'**
|
/// **'No stories available yet.'**
|
||||||
String get noStories;
|
String get noStories;
|
||||||
|
|
||||||
|
/// No description provided for @notices.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Notices'**
|
||||||
|
String get notices;
|
||||||
|
|
||||||
|
/// No description provided for @noNotices.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No notices yet.'**
|
||||||
|
String get noNotices;
|
||||||
|
|
||||||
|
/// No description provided for @noticesLoadFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Notices could not be loaded.'**
|
||||||
|
String get noticesLoadFailed;
|
||||||
|
|
||||||
|
/// No description provided for @noticeDetails.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Notice details'**
|
||||||
|
String get noticeDetails;
|
||||||
|
|
||||||
|
/// No description provided for @noticeAnnouncement.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Notice'**
|
||||||
|
String get noticeAnnouncement;
|
||||||
|
|
||||||
|
/// No description provided for @close.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Close'**
|
||||||
|
String get close;
|
||||||
|
|
||||||
|
/// No description provided for @carouselSemantics.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Banner {current} of {total}'**
|
||||||
|
String carouselSemantics(int current, int total);
|
||||||
|
|
||||||
/// No description provided for @retry.
|
/// No description provided for @retry.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -730,6 +964,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'简体中文'**
|
/// **'简体中文'**
|
||||||
String get simplifiedChinese;
|
String get simplifiedChinese;
|
||||||
|
|
||||||
|
/// No description provided for @bahasaIndonesia.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bahasa Indonesia'**
|
||||||
|
String get bahasaIndonesia;
|
||||||
|
|
||||||
/// No description provided for @episodeOneTitle.
|
/// No description provided for @episodeOneTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -835,14 +1075,560 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @brandMark.
|
/// No description provided for @brandMark.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'NONTONKI · DEMO'**
|
/// **'FRUITSHORTS'**
|
||||||
String get brandMark;
|
String get brandMark;
|
||||||
|
|
||||||
/// No description provided for @shareFooter.
|
/// No description provided for @shareFooter.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Nontonki demo'**
|
/// **'FruitShorts'**
|
||||||
String get shareFooter;
|
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 @authBrandTagline.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Short dramas made for Indonesia'**
|
||||||
|
String get authBrandTagline;
|
||||||
|
|
||||||
|
/// 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:
|
||||||
|
/// **'Watch history, favorites, comments and downloads 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 @showPassword.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show password'**
|
||||||
|
String get showPassword;
|
||||||
|
|
||||||
|
/// No description provided for @hidePassword.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hide password'**
|
||||||
|
String get hidePassword;
|
||||||
|
|
||||||
|
/// 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 @myFavorites.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'My favorites'**
|
||||||
|
String get myFavorites;
|
||||||
|
|
||||||
|
/// No description provided for @noFavorites.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No favorites yet.'**
|
||||||
|
String get noFavorites;
|
||||||
|
|
||||||
|
/// No description provided for @downloads.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloads'**
|
||||||
|
String get downloads;
|
||||||
|
|
||||||
|
/// No description provided for @downloadsError.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download settings could not be loaded.'**
|
||||||
|
String get downloadsError;
|
||||||
|
|
||||||
|
/// No description provided for @noDownloads.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No download authorizations yet.'**
|
||||||
|
String get noDownloads;
|
||||||
|
|
||||||
|
/// No description provided for @downloadedItems.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download authorizations'**
|
||||||
|
String get downloadedItems;
|
||||||
|
|
||||||
|
/// No description provided for @downloadQuota.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{remaining} of {max} authorizations available'**
|
||||||
|
String downloadQuota(int remaining, int max);
|
||||||
|
|
||||||
|
/// No description provided for @downloadLicenseDays.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Authorization valid for {days} days'**
|
||||||
|
String downloadLicenseDays(int days);
|
||||||
|
|
||||||
|
/// No description provided for @downloadWifiOnly.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Wi-Fi only'**
|
||||||
|
String get downloadWifiOnly;
|
||||||
|
|
||||||
|
/// No description provided for @downloadQuality.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Maximum quality'**
|
||||||
|
String get downloadQuality;
|
||||||
|
|
||||||
|
/// No description provided for @downloadStatus.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Authorization: {status}'**
|
||||||
|
String downloadStatus(Object status);
|
||||||
|
|
||||||
|
/// No description provided for @downloadTaskStatus.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Task: {status}'**
|
||||||
|
String downloadTaskStatus(Object status);
|
||||||
|
|
||||||
|
/// No description provided for @downloadProgress.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{progress}%'**
|
||||||
|
String downloadProgress(int progress);
|
||||||
|
|
||||||
|
/// No description provided for @downloadExpiresAt.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Expires {time}'**
|
||||||
|
String downloadExpiresAt(Object time);
|
||||||
|
|
||||||
|
/// No description provided for @clearDownloadsTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear download authorizations?'**
|
||||||
|
String get clearDownloadsTitle;
|
||||||
|
|
||||||
|
/// No description provided for @clearDownloadsMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This revokes every download authorization for this device.'**
|
||||||
|
String get clearDownloadsMessage;
|
||||||
|
|
||||||
|
/// No description provided for @downloadSettingsFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not update download settings.'**
|
||||||
|
String get downloadSettingsFailed;
|
||||||
|
|
||||||
|
/// No description provided for @downloadDeleteFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not remove this download authorization.'**
|
||||||
|
String get downloadDeleteFailed;
|
||||||
|
|
||||||
|
/// No description provided for @downloadClearFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not clear download authorizations.'**
|
||||||
|
String get downloadClearFailed;
|
||||||
|
|
||||||
|
/// No description provided for @downloadGrantFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not request this download.'**
|
||||||
|
String get downloadGrantFailed;
|
||||||
|
|
||||||
|
/// No description provided for @downloadGranted.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download authorization is ready.'**
|
||||||
|
String get downloadGranted;
|
||||||
|
|
||||||
|
/// No description provided for @noWatchRecords.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No watch history yet.'**
|
||||||
|
String get noWatchRecords;
|
||||||
|
|
||||||
|
/// No description provided for @recordDeleteFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not delete this record. Please retry.'**
|
||||||
|
String get recordDeleteFailed;
|
||||||
|
|
||||||
|
/// No description provided for @recordClearFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not clear watch history. Please retry.'**
|
||||||
|
String get recordClearFailed;
|
||||||
|
|
||||||
|
/// No description provided for @clearAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear all'**
|
||||||
|
String get clearAll;
|
||||||
|
|
||||||
|
/// No description provided for @clearWatchHistoryTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear watch history?'**
|
||||||
|
String get clearWatchHistoryTitle;
|
||||||
|
|
||||||
|
/// No description provided for @clearWatchHistoryMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This removes the watch history from your account.'**
|
||||||
|
String get clearWatchHistoryMessage;
|
||||||
|
|
||||||
|
/// No description provided for @confirmClear.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear'**
|
||||||
|
String get confirmClear;
|
||||||
|
|
||||||
|
/// No description provided for @detailLoadFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Details failed to load'**
|
||||||
|
String get detailLoadFailed;
|
||||||
|
|
||||||
|
/// No description provided for @retryDetail.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Retry'**
|
||||||
|
String get retryDetail;
|
||||||
|
|
||||||
|
/// No description provided for @episodeUnavailable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Episode unavailable'**
|
||||||
|
String get episodeUnavailable;
|
||||||
|
|
||||||
|
/// No description provided for @updatedAtLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Updated {time}'**
|
||||||
|
String updatedAtLabel(Object time);
|
||||||
|
|
||||||
|
/// 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
|
class _AppLocalizationsDelegate
|
||||||
@@ -856,7 +1642,7 @@ class _AppLocalizationsDelegate
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool isSupported(Locale locale) =>
|
bool isSupported(Locale locale) =>
|
||||||
<String>['en', 'zh'].contains(locale.languageCode);
|
<String>['en', 'id', 'zh'].contains(locale.languageCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
||||||
@@ -867,6 +1653,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
|||||||
switch (locale.languageCode) {
|
switch (locale.languageCode) {
|
||||||
case 'en':
|
case 'en':
|
||||||
return AppLocalizationsEn();
|
return AppLocalizationsEn();
|
||||||
|
case 'id':
|
||||||
|
return AppLocalizationsId();
|
||||||
case 'zh':
|
case 'zh':
|
||||||
return AppLocalizationsZh();
|
return AppLocalizationsZh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
// ignore: unused_import
|
// ignore: unused_import
|
||||||
import 'package:intl/intl.dart' as intl;
|
import 'package:intl/intl.dart' as intl;
|
||||||
import 'app_localizations.dart';
|
import 'app_localizations.dart';
|
||||||
@@ -11,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appName => 'Nontonki';
|
String get appName => 'FruitShorts';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get demo => 'DEMO';
|
String get demo => 'DEMO';
|
||||||
@@ -159,6 +157,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noCategoryDramas => 'No dramas in this category yet.';
|
String get noCategoryDramas => 'No dramas in this category yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allDramas => 'All Dramas';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trendingTab => 'Trending';
|
String get trendingTab => 'Trending';
|
||||||
|
|
||||||
@@ -189,9 +190,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get share => 'Share';
|
String get share => 'Share';
|
||||||
|
|
||||||
@override
|
|
||||||
String get localOnlyComments => 'Only visible on this device';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noComments => 'No comments yet.';
|
String get noComments => 'No comments yet.';
|
||||||
|
|
||||||
@@ -201,6 +199,98 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get postComment => 'Post';
|
String get postComment => 'Post';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadMore => 'Load more';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get commentsLoadFailed => 'Comments could not be loaded.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get commentPostFailed => 'Could not post your comment.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get delete => 'Delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteComment => 'Delete comment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteCommentTitle => 'Delete this comment?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteCommentMessage =>
|
||||||
|
'This removes your comment from the conversation.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get commentDeleteFailed => 'Could not delete this comment.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get underReview => 'Under review';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reply => 'Reply';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String replyingTo(Object name) {
|
||||||
|
return 'Replying to $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelReply => 'Cancel reply';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String viewReplies(int count) {
|
||||||
|
return 'View replies ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hideReplies => 'Hide replies';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get anonymousUser => 'Viewer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get more => 'More';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get likeFailed => 'Could not update like. Please retry.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportComment => 'Report comment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportDrama => 'Report drama';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReason => 'Reason';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonSpam => 'Spam';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonAbuse => 'Harassment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonPorn => 'Sexual content';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonAd => 'Advertising';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonOther => 'Other';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportDescriptionHint => 'Describe the issue (optional)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportSubmit => 'Submit report';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportSubmitted => 'Report submitted. Thank you.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportFailed => 'Could not submit report.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get shareFailed => 'Could not open the share sheet.';
|
String get shareFailed => 'Could not open the share sheet.';
|
||||||
|
|
||||||
@@ -296,9 +386,41 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get catalogError => 'The catalog could not be loaded.';
|
String get catalogError => 'The catalog could not be loaded.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get categoriesError => 'Categories 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
|
@override
|
||||||
String get noStories => 'No stories available yet.';
|
String get noStories => 'No stories available yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notices => 'Notices';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noNotices => 'No notices yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticesLoadFailed => 'Notices could not be loaded.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticeDetails => 'Notice details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticeAnnouncement => 'Notice';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get close => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String carouselSemantics(int current, int total) {
|
||||||
|
return 'Banner $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get retry => 'Retry';
|
String get retry => 'Retry';
|
||||||
|
|
||||||
@@ -344,6 +466,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get simplifiedChinese => '简体中文';
|
String get simplifiedChinese => '简体中文';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bahasaIndonesia => 'Bahasa Indonesia';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get episodeOneTitle => 'First Light';
|
String get episodeOneTitle => 'First Light';
|
||||||
|
|
||||||
@@ -399,8 +524,305 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get preferencesHeading => 'PREFERENCES';
|
String get preferencesHeading => 'PREFERENCES';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get brandMark => 'NONTONKI · DEMO';
|
String get brandMark => 'FRUITSHORTS';
|
||||||
|
|
||||||
@override
|
@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 authBrandTagline => 'Short dramas made for Indonesia';
|
||||||
|
|
||||||
|
@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 =>
|
||||||
|
'Watch history, favorites, comments and downloads need an account.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authGateAction => 'Sign In';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showPassword => 'Show password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hidePassword => 'Hide password';
|
||||||
|
|
||||||
|
@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 myFavorites => 'My favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavorites => 'No favorites yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloads => 'Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadsError => 'Download settings could not be loaded.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDownloads => 'No download authorizations yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadedItems => 'Download authorizations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadQuota(int remaining, int max) {
|
||||||
|
return '$remaining of $max authorizations available';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadLicenseDays(int days) {
|
||||||
|
return 'Authorization valid for $days days';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadWifiOnly => 'Wi-Fi only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadQuality => 'Maximum quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadStatus(Object status) {
|
||||||
|
return 'Authorization: $status';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadTaskStatus(Object status) {
|
||||||
|
return 'Task: $status';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadProgress(int progress) {
|
||||||
|
return '$progress%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadExpiresAt(Object time) {
|
||||||
|
return 'Expires $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearDownloadsTitle => 'Clear download authorizations?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearDownloadsMessage =>
|
||||||
|
'This revokes every download authorization for this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSettingsFailed => 'Could not update download settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadDeleteFailed =>
|
||||||
|
'Could not remove this download authorization.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadClearFailed => 'Could not clear download authorizations.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadGrantFailed => 'Could not request this download.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadGranted => 'Download authorization is ready.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noWatchRecords => 'No watch history yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordDeleteFailed =>
|
||||||
|
'Could not delete this record. Please retry.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordClearFailed =>
|
||||||
|
'Could not clear watch history. Please retry.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearAll => 'Clear all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearWatchHistoryTitle => 'Clear watch history?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearWatchHistoryMessage =>
|
||||||
|
'This removes the watch history from your account.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get confirmClear => 'Clear';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detailLoadFailed => 'Details failed to load';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retryDetail => 'Retry';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get episodeUnavailable => 'Episode unavailable';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String updatedAtLabel(Object time) {
|
||||||
|
return 'Updated $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,826 @@
|
|||||||
|
// 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 allDramas => 'Semua Drama';
|
||||||
|
|
||||||
|
@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 noComments => 'Belum ada komentar.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get writeComment => 'Tulis komentar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get postComment => 'Kirim';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadMore => 'Muat lebih banyak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get commentsLoadFailed => 'Komentar tidak dapat dimuat.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get commentPostFailed => 'Komentar tidak dapat dikirim.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get delete => 'Hapus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteComment => 'Hapus komentar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteCommentTitle => 'Hapus komentar ini?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteCommentMessage =>
|
||||||
|
'Komentar ini akan dihapus dari percakapan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get commentDeleteFailed => 'Komentar tidak dapat dihapus.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get underReview => 'Sedang ditinjau';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reply => 'Balas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String replyingTo(Object name) {
|
||||||
|
return 'Membalas $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelReply => 'Batal membalas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String viewReplies(int count) {
|
||||||
|
return 'Lihat balasan ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hideReplies => 'Sembunyikan balasan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get anonymousUser => 'Penonton';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get more => 'Lainnya';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get likeFailed => 'Status suka tidak dapat diperbarui.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportComment => 'Laporkan komentar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportDrama => 'Laporkan drama';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReason => 'Alasan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonSpam => 'Spam';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonAbuse => 'Pelecehan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonPorn => 'Konten seksual';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonAd => 'Iklan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonOther => 'Lainnya';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportDescriptionHint => 'Jelaskan masalah (opsional)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportSubmit => 'Kirim laporan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportSubmitted => 'Laporan terkirim. Terima kasih.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportFailed => 'Laporan tidak dapat dikirim.';
|
||||||
|
|
||||||
|
@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 categoriesError => 'Kategori 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 notices => 'Notifikasi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noNotices => 'Belum ada notifikasi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticesLoadFailed => 'Notifikasi tidak dapat dimuat.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticeDetails => 'Detail notifikasi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticeAnnouncement => 'Pengumuman';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get close => 'Tutup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String carouselSemantics(int current, int total) {
|
||||||
|
return 'Banner $current dari $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 bahasaIndonesia => 'Bahasa Indonesia';
|
||||||
|
|
||||||
|
@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 authBrandTagline => 'Drama pendek untuk Indonesia';
|
||||||
|
|
||||||
|
@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 =>
|
||||||
|
'Riwayat tontonan, favorit, komentar, dan unduhan perlu akun.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authGateAction => 'Masuk';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showPassword => 'Tampilkan kata sandi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hidePassword => 'Sembunyikan kata sandi';
|
||||||
|
|
||||||
|
@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 myFavorites => 'Favorit saya';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavorites => 'Belum ada favorit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloads => 'Unduhan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadsError => 'Pengaturan unduhan gagal dimuat.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDownloads => 'Belum ada izin unduhan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadedItems => 'Izin unduhan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadQuota(int remaining, int max) {
|
||||||
|
return '$remaining dari $max izin tersedia';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadLicenseDays(int days) {
|
||||||
|
return 'Izin berlaku $days hari';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadWifiOnly => 'Hanya Wi-Fi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadQuality => 'Kualitas maksimum';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadStatus(Object status) {
|
||||||
|
return 'Izin: $status';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadTaskStatus(Object status) {
|
||||||
|
return 'Tugas: $status';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadProgress(int progress) {
|
||||||
|
return '$progress%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadExpiresAt(Object time) {
|
||||||
|
return 'Berakhir $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearDownloadsTitle => 'Hapus izin unduhan?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearDownloadsMessage =>
|
||||||
|
'Semua izin unduhan untuk perangkat ini akan dicabut.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSettingsFailed => 'Gagal memperbarui pengaturan unduhan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadDeleteFailed => 'Gagal menghapus izin unduhan ini.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadClearFailed => 'Gagal menghapus semua izin unduhan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadGrantFailed => 'Gagal meminta unduhan ini.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadGranted => 'Izin unduhan sudah siap.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noWatchRecords => 'Belum ada riwayat tontonan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordDeleteFailed => 'Gagal menghapus riwayat ini. Coba lagi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordClearFailed => 'Gagal menghapus semua riwayat. Coba lagi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearAll => 'Hapus semua';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearWatchHistoryTitle => 'Hapus riwayat tontonan?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearWatchHistoryMessage =>
|
||||||
|
'Semua riwayat akan dihapus dari akun kamu.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get confirmClear => 'Hapus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detailLoadFailed => 'Detail drama gagal dimuat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retryDetail => 'Coba lagi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get episodeUnavailable => 'Episode tidak tersedia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String updatedAtLabel(Object time) {
|
||||||
|
return 'Diperbarui $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
// ignore: unused_import
|
// ignore: unused_import
|
||||||
import 'package:intl/intl.dart' as intl;
|
import 'package:intl/intl.dart' as intl;
|
||||||
import 'app_localizations.dart';
|
import 'app_localizations.dart';
|
||||||
@@ -11,7 +9,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
AppLocalizationsZh([String locale = 'zh']) : super(locale);
|
AppLocalizationsZh([String locale = 'zh']) : super(locale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appName => 'Nontonki';
|
String get appName => 'FruitShorts';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get demo => '演示';
|
String get demo => '演示';
|
||||||
@@ -156,6 +154,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noCategoryDramas => '这个分类暂时没有剧集。';
|
String get noCategoryDramas => '这个分类暂时没有剧集。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allDramas => '全部剧目';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trendingTab => '热门';
|
String get trendingTab => '热门';
|
||||||
|
|
||||||
@@ -186,9 +187,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get share => '分享';
|
String get share => '分享';
|
||||||
|
|
||||||
@override
|
|
||||||
String get localOnlyComments => '仅在本机可见';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noComments => '还没有评论。';
|
String get noComments => '还没有评论。';
|
||||||
|
|
||||||
@@ -198,6 +196,97 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get postComment => '发布';
|
String get postComment => '发布';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadMore => '加载更多';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get commentsLoadFailed => '评论加载失败,请重试。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get commentPostFailed => '评论发布失败,请重试。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get delete => '删除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteComment => '删除评论';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteCommentTitle => '删除这条评论?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteCommentMessage => '删除后,这条评论将从评论列表中移除。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get commentDeleteFailed => '评论删除失败,请重试。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get underReview => '审核中';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reply => '回复';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String replyingTo(Object name) {
|
||||||
|
return '回复 $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelReply => '取消回复';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String viewReplies(int count) {
|
||||||
|
return '查看回复($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hideReplies => '收起回复';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get anonymousUser => '观众';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get more => '更多';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get likeFailed => '点赞状态更新失败,请重试。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportComment => '举报评论';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportDrama => '举报短剧';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReason => '举报原因';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonSpam => '垃圾广告';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonAbuse => '辱骂骚扰';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonPorn => '色情低俗';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonAd => '引流广告';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportReasonOther => '其他';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportDescriptionHint => '补充说明(选填)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportSubmit => '提交举报';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportSubmitted => '举报已提交,感谢反馈。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reportFailed => '举报提交失败,请重试。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get shareFailed => '无法打开系统分享面板。';
|
String get shareFailed => '无法打开系统分享面板。';
|
||||||
|
|
||||||
@@ -291,9 +380,41 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get catalogError => '剧目列表加载失败。';
|
String get catalogError => '剧目列表加载失败。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get categoriesError => '分类加载失败。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detailError => '剧集信息加载失败。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noMediaNotice => '该剧集还没有可播放的片源。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noStories => '暂无剧目。';
|
String get noStories => '暂无剧目。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notices => '通知';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noNotices => '暂无通知。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticesLoadFailed => '通知加载失败,请重试。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticeDetails => '通知详情';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticeAnnouncement => '公告';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get close => '关闭';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String carouselSemantics(int current, int total) {
|
||||||
|
return '第 $current 张,共 $total 张';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get retry => '重试';
|
String get retry => '重试';
|
||||||
|
|
||||||
@@ -339,6 +460,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get simplifiedChinese => '简体中文';
|
String get simplifiedChinese => '简体中文';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bahasaIndonesia => '印尼语';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get episodeOneTitle => '初见微光';
|
String get episodeOneTitle => '初见微光';
|
||||||
|
|
||||||
@@ -391,8 +515,297 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get preferencesHeading => '偏好设置';
|
String get preferencesHeading => '偏好设置';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get brandMark => 'NONTONKI · 演示';
|
String get brandMark => 'FRUITSHORTS';
|
||||||
|
|
||||||
@override
|
@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 authBrandTagline => '为印尼市场打造的短剧体验';
|
||||||
|
|
||||||
|
@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 showPassword => '显示密码';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hidePassword => '隐藏密码';
|
||||||
|
|
||||||
|
@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 myFavorites => '我的收藏';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavorites => '还没有收藏。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloads => '下载';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadsError => '下载设置加载失败。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDownloads => '还没有下载授权。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadedItems => '下载授权';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadQuota(int remaining, int max) {
|
||||||
|
return '剩余 $remaining / $max 个授权';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadLicenseDays(int days) {
|
||||||
|
return '授权有效期 $days 天';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadWifiOnly => '仅 Wi-Fi 下载';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadQuality => '最高清晰度';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadStatus(Object status) {
|
||||||
|
return '授权:$status';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadTaskStatus(Object status) {
|
||||||
|
return '任务:$status';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadProgress(int progress) {
|
||||||
|
return '$progress%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadExpiresAt(Object time) {
|
||||||
|
return '到期时间 $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearDownloadsTitle => '清空下载授权?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearDownloadsMessage => '这会吊销当前设备的全部下载授权。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSettingsFailed => '下载设置更新失败。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadDeleteFailed => '删除下载授权失败。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadClearFailed => '清空下载授权失败。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadGrantFailed => '申请下载失败。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadGranted => '下载授权已就绪。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noWatchRecords => '还没有观看记录。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordDeleteFailed => '删除记录失败,请重试。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordClearFailed => '清空观看记录失败,请重试。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearAll => '清空';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearWatchHistoryTitle => '清空观看记录?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearWatchHistoryMessage => '这会从当前账号移除全部观看记录。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get confirmClear => '清空';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detailLoadFailed => '剧集信息加载失败';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retryDetail => '重试';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get episodeUnavailable => '集数未知';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String updatedAtLabel(Object time) {
|
||||||
|
return '更新于 $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remove => '移除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unlockComingSoon => '金币解锁功能即将开放。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String resumeProgress(Object position, Object total) {
|
||||||
|
return '续看至 $position / $total';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+221
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "zh",
|
"@@locale": "zh",
|
||||||
"appName": "Nontonki",
|
"appName": "FruitShorts",
|
||||||
"demo": "演示",
|
"demo": "演示",
|
||||||
"homeHeadline": "即刻观看",
|
"homeHeadline": "即刻观看",
|
||||||
"homeSubtitle": "免费短剧预览。",
|
"homeSubtitle": "免费短剧预览。",
|
||||||
@@ -34,7 +34,13 @@
|
|||||||
"localDemoProfile": "本地演示档案",
|
"localDemoProfile": "本地演示档案",
|
||||||
"continueWatching": "继续观看",
|
"continueWatching": "继续观看",
|
||||||
"watchedPercent": "已观看 {percent}%",
|
"watchedPercent": "已观看 {percent}%",
|
||||||
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
|
"@watchedPercent": {
|
||||||
|
"placeholders": {
|
||||||
|
"percent": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"resume": "继续播放",
|
"resume": "继续播放",
|
||||||
"history": "历史",
|
"history": "历史",
|
||||||
"watchlist": "追剧",
|
"watchlist": "追剧",
|
||||||
@@ -49,6 +55,7 @@
|
|||||||
"animatedTab": "漫剧",
|
"animatedTab": "漫剧",
|
||||||
"recommendedTab": "推荐",
|
"recommendedTab": "推荐",
|
||||||
"noCategoryDramas": "这个分类暂时没有剧集。",
|
"noCategoryDramas": "这个分类暂时没有剧集。",
|
||||||
|
"allDramas": "全部剧目",
|
||||||
"trendingTab": "热门",
|
"trendingTab": "热门",
|
||||||
"liveActionTab": "真人剧",
|
"liveActionTab": "真人剧",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
@@ -59,10 +66,50 @@
|
|||||||
"save": "收藏",
|
"save": "收藏",
|
||||||
"comments": "评论",
|
"comments": "评论",
|
||||||
"share": "分享",
|
"share": "分享",
|
||||||
"localOnlyComments": "仅在本机可见",
|
|
||||||
"noComments": "还没有评论。",
|
"noComments": "还没有评论。",
|
||||||
"writeComment": "写评论",
|
"writeComment": "写评论",
|
||||||
"postComment": "发布",
|
"postComment": "发布",
|
||||||
|
"loadMore": "加载更多",
|
||||||
|
"commentsLoadFailed": "评论加载失败,请重试。",
|
||||||
|
"commentPostFailed": "评论发布失败,请重试。",
|
||||||
|
"delete": "删除",
|
||||||
|
"deleteComment": "删除评论",
|
||||||
|
"deleteCommentTitle": "删除这条评论?",
|
||||||
|
"deleteCommentMessage": "删除后,这条评论将从评论列表中移除。",
|
||||||
|
"commentDeleteFailed": "评论删除失败,请重试。",
|
||||||
|
"underReview": "审核中",
|
||||||
|
"reply": "回复",
|
||||||
|
"replyingTo": "回复 {name}",
|
||||||
|
"@replyingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cancelReply": "取消回复",
|
||||||
|
"viewReplies": "查看回复({count})",
|
||||||
|
"@viewReplies": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hideReplies": "收起回复",
|
||||||
|
"anonymousUser": "观众",
|
||||||
|
"more": "更多",
|
||||||
|
"likeFailed": "点赞状态更新失败,请重试。",
|
||||||
|
"reportComment": "举报评论",
|
||||||
|
"reportDrama": "举报短剧",
|
||||||
|
"reportReason": "举报原因",
|
||||||
|
"reportReasonSpam": "垃圾广告",
|
||||||
|
"reportReasonAbuse": "辱骂骚扰",
|
||||||
|
"reportReasonPorn": "色情低俗",
|
||||||
|
"reportReasonAd": "引流广告",
|
||||||
|
"reportReasonOther": "其他",
|
||||||
|
"reportDescriptionHint": "补充说明(选填)",
|
||||||
|
"reportSubmit": "提交举报",
|
||||||
|
"reportSubmitted": "举报已提交,感谢反馈。",
|
||||||
|
"reportFailed": "举报提交失败,请重试。",
|
||||||
"shareFailed": "无法打开系统分享面板。",
|
"shareFailed": "无法打开系统分享面板。",
|
||||||
"nowPlaying": "正在播放",
|
"nowPlaying": "正在播放",
|
||||||
"watchFullscreen": "横屏全屏观看",
|
"watchFullscreen": "横屏全屏观看",
|
||||||
@@ -72,7 +119,13 @@
|
|||||||
"originalTag": "原创",
|
"originalTag": "原创",
|
||||||
"dramaTag": "短剧",
|
"dramaTag": "短剧",
|
||||||
"episodeLabel": "第 {number} 集",
|
"episodeLabel": "第 {number} 集",
|
||||||
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
|
"@episodeLabel": {
|
||||||
|
"placeholders": {
|
||||||
|
"number": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"exploreSeries": "查看剧集",
|
"exploreSeries": "查看剧集",
|
||||||
"episodeCount": "{count} 集",
|
"episodeCount": "{count} 集",
|
||||||
"technicalDemoCount": "免费试看 · {count} 集",
|
"technicalDemoCount": "免费试看 · {count} 集",
|
||||||
@@ -91,7 +144,27 @@
|
|||||||
"lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。",
|
"lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。",
|
||||||
"playbackError": "此视频暂时无法播放。",
|
"playbackError": "此视频暂时无法播放。",
|
||||||
"catalogError": "剧目列表加载失败。",
|
"catalogError": "剧目列表加载失败。",
|
||||||
|
"categoriesError": "分类加载失败。",
|
||||||
|
"detailError": "剧集信息加载失败。",
|
||||||
|
"noMediaNotice": "该剧集还没有可播放的片源。",
|
||||||
"noStories": "暂无剧目。",
|
"noStories": "暂无剧目。",
|
||||||
|
"notices": "通知",
|
||||||
|
"noNotices": "暂无通知。",
|
||||||
|
"noticesLoadFailed": "通知加载失败,请重试。",
|
||||||
|
"noticeDetails": "通知详情",
|
||||||
|
"noticeAnnouncement": "公告",
|
||||||
|
"close": "关闭",
|
||||||
|
"carouselSemantics": "第 {current} 张,共 {total} 张",
|
||||||
|
"@carouselSemantics": {
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"backToSeries": "返回剧集详情",
|
"backToSeries": "返回剧集详情",
|
||||||
"nextEpisode": "下一集",
|
"nextEpisode": "下一集",
|
||||||
@@ -107,6 +180,7 @@
|
|||||||
"systemLanguage": "跟随系统",
|
"systemLanguage": "跟随系统",
|
||||||
"english": "English",
|
"english": "English",
|
||||||
"simplifiedChinese": "简体中文",
|
"simplifiedChinese": "简体中文",
|
||||||
|
"bahasaIndonesia": "印尼语",
|
||||||
"episodeOneTitle": "初见微光",
|
"episodeOneTitle": "初见微光",
|
||||||
"episodeTwoTitle": "新的频率",
|
"episodeTwoTitle": "新的频率",
|
||||||
"episodeThreeTitle": "越过边界",
|
"episodeThreeTitle": "越过边界",
|
||||||
@@ -124,6 +198,147 @@
|
|||||||
"genreDramaMystery": "剧情 · 悬疑",
|
"genreDramaMystery": "剧情 · 悬疑",
|
||||||
"continueWatchingHeading": "继续观看",
|
"continueWatchingHeading": "继续观看",
|
||||||
"preferencesHeading": "偏好设置",
|
"preferencesHeading": "偏好设置",
|
||||||
"brandMark": "NONTONKI · 演示",
|
"brandMark": "FRUITSHORTS",
|
||||||
"shareFooter": "Nontonki 演示"
|
"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": "用邮箱和密码登录。",
|
||||||
|
"authBrandTagline": "为印尼市场打造的短剧体验",
|
||||||
|
"authEmailLabel": "邮箱",
|
||||||
|
"authPasswordLabel": "密码",
|
||||||
|
"authPasswordHint": "至少 8 位,需包含字母和数字",
|
||||||
|
"authEmailRequired": "请输入邮箱",
|
||||||
|
"authEmailInvalid": "邮箱格式不正确",
|
||||||
|
"authPasswordRequired": "请输入密码",
|
||||||
|
"authPasswordLength": "密码长度需在 8-64 位之间",
|
||||||
|
"authPasswordWeak": "密码需同时包含字母和数字",
|
||||||
|
"authNetworkError": "网络异常,请稍后重试",
|
||||||
|
"authSwitchToSignUp": "还没有账号?去注册",
|
||||||
|
"authSwitchToSignIn": "已有账号?去登录",
|
||||||
|
"authGateTitle": "登录后可继续",
|
||||||
|
"authGateMessage": "观看记录、收藏、评论和下载需要登录。",
|
||||||
|
"authGateAction": "去登录",
|
||||||
|
"showPassword": "显示密码",
|
||||||
|
"hidePassword": "隐藏密码",
|
||||||
|
"cancel": "暂不",
|
||||||
|
"noEpisodesYet": "还没有可播放的剧集。",
|
||||||
|
"networkError": "网络异常,请检查网络后重试。",
|
||||||
|
"progressError": "续看进度加载失败。",
|
||||||
|
"favoritesError": "收藏列表加载失败。",
|
||||||
|
"favoriteToggleFailed": "收藏操作失败,请重试。",
|
||||||
|
"myFavorites": "我的收藏",
|
||||||
|
"noFavorites": "还没有收藏。",
|
||||||
|
"downloads": "下载",
|
||||||
|
"downloadsError": "下载设置加载失败。",
|
||||||
|
"noDownloads": "还没有下载授权。",
|
||||||
|
"downloadedItems": "下载授权",
|
||||||
|
"downloadQuota": "剩余 {remaining} / {max} 个授权",
|
||||||
|
"@downloadQuota": {
|
||||||
|
"placeholders": {
|
||||||
|
"remaining": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadLicenseDays": "授权有效期 {days} 天",
|
||||||
|
"@downloadLicenseDays": {
|
||||||
|
"placeholders": {
|
||||||
|
"days": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadWifiOnly": "仅 Wi-Fi 下载",
|
||||||
|
"downloadQuality": "最高清晰度",
|
||||||
|
"downloadStatus": "授权:{status}",
|
||||||
|
"@downloadStatus": {
|
||||||
|
"placeholders": {
|
||||||
|
"status": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadTaskStatus": "任务:{status}",
|
||||||
|
"@downloadTaskStatus": {
|
||||||
|
"placeholders": {
|
||||||
|
"status": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadProgress": "{progress}%",
|
||||||
|
"@downloadProgress": {
|
||||||
|
"placeholders": {
|
||||||
|
"progress": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloadExpiresAt": "到期时间 {time}",
|
||||||
|
"@downloadExpiresAt": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clearDownloadsTitle": "清空下载授权?",
|
||||||
|
"clearDownloadsMessage": "这会吊销当前设备的全部下载授权。",
|
||||||
|
"downloadSettingsFailed": "下载设置更新失败。",
|
||||||
|
"downloadDeleteFailed": "删除下载授权失败。",
|
||||||
|
"downloadClearFailed": "清空下载授权失败。",
|
||||||
|
"downloadGrantFailed": "申请下载失败。",
|
||||||
|
"downloadGranted": "下载授权已就绪。",
|
||||||
|
"noWatchRecords": "还没有观看记录。",
|
||||||
|
"recordDeleteFailed": "删除记录失败,请重试。",
|
||||||
|
"recordClearFailed": "清空观看记录失败,请重试。",
|
||||||
|
"clearAll": "清空",
|
||||||
|
"clearWatchHistoryTitle": "清空观看记录?",
|
||||||
|
"clearWatchHistoryMessage": "这会从当前账号移除全部观看记录。",
|
||||||
|
"confirmClear": "清空",
|
||||||
|
"detailLoadFailed": "剧集信息加载失败",
|
||||||
|
"retryDetail": "重试",
|
||||||
|
"episodeUnavailable": "集数未知",
|
||||||
|
"updatedAtLabel": "更新于 {time}",
|
||||||
|
"@updatedAtLabel": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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,363 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
part of 'auth_view.dart';
|
||||||
|
|
||||||
|
class _AuthFields extends StatelessWidget {
|
||||||
|
const _AuthFields({required this.state, required this.callbacks});
|
||||||
|
|
||||||
|
final _AuthCardState state;
|
||||||
|
final _AuthCardCallbacks callbacks;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_fieldLabel(state.l10n.authEmailLabel),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_emailField(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_fieldLabel(state.l10n.authPasswordLabel),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_passwordField(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _emailField() {
|
||||||
|
final enabled = !state.status.busy;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: state.fields.emailFocus,
|
||||||
|
builder: (context, _) => SizedBox(
|
||||||
|
height: _fieldHeight,
|
||||||
|
child: TextField(
|
||||||
|
key: const Key('auth-email'),
|
||||||
|
controller: state.fields.email,
|
||||||
|
focusNode: state.fields.emailFocus,
|
||||||
|
enabled: enabled,
|
||||||
|
autofocus: true,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autofillHints: const [AutofillHints.email],
|
||||||
|
onChanged: callbacks.onEmailChanged,
|
||||||
|
onSubmitted: (_) => state.fields.passwordFocus.requestFocus(),
|
||||||
|
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
|
||||||
|
decoration: _decoration(
|
||||||
|
hint: 'name@example.com',
|
||||||
|
icon: Icons.mail_outline_rounded,
|
||||||
|
focused: state.fields.emailFocus.hasFocus,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _passwordField() {
|
||||||
|
final enabled = !state.status.busy;
|
||||||
|
final l10n = state.l10n;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: state.fields.passwordFocus,
|
||||||
|
builder: (context, _) => SizedBox(
|
||||||
|
height: _fieldHeight,
|
||||||
|
child: TextField(
|
||||||
|
key: const Key('auth-password'),
|
||||||
|
controller: state.fields.password,
|
||||||
|
focusNode: state.fields.passwordFocus,
|
||||||
|
enabled: enabled,
|
||||||
|
obscureText: state.status.obscurePassword,
|
||||||
|
keyboardType: TextInputType.visiblePassword,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
onChanged: callbacks.onPasswordChanged,
|
||||||
|
onSubmitted: (_) => callbacks.onSubmit(),
|
||||||
|
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
|
||||||
|
decoration: _decoration(
|
||||||
|
hint: l10n.authPasswordHint,
|
||||||
|
icon: Icons.lock_outline_rounded,
|
||||||
|
focused: state.fields.passwordFocus.hasFocus,
|
||||||
|
suffix: IconButton(
|
||||||
|
key: const Key('auth-password-visibility'),
|
||||||
|
constraints: const BoxConstraints.tightFor(
|
||||||
|
width: _minTapTarget,
|
||||||
|
height: _minTapTarget,
|
||||||
|
),
|
||||||
|
tooltip: state.status.obscurePassword
|
||||||
|
? l10n.showPassword
|
||||||
|
: l10n.hidePassword,
|
||||||
|
onPressed: enabled ? callbacks.onTogglePasswordVisibility : null,
|
||||||
|
icon: Icon(
|
||||||
|
state.status.obscurePassword
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _fieldLabel(String label) => Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.muted,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
InputDecoration _decoration({
|
||||||
|
required String hint,
|
||||||
|
required IconData icon,
|
||||||
|
required bool focused,
|
||||||
|
Widget? suffix,
|
||||||
|
}) {
|
||||||
|
final border = OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(_fieldRadius),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
);
|
||||||
|
return InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
icon,
|
||||||
|
size: 21,
|
||||||
|
color: focused ? AppColors.blueSoft : AppColors.muted,
|
||||||
|
),
|
||||||
|
prefixIconConstraints: const BoxConstraints(
|
||||||
|
minWidth: _minTapTarget,
|
||||||
|
minHeight: _minTapTarget,
|
||||||
|
),
|
||||||
|
suffixIcon: suffix,
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.bar,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.only(right: 16),
|
||||||
|
border: border,
|
||||||
|
enabledBorder: border,
|
||||||
|
disabledBorder: border,
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(_fieldRadius),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: AppColors.blue,
|
||||||
|
width: _fieldBorderWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthSubmitButton extends StatelessWidget {
|
||||||
|
const _AuthSubmitButton({required this.state, required this.onSubmit});
|
||||||
|
|
||||||
|
final _AuthCardState state;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final status = state.status;
|
||||||
|
final l10n = state.l10n;
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: _buttonHeight,
|
||||||
|
child: FilledButton(
|
||||||
|
key: const Key('auth-submit'),
|
||||||
|
onPressed: status.busy ? null : onSubmit,
|
||||||
|
style: ButtonStyle(
|
||||||
|
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.disabled)) {
|
||||||
|
return AppColors.blue.withValues(alpha: 0.5);
|
||||||
|
}
|
||||||
|
if (states.contains(WidgetState.pressed)) {
|
||||||
|
return AppColors.bluePressed;
|
||||||
|
}
|
||||||
|
return AppColors.blue;
|
||||||
|
}),
|
||||||
|
foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary),
|
||||||
|
elevation: const WidgetStatePropertyAll(0),
|
||||||
|
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
|
||||||
|
shape: WidgetStatePropertyAll(
|
||||||
|
RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(_buttonRadius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: status.busy
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
status.signUpMode ? l10n.signUp : l10n.signIn,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModeSegment extends StatelessWidget {
|
||||||
|
const _ModeSegment({
|
||||||
|
required this.segmentKey,
|
||||||
|
required this.label,
|
||||||
|
required this.hiddenLabel,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Key segmentKey;
|
||||||
|
final String label;
|
||||||
|
final String hiddenLabel;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
key: segmentKey,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: _segmentDuration,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? AppColors.blue : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: selected ? AppColors.onPrimary : AppColors.muted,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: ExcludeSemantics(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
hiddenLabel,
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthBrand extends StatelessWidget {
|
||||||
|
const _AuthBrand({required this.tagline});
|
||||||
|
|
||||||
|
final String tagline;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
key: const Key('auth-brand'),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
key: const Key('auth-brand-mark'),
|
||||||
|
width: _brandMarkSize,
|
||||||
|
height: _brandMarkSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.blue,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.blue.withValues(alpha: 0.32),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.movie_filter_rounded,
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
size: 26,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context)!.appName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
fontSize: 23,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
tagline,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.blueSoft,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthError extends StatelessWidget {
|
||||||
|
const _AuthError({required this.message});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = Theme.of(context).colorScheme.error;
|
||||||
|
return Container(
|
||||||
|
key: const Key('auth-error-capsule'),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: _errorOpacity),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: color.withValues(alpha: _errorBorderOpacity)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline_rounded, color: color, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
key: const Key('auth-error'),
|
||||||
|
style: TextStyle(color: color, fontSize: 13, height: 1.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,259 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
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/platform/haptics.dart';
|
||||||
|
import '../../core/theme/app_colors.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import 'auth_logic.dart';
|
||||||
|
|
||||||
|
part 'auth_widgets.dart';
|
||||||
|
part 'auth_form_widgets.dart';
|
||||||
|
|
||||||
|
const double _minTapTarget = 44;
|
||||||
|
const double _brandMarkSize = 48;
|
||||||
|
const double _contentMaxWidth = 440;
|
||||||
|
const double _cardRadius = 20;
|
||||||
|
const double _cardPadding = 20;
|
||||||
|
const double _cardBorderWidth = 1;
|
||||||
|
const double _fieldHeight = 52;
|
||||||
|
const double _fieldRadius = 14;
|
||||||
|
const double _fieldBorderWidth = 1.5;
|
||||||
|
const double _buttonHeight = 52;
|
||||||
|
const double _buttonRadius = 26;
|
||||||
|
const double _glowOpacity = 0.18;
|
||||||
|
const double _errorOpacity = 0.10;
|
||||||
|
const double _errorBorderOpacity = 0.28;
|
||||||
|
const double _enterSlideDistance = 16;
|
||||||
|
const Duration _enterDuration = Duration(milliseconds: 220);
|
||||||
|
const Duration _segmentDuration = Duration(milliseconds: 160);
|
||||||
|
|
||||||
|
/// 登录 / 注册页:邮箱 + 密码。
|
||||||
|
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;
|
||||||
|
late final TextEditingController _emailController;
|
||||||
|
late final TextEditingController _passwordController;
|
||||||
|
late final FocusNode _emailFocus;
|
||||||
|
late final FocusNode _passwordFocus;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_logic = AuthLogic(signUpMode: widget.signUpMode);
|
||||||
|
_emailController = TextEditingController();
|
||||||
|
_passwordController = TextEditingController();
|
||||||
|
_emailFocus = FocusNode(debugLabel: 'auth-email-focus');
|
||||||
|
_passwordFocus = FocusNode(debugLabel: 'auth-password-focus');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_emailFocus.dispose();
|
||||||
|
_passwordFocus.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
unawaited(context.read<Haptics>().lightImpact());
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
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 Theme(
|
||||||
|
data: AppTheme.dark(),
|
||||||
|
child: Scaffold(
|
||||||
|
resizeToAvoidBottomInset: true,
|
||||||
|
backgroundColor: AppColors.ink,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 420,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Container(
|
||||||
|
key: const Key('auth-background-glow'),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
AppColors.blue.withValues(alpha: _glowOpacity),
|
||||||
|
AppColors.blue.withValues(alpha: 0.05),
|
||||||
|
AppColors.ink.withValues(alpha: 0),
|
||||||
|
],
|
||||||
|
stops: const [0, 0.55, 1],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: AutofillGroup(
|
||||||
|
child: ListView(
|
||||||
|
key: const Key('auth-scroll'),
|
||||||
|
keyboardDismissBehavior:
|
||||||
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
12,
|
||||||
|
20,
|
||||||
|
28 + MediaQuery.viewInsetsOf(context).bottom,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: _contentMaxWidth,
|
||||||
|
),
|
||||||
|
child: TweenAnimationBuilder<double>(
|
||||||
|
key: const Key('auth-enter-animation'),
|
||||||
|
tween: Tween(begin: 0, end: 1),
|
||||||
|
duration: _enterDuration,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (context, value, child) => Opacity(
|
||||||
|
opacity: value,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(
|
||||||
|
0,
|
||||||
|
_enterSlideDistance * (1 - value),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (Navigator.of(context).canPop())
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: IconButton(
|
||||||
|
key: const Key('auth-back'),
|
||||||
|
tooltip: MaterialLocalizations.of(
|
||||||
|
context,
|
||||||
|
).backButtonTooltip,
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.arrow_back_rounded,
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_AuthBrand(tagline: l10n.authBrandTagline),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
_authCard(l10n),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _authCard(AppLocalizations l10n) => _AuthCard(
|
||||||
|
state: _AuthCardState(
|
||||||
|
l10n: l10n,
|
||||||
|
status: _AuthFormStatus(
|
||||||
|
signUpMode: _logic.signUpMode,
|
||||||
|
busy: _logic.busy,
|
||||||
|
errorMessage: _logic.errorMessage,
|
||||||
|
obscurePassword: _obscurePassword,
|
||||||
|
),
|
||||||
|
fields: _AuthFieldControllers(
|
||||||
|
email: _emailController,
|
||||||
|
password: _passwordController,
|
||||||
|
emailFocus: _emailFocus,
|
||||||
|
passwordFocus: _passwordFocus,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
callbacks: _AuthCardCallbacks(
|
||||||
|
onModeSelected: _selectMode,
|
||||||
|
onEmailChanged: (value) => _logic.email = value,
|
||||||
|
onPasswordChanged: (value) => _logic.password = value,
|
||||||
|
onTogglePasswordVisibility: () =>
|
||||||
|
setState(() => _obscurePassword = !_obscurePassword),
|
||||||
|
onSubmit: _submit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
void _selectMode(bool signUpMode) {
|
||||||
|
if (_logic.busy || _logic.signUpMode == signUpMode) return;
|
||||||
|
setState(() => _logic.toggleMode());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
part of 'auth_view.dart';
|
||||||
|
|
||||||
|
class _AuthCardState {
|
||||||
|
const _AuthCardState({
|
||||||
|
required this.l10n,
|
||||||
|
required this.status,
|
||||||
|
required this.fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
final _AuthFormStatus status;
|
||||||
|
final _AuthFieldControllers fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthFormStatus {
|
||||||
|
const _AuthFormStatus({
|
||||||
|
required this.signUpMode,
|
||||||
|
required this.busy,
|
||||||
|
required this.errorMessage,
|
||||||
|
required this.obscurePassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool signUpMode;
|
||||||
|
final bool busy;
|
||||||
|
final String? errorMessage;
|
||||||
|
final bool obscurePassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthFieldControllers {
|
||||||
|
const _AuthFieldControllers({
|
||||||
|
required this.email,
|
||||||
|
required this.password,
|
||||||
|
required this.emailFocus,
|
||||||
|
required this.passwordFocus,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController email;
|
||||||
|
final TextEditingController password;
|
||||||
|
final FocusNode emailFocus;
|
||||||
|
final FocusNode passwordFocus;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthCardCallbacks {
|
||||||
|
const _AuthCardCallbacks({
|
||||||
|
required this.onModeSelected,
|
||||||
|
required this.onEmailChanged,
|
||||||
|
required this.onPasswordChanged,
|
||||||
|
required this.onTogglePasswordVisibility,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ValueChanged<bool> onModeSelected;
|
||||||
|
final ValueChanged<String> onEmailChanged;
|
||||||
|
final ValueChanged<String> onPasswordChanged;
|
||||||
|
final VoidCallback onTogglePasswordVisibility;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthCard extends StatelessWidget {
|
||||||
|
const _AuthCard({required this.state, required this.callbacks});
|
||||||
|
|
||||||
|
final _AuthCardState state;
|
||||||
|
final _AuthCardCallbacks callbacks;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = state.l10n;
|
||||||
|
final status = state.status;
|
||||||
|
return Container(
|
||||||
|
key: const Key('auth-card'),
|
||||||
|
padding: const EdgeInsets.all(_cardPadding),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.card,
|
||||||
|
borderRadius: BorderRadius.circular(_cardRadius),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.outlineVariantDark,
|
||||||
|
width: _cardBorderWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_AuthModeSwitch(
|
||||||
|
l10n: l10n,
|
||||||
|
signUpMode: status.signUpMode,
|
||||||
|
busy: status.busy,
|
||||||
|
onSelected: callbacks.onModeSelected,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
Text(
|
||||||
|
status.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
fontSize: 25,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
l10n.authSubtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.muted,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_AuthFields(state: state, callbacks: callbacks),
|
||||||
|
if (status.errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_AuthError(message: status.errorMessage!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_AuthSubmitButton(state: state, onSubmit: callbacks.onSubmit),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthModeSwitch extends StatelessWidget {
|
||||||
|
const _AuthModeSwitch({
|
||||||
|
required this.l10n,
|
||||||
|
required this.signUpMode,
|
||||||
|
required this.busy,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
final bool signUpMode;
|
||||||
|
final bool busy;
|
||||||
|
final ValueChanged<bool> onSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextButton(
|
||||||
|
key: const Key('auth-switch-mode'),
|
||||||
|
onPressed: busy ? null : () => onSelected(!signUpMode),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, _minTapTarget),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
overlayColor: Colors.transparent,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
height: _minTapTarget,
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.bar,
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
border: Border.all(color: AppColors.outlineVariantDark),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _ModeSegment(
|
||||||
|
segmentKey: const Key('auth-mode-sign-in'),
|
||||||
|
label: l10n.signIn,
|
||||||
|
hiddenLabel: l10n.authSwitchToSignIn,
|
||||||
|
selected: !signUpMode,
|
||||||
|
onTap: busy ? null : () => onSelected(false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: _ModeSegment(
|
||||||
|
segmentKey: const Key('auth-mode-sign-up'),
|
||||||
|
label: l10n.signUp,
|
||||||
|
hiddenLabel: l10n.authSwitchToSignUp,
|
||||||
|
selected: signUpMode,
|
||||||
|
onTap: busy ? null : () => onSelected(true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ import 'discover_logic.dart';
|
|||||||
class DiscoverBinding {
|
class DiscoverBinding {
|
||||||
DiscoverLogic create({
|
DiscoverLogic create({
|
||||||
required Drama drama,
|
required Drama drama,
|
||||||
WatchHistoryRepository? history,
|
required WatchHistoryRepository history,
|
||||||
}) => DiscoverLogic(
|
}) => DiscoverLogic(
|
||||||
drama: drama,
|
drama: drama,
|
||||||
history: history ?? SharedPreferencesWatchHistoryRepository(),
|
history: history,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
final TextEditingController search = TextEditingController();
|
final TextEditingController search = TextEditingController();
|
||||||
final FocusNode searchFocus = FocusNode();
|
final FocusNode searchFocus = FocusNode();
|
||||||
final ScrollController scroll = ScrollController();
|
final ScrollController scroll = ScrollController();
|
||||||
int category = 0;
|
bool searchOpen = false;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
void notify() {
|
void notify() {
|
||||||
@@ -27,20 +27,23 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void refreshPosition() {
|
void refreshPosition() {
|
||||||
position = history.positionFor(
|
// 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看"
|
||||||
demoProfileId,
|
position = history
|
||||||
drama.id,
|
.resumePointFor(drama.id)
|
||||||
drama.episodes.first.id,
|
.then((point) => point?.position)
|
||||||
);
|
.catchError((Object _) => null);
|
||||||
}
|
|
||||||
|
|
||||||
void selectCategory(int index) {
|
|
||||||
category = index;
|
|
||||||
notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onSearchChanged() => notify();
|
void onSearchChanged() => notify();
|
||||||
|
|
||||||
|
bool get showSearch => searchOpen || search.text.isNotEmpty;
|
||||||
|
|
||||||
|
void openSearch() {
|
||||||
|
searchOpen = true;
|
||||||
|
notify();
|
||||||
|
searchFocus.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
void syncDrama({
|
void syncDrama({
|
||||||
required bool active,
|
required bool active,
|
||||||
required bool wasActive,
|
required bool wasActive,
|
||||||
@@ -56,6 +59,8 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
if (active && searchRequest != previousSearchRequest) {
|
if (active && searchRequest != previousSearchRequest) {
|
||||||
|
searchOpen = true;
|
||||||
|
notify();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (isActive()) searchFocus.requestFocus();
|
if (isActive()) searchFocus.requestFocus();
|
||||||
});
|
});
|
||||||
@@ -94,7 +99,12 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
} else if (embedded && onDramaSelected != null) {
|
} else if (embedded && onDramaSelected != null) {
|
||||||
onDramaSelected(selected);
|
onDramaSelected(selected);
|
||||||
} else {
|
} else {
|
||||||
act(context, DiscoverPageAction.detail, embedded: embedded, onAction: onAction);
|
act(
|
||||||
|
context,
|
||||||
|
DiscoverPageAction.detail,
|
||||||
|
embedded: embedded,
|
||||||
|
onAction: onAction,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,41 +122,6 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice)));
|
).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void showFilters(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final labels = [
|
|
||||||
l10n.featured,
|
|
||||||
l10n.suspense,
|
|
||||||
l10n.romance,
|
|
||||||
l10n.actionGenre,
|
|
||||||
];
|
|
||||||
showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: const Color(0xFF202126),
|
|
||||||
showDragHandle: true,
|
|
||||||
builder: (sheetContext) => SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ListTile(title: Text(l10n.filter)),
|
|
||||||
for (var index = 0; index < labels.length; index++)
|
|
||||||
ListTile(
|
|
||||||
title: Text(labels[index]),
|
|
||||||
trailing: index == category
|
|
||||||
? const Icon(Icons.check_rounded, color: Color(0xFFFF5B3D))
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
selectCategory(index);
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
|||||||
@@ -2,13 +2,22 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.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/repositories/watch_history_repository.dart';
|
||||||
|
import '../../core/data/services/catalog_controller.dart';
|
||||||
|
import '../../core/data/services/category_controller.dart';
|
||||||
|
import '../../core/theme/app_colors.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/remote_image.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import 'discover_binding.dart';
|
import 'discover_binding.dart';
|
||||||
import 'discover_logic.dart';
|
import 'discover_logic.dart';
|
||||||
|
import 'widgets/cover_carousel.dart';
|
||||||
|
|
||||||
|
part 'widgets/home_drama_grid.dart';
|
||||||
|
|
||||||
class DiscoverPage extends StatefulWidget {
|
class DiscoverPage extends StatefulWidget {
|
||||||
const DiscoverPage({
|
const DiscoverPage({
|
||||||
@@ -20,6 +29,10 @@ class DiscoverPage extends StatefulWidget {
|
|||||||
this.embedded = false,
|
this.embedded = false,
|
||||||
this.active = true,
|
this.active = true,
|
||||||
this.searchRequest = 0,
|
this.searchRequest = 0,
|
||||||
|
this.signedIn = false,
|
||||||
|
this.onSignIn,
|
||||||
|
this.onCoins,
|
||||||
|
this.onRewards,
|
||||||
this.onAction,
|
this.onAction,
|
||||||
this.dramas,
|
this.dramas,
|
||||||
this.onDramaSelected,
|
this.onDramaSelected,
|
||||||
@@ -32,6 +45,10 @@ class DiscoverPage extends StatefulWidget {
|
|||||||
final bool embedded;
|
final bool embedded;
|
||||||
final bool active;
|
final bool active;
|
||||||
final int searchRequest;
|
final int searchRequest;
|
||||||
|
final bool signedIn;
|
||||||
|
final VoidCallback? onSignIn;
|
||||||
|
final VoidCallback? onCoins;
|
||||||
|
final VoidCallback? onRewards;
|
||||||
final ValueChanged<DiscoverPageAction>? onAction;
|
final ValueChanged<DiscoverPageAction>? onAction;
|
||||||
final List<Drama>? dramas;
|
final List<Drama>? dramas;
|
||||||
final ValueChanged<Drama>? onDramaSelected;
|
final ValueChanged<Drama>? onDramaSelected;
|
||||||
@@ -48,8 +65,9 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
logic = DiscoverBinding().create(
|
logic = DiscoverBinding().create(
|
||||||
drama: widget.drama,
|
drama: widget.drama,
|
||||||
history: widget.history,
|
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||||
);
|
);
|
||||||
|
if (widget.focusSearch) logic.searchOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -82,53 +100,15 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
|
|
||||||
Widget _page(BuildContext context) {
|
Widget _page(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final cards = _cardsFor(l10n, logic.category, logic.search.text);
|
final categories = context.watch<CategoryController>();
|
||||||
|
final homeBanners = context.watch<CatalogController>().feed.banners;
|
||||||
|
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
||||||
final content = SafeArea(
|
final content = SafeArea(
|
||||||
bottom: !widget.embedded,
|
bottom: !widget.embedded,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_topBar(l10n),
|
_header(l10n),
|
||||||
Expanded(
|
Expanded(child: _feed(l10n, categories, homeBanners, allDramas)),
|
||||||
child: ListView(
|
|
||||||
controller: logic.scroll,
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 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]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!widget.embedded) _bottomNav(l10n),
|
if (!widget.embedded) _bottomNav(l10n),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -136,7 +116,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
value: SystemUiOverlayStyle.light,
|
value: SystemUiOverlayStyle.light,
|
||||||
child: Theme(
|
child: Theme(
|
||||||
data: ThemeData.dark(useMaterial3: true),
|
data: AppTheme.dark(),
|
||||||
child: widget.embedded
|
child: widget.embedded
|
||||||
? content
|
? content
|
||||||
: Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content),
|
: Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content),
|
||||||
@@ -144,416 +124,195 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _topBar(AppLocalizations l10n) => SizedBox(
|
Widget _feed(
|
||||||
height: 51,
|
AppLocalizations l10n,
|
||||||
child: Padding(
|
CategoryController categories,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
List<FeedBanner> banners,
|
||||||
child: Row(
|
List<Drama> allDramas,
|
||||||
children: [
|
) => CustomScrollView(
|
||||||
Container(
|
controller: logic.scroll,
|
||||||
width: 29,
|
slivers: [
|
||||||
height: 29,
|
SliverToBoxAdapter(
|
||||||
decoration: BoxDecoration(
|
child: Padding(
|
||||||
color: _orange,
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||||
borderRadius: BorderRadius.circular(7),
|
child: ClipRRect(
|
||||||
),
|
borderRadius: BorderRadius.circular(18),
|
||||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
|
child: _carousel(l10n, banners, allDramas),
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
SliverToBoxAdapter(
|
||||||
}
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 18, 16, 12),
|
||||||
Widget _continueCard(AppLocalizations l10n) => FutureBuilder<Duration?>(
|
child: Column(
|
||||||
future: logic.position,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
builder: (context, snapshot) {
|
children: [
|
||||||
final position = snapshot.data ?? Duration.zero;
|
_categories(l10n, categories),
|
||||||
final total = widget.duration > Duration.zero
|
const SizedBox(height: 18),
|
||||||
? widget.duration
|
Text(
|
||||||
: const Duration(seconds: 12);
|
l10n.allDramas,
|
||||||
final progress = (position.inMilliseconds / total.inMilliseconds).clamp(
|
key: const Key('home-all-dramas-title'),
|
||||||
0.0,
|
style: const TextStyle(
|
||||||
1.0,
|
color: AppColors.onPrimary,
|
||||||
);
|
fontSize: 18,
|
||||||
return Container(
|
fontWeight: FontWeight.w700,
|
||||||
padding: const EdgeInsets.all(10),
|
height: 1.2,
|
||||||
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++) ...[
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (index < actions.length - 1) const SizedBox(width: 7),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _sectionTitle(AppLocalizations l10n) => Row(
|
|
||||||
children: [
|
|
||||||
Container(width: 3, height: 20, color: _orange),
|
|
||||||
const SizedBox(width: 7),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
l10n.trendingMicroDramas,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
_HomeDramaGrid(
|
||||||
onPressed: () => logic.selectCategory(0),
|
l10n: l10n,
|
||||||
child: Text(
|
categories: categories,
|
||||||
l10n.seeAll,
|
onOpen: (drama) => logic.openCard(
|
||||||
style: const TextStyle(color: Color(0xFFFFB3A1)),
|
context,
|
||||||
|
drama,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onDramaSelected: widget.onDramaSelected,
|
||||||
|
onAction: widget.onAction,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) {
|
Widget _header(AppLocalizations l10n) {
|
||||||
final playable =
|
return Padding(
|
||||||
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]) != null;
|
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
||||||
return InkWell(
|
child: Row(
|
||||||
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: [
|
children: [
|
||||||
|
const _BrandMark(),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ClipRRect(
|
child: Text(
|
||||||
borderRadius: BorderRadius.circular(10),
|
l10n.appName,
|
||||||
child: Stack(
|
maxLines: 1,
|
||||||
fit: StackFit.expand,
|
overflow: TextOverflow.ellipsis,
|
||||||
children: [
|
style: const TextStyle(
|
||||||
Image.asset(card.image, fit: BoxFit.cover),
|
color: Colors.white,
|
||||||
const Positioned(
|
fontSize: 20,
|
||||||
left: 0,
|
fontWeight: FontWeight.w700,
|
||||||
right: 0,
|
height: 1.1,
|
||||||
bottom: 0,
|
|
||||||
height: 37,
|
|
||||||
child: ColoredBox(color: Color(0xFF101116)),
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
bottom: 10,
|
|
||||||
left: 8,
|
|
||||||
right: 8,
|
|
||||||
child: Text(
|
|
||||||
playable ? l10n.freeTestMedia : l10n.videoPending,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xFFFFC789),
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(width: 8),
|
||||||
Text(
|
if (widget.signedIn) ...[
|
||||||
card.title,
|
_HeaderPill(
|
||||||
maxLines: 1,
|
icon: Icons.monetization_on_rounded,
|
||||||
overflow: TextOverflow.ellipsis,
|
label: r'$ 0',
|
||||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
|
onTap: widget.onCoins,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(width: 8),
|
||||||
Text(
|
_HeaderPill(
|
||||||
card.genre,
|
icon: Icons.card_giftcard_rounded,
|
||||||
maxLines: 1,
|
label: l10n.rewards,
|
||||||
overflow: TextOverflow.ellipsis,
|
onTap: widget.onRewards,
|
||||||
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 11),
|
),
|
||||||
),
|
] 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(
|
||||||
|
active: widget.active,
|
||||||
|
items: [
|
||||||
|
for (final banner in items)
|
||||||
|
CoverItem(
|
||||||
|
title: _bannerTitle(banner, allDramas),
|
||||||
|
subtitle: '',
|
||||||
|
image: banner.imageUrl,
|
||||||
|
onOpen: () => _openBanner(banner, allDramas),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _categories(AppLocalizations l10n, CategoryController categories) {
|
||||||
|
final choices = <({String code, String label})>[
|
||||||
|
(code: CategoryController.allCode, label: l10n.forYou),
|
||||||
|
(code: CategoryController.hotCode, label: l10n.hot),
|
||||||
|
(code: CategoryController.newCode, label: l10n.newest),
|
||||||
|
for (final category in categories.categories)
|
||||||
|
(code: 'category-${category.id}', label: category.name),
|
||||||
|
];
|
||||||
|
return SizedBox(
|
||||||
|
height: 32,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: choices.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final choice = choices[index];
|
||||||
|
final selected = categories.selectedCode == choice.code;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => categories.selectCode(choice.code),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected
|
||||||
|
? AppColors.bluePressed
|
||||||
|
: const Color(0xFF1C1E24),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
choice.label,
|
||||||
|
maxLines: 1,
|
||||||
|
style: TextStyle(
|
||||||
|
color: selected ? Colors.white : AppColors.muted,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _bottomNav(AppLocalizations l10n) => Container(
|
Widget _bottomNav(AppLocalizations l10n) => Container(
|
||||||
height: 63,
|
height: 63,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
@@ -618,12 +377,12 @@ class _DiscoverNavItem extends StatelessWidget {
|
|||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 21,
|
size: 21,
|
||||||
color: selected ? _orange : const Color(0xFFCAC4C7),
|
color: selected ? _accent : const Color(0xFFCAC4C7),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: selected ? _orange : const Color(0xFFCAC4C7),
|
color: selected ? _accent : const Color(0xFFCAC4C7),
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
@@ -634,65 +393,95 @@ class _DiscoverNavItem extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ShowcaseCard {
|
const _accent = AppColors.blueSoft;
|
||||||
const _ShowcaseCard({
|
const _coin = Color(0xFFF5C451);
|
||||||
required this.title,
|
|
||||||
required this.genre,
|
class _BrandMark extends StatelessWidget {
|
||||||
required this.image,
|
const _BrandMark();
|
||||||
required this.category,
|
|
||||||
this.dramaId,
|
@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 String title;
|
final IconData icon;
|
||||||
final String genre;
|
final String label;
|
||||||
final String image;
|
final VoidCallback? onTap;
|
||||||
final int category;
|
|
||||||
final String? dramaId;
|
@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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<_ShowcaseCard> _cardsFor(
|
class _LoginButton extends StatelessWidget {
|
||||||
AppLocalizations l10n,
|
const _LoginButton({required this.label, required this.onTap});
|
||||||
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 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
const _orange = Color(0xFFFF5B3D);
|
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,310 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/remote_image.dart';
|
||||||
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
const Duration _autoPlayInterval = Duration(milliseconds: 4500);
|
||||||
|
|
||||||
|
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, this.active = true});
|
||||||
|
|
||||||
|
final List<CoverItem> items;
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
Timer? _autoTimer;
|
||||||
|
bool _tickerEnabled = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_snap = AnimationController(vsync: this)..addListener(_onSnap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_autoTimer?.cancel();
|
||||||
|
_snap.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final enabled = TickerMode.valuesOf(context).enabled;
|
||||||
|
if (enabled != _tickerEnabled) {
|
||||||
|
_tickerEnabled = enabled;
|
||||||
|
_syncAutoTimer();
|
||||||
|
} else if (_autoTimer == null) {
|
||||||
|
_syncAutoTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant CoverCarousel oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (_page > _last) _page = _last;
|
||||||
|
if (oldWidget.items.length != widget.items.length ||
|
||||||
|
oldWidget.active != widget.active) {
|
||||||
|
_syncAutoTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double get _last => math.max(0, widget.items.length - 1).toDouble();
|
||||||
|
|
||||||
|
void _onSnap() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final t = Curves.easeOutCubic.transform(_snap.value);
|
||||||
|
setState(() => _page = _from + (_to - _from) * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _turnTo(double target) {
|
||||||
|
if (!mounted || widget.items.isEmpty) return;
|
||||||
|
_from = _page;
|
||||||
|
_to = target.clamp(0, _last);
|
||||||
|
_snap.duration = const Duration(milliseconds: 420);
|
||||||
|
_snap.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncAutoTimer() {
|
||||||
|
_autoTimer?.cancel();
|
||||||
|
_autoTimer = null;
|
||||||
|
if (!mounted ||
|
||||||
|
!widget.active ||
|
||||||
|
!_tickerEnabled ||
|
||||||
|
widget.items.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_autoTimer = Timer.periodic(_autoPlayInterval, (_) {
|
||||||
|
if (!mounted || _pointer != null || !widget.active || !_tickerEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final current = _page.round().clamp(0, _last.toInt());
|
||||||
|
if (current >= _last.toInt()) {
|
||||||
|
setState(() => _page = 0);
|
||||||
|
} else {
|
||||||
|
_turnTo((current + 1).toDouble());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.items.isEmpty) return const SizedBox.shrink();
|
||||||
|
final current = _page.round().clamp(0, widget.items.length - 1);
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth;
|
||||||
|
final height = math.min(width * 0.52, 210).toDouble();
|
||||||
|
return Semantics(
|
||||||
|
container: true,
|
||||||
|
label: l10n.carouselSemantics(current + 1, widget.items.length),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: height,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(child: _stage(width)),
|
||||||
|
_indicator(l10n, current),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stage(double width) {
|
||||||
|
return Listener(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onPointerDown: (event) {
|
||||||
|
_autoTimer?.cancel();
|
||||||
|
_autoTimer = null;
|
||||||
|
_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());
|
||||||
|
_syncAutoTimer();
|
||||||
|
},
|
||||||
|
onPointerCancel: (event) {
|
||||||
|
if (event.pointer != _pointer) return;
|
||||||
|
_pointer = null;
|
||||||
|
_turnTo(_page.roundToDouble());
|
||||||
|
_syncAutoTimer();
|
||||||
|
},
|
||||||
|
child: ClipRect(
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [for (final index in _visible) _face(index, width)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _indicator(AppLocalizations l10n, int current) {
|
||||||
|
return Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 8,
|
||||||
|
child: Semantics(
|
||||||
|
label: l10n.carouselSemantics(current + 1, widget.items.length),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < widget.items.length; index++)
|
||||||
|
Container(
|
||||||
|
key: Key('carousel-dot-$index'),
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: index == current
|
||||||
|
? AppColors.onPrimary
|
||||||
|
: AppColors.muted.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
showFallbackIcon: true,
|
||||||
|
fallbackIconKey: const Key('carousel-image-fallback'),
|
||||||
|
),
|
||||||
|
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: 30,
|
||||||
|
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)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
part of '../discover_view.dart';
|
||||||
|
|
||||||
|
class _HomeDramaGrid extends StatelessWidget {
|
||||||
|
const _HomeDramaGrid({
|
||||||
|
required this.l10n,
|
||||||
|
required this.categories,
|
||||||
|
required this.onOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
final CategoryController categories;
|
||||||
|
final ValueChanged<Drama> onOpen;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (categories.loading && categories.feed.sections.isEmpty) {
|
||||||
|
return const SliverPadding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (categories.error != null) {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 18),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.categoriesError,
|
||||||
|
style: const TextStyle(color: AppColors.muted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: categories.load, child: Text(l10n.retry)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final dramas = categories.visibleDramas;
|
||||||
|
if (dramas.isEmpty) {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 24),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Text(
|
||||||
|
categories.selectedSectionMissing
|
||||||
|
? l10n.noCategoryDramas
|
||||||
|
: l10n.noSearchResults,
|
||||||
|
style: const TextStyle(color: AppColors.muted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SliverPadding(
|
||||||
|
key: const Key('home-all-dramas-section'),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
key: const Key('home-all-dramas-grid'),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 2 / 3,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _HomeDramaCard(
|
||||||
|
l10n: l10n,
|
||||||
|
drama: dramas[index],
|
||||||
|
onTap: () => onOpen(dramas[index]),
|
||||||
|
),
|
||||||
|
childCount: dramas.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeDramaCard extends StatelessWidget {
|
||||||
|
const _HomeDramaCard({
|
||||||
|
required this.l10n,
|
||||||
|
required this.drama,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
final Drama drama;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
||||||
|
return InkWell(
|
||||||
|
key: Key('home-drama-card-${drama.id}'),
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: AspectRatio(
|
||||||
|
key: Key('home-drama-cover-${drama.id}'),
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
RemoteImage(
|
||||||
|
drama.posterUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
showFallbackIcon: true,
|
||||||
|
),
|
||||||
|
const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0x00000000), Color(0xE6000000)],
|
||||||
|
stops: [0.4, 1],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 10,
|
||||||
|
right: 10,
|
||||||
|
bottom: 10,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
drama.title,
|
||||||
|
key: Key('home-drama-title-${drama.id}'),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
genre.isEmpty ? l10n.videoPending : genre,
|
||||||
|
key: Key('home-drama-genre-${drama.id}'),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xE6FFFFFF),
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../../core/data/services/download_controller.dart';
|
||||||
|
|
||||||
|
/// 下载管理页依赖装配。
|
||||||
|
class DownloadsBinding {
|
||||||
|
/// 从服务端刷新策略与授权列表。
|
||||||
|
Future<void> load(DownloadController downloads) => downloads.load();
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../core/data/models/download_license.dart';
|
||||||
|
import '../../core/data/models/download_policy.dart';
|
||||||
|
import '../../core/data/services/auth_controller.dart';
|
||||||
|
import '../../core/data/services/download_controller.dart';
|
||||||
|
import '../../core/formatting/error_text.dart';
|
||||||
|
import '../../core/formatting/localized_date_time.dart';
|
||||||
|
import '../../core/platform/haptics.dart';
|
||||||
|
import '../../core/theme/app_colors.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/auth_gate.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import 'downloads_binding.dart';
|
||||||
|
|
||||||
|
/// 下载授权与状态管理页;本批不落盘,只管理服务端授权。
|
||||||
|
class DownloadsPage extends StatefulWidget {
|
||||||
|
const DownloadsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DownloadsPage> createState() => _DownloadsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadsPageState extends State<DownloadsPage> {
|
||||||
|
bool _requested = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
|
if (!signedIn) {
|
||||||
|
_requested = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_requested) return;
|
||||||
|
_requested = true;
|
||||||
|
final downloads = context.read<DownloadController>();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) unawaited(DownloadsBinding().load(downloads));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
|
return Theme(
|
||||||
|
data: AppTheme.dark(),
|
||||||
|
child: Scaffold(
|
||||||
|
key: const Key('downloads-page'),
|
||||||
|
backgroundColor: const Color(0xFF0B0C10),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: const Color(0xFF0B0C10),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
title: Text(l10n.downloads),
|
||||||
|
),
|
||||||
|
body: signedIn
|
||||||
|
? _DownloadsBody(downloads: context.watch<DownloadController>())
|
||||||
|
: const AuthGatePanel(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadsBody extends StatelessWidget {
|
||||||
|
const _DownloadsBody({required this.downloads});
|
||||||
|
|
||||||
|
final DownloadController downloads;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
if (downloads.loading && downloads.policy == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (downloads.error != null && downloads.policy == null) {
|
||||||
|
return Center(
|
||||||
|
key: const Key('downloads-error'),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
errorText(l10n, downloads.error, l10n.downloadsError),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('downloads-retry'),
|
||||||
|
onPressed: () => unawaited(downloads.load()),
|
||||||
|
child: Text(l10n.retry),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final policy = downloads.policy;
|
||||||
|
if (policy == null) {
|
||||||
|
return Center(
|
||||||
|
key: const Key('downloads-error'),
|
||||||
|
child: FilledButton(
|
||||||
|
key: const Key('downloads-retry'),
|
||||||
|
onPressed: () => unawaited(downloads.load()),
|
||||||
|
child: Text(l10n.retry),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: downloads.load,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 28),
|
||||||
|
children: [
|
||||||
|
_PolicyPanel(downloads: downloads, policy: policy),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.downloadedItems,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (downloads.licenses.isNotEmpty)
|
||||||
|
TextButton.icon(
|
||||||
|
key: const Key('download-clear'),
|
||||||
|
onPressed: downloads.clearing
|
||||||
|
? null
|
||||||
|
: () => _confirmClear(context),
|
||||||
|
icon: const Icon(Icons.delete_sweep_outlined),
|
||||||
|
label: Text(l10n.clearAll),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (downloads.licenses.isEmpty)
|
||||||
|
Padding(
|
||||||
|
key: const Key('downloads-empty'),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 34),
|
||||||
|
child: Text(
|
||||||
|
l10n.noDownloads,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
for (final license in downloads.licenses) ...[
|
||||||
|
_LicenseTile(
|
||||||
|
license: license,
|
||||||
|
onRemove: () => _remove(context, license.episodeId),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _remove(BuildContext context, String episodeId) async {
|
||||||
|
unawaited(context.read<Haptics>().mediumImpact());
|
||||||
|
final ok = await downloads.remove(episodeId);
|
||||||
|
if (ok || !context.mounted) return;
|
||||||
|
_showMessage(context, AppLocalizations.of(context)!.downloadDeleteFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmClear(BuildContext context) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(l10n.clearDownloadsTitle),
|
||||||
|
content: Text(l10n.clearDownloadsMessage),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: Text(l10n.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: Text(l10n.confirmClear),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
if (context.mounted) {
|
||||||
|
unawaited(context.read<Haptics>().mediumImpact());
|
||||||
|
}
|
||||||
|
final ok = await downloads.clear();
|
||||||
|
if (!ok && context.mounted) {
|
||||||
|
_showMessage(context, l10n.downloadClearFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showMessage(BuildContext context, String message) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PolicyPanel extends StatelessWidget {
|
||||||
|
const _PolicyPanel({required this.downloads, required this.policy});
|
||||||
|
|
||||||
|
final DownloadController downloads;
|
||||||
|
final DownloadPolicy policy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return Container(
|
||||||
|
key: const Key('download-policy'),
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.card,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFF2A2C33)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.downloadQuota(policy.remaining, policy.maxPerDevice),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
l10n.downloadLicenseDays(policy.licenseDays),
|
||||||
|
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
policy.expirePolicyText,
|
||||||
|
style: const TextStyle(color: Color(0xFFB7B3B8), fontSize: 12),
|
||||||
|
),
|
||||||
|
const Divider(height: 22, color: Color(0xFF2A2C33)),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.downloadWifiOnly,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
key: const Key('download-wifi-switch'),
|
||||||
|
value: policy.wifiOnly,
|
||||||
|
onChanged: (value) => _update(context, wifiOnly: value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.downloadQuality,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownButton<String>(
|
||||||
|
key: const Key('download-quality'),
|
||||||
|
value:
|
||||||
|
_qualityChoices(
|
||||||
|
policy.maxQuality,
|
||||||
|
).contains(policy.maxQuality)
|
||||||
|
? policy.maxQuality
|
||||||
|
: 'AUTO',
|
||||||
|
underline: const SizedBox.shrink(),
|
||||||
|
items: [
|
||||||
|
for (final quality in _qualityChoices(policy.maxQuality))
|
||||||
|
DropdownMenuItem(value: quality, child: Text(quality)),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null || value == policy.maxQuality) return;
|
||||||
|
_update(context, maxQuality: value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _update(
|
||||||
|
BuildContext context, {
|
||||||
|
bool? wifiOnly,
|
||||||
|
String? maxQuality,
|
||||||
|
}) async {
|
||||||
|
final ok = await downloads.updateSettings(
|
||||||
|
wifiOnly: wifiOnly,
|
||||||
|
maxQuality: maxQuality,
|
||||||
|
);
|
||||||
|
if (ok || !context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)!.downloadSettingsFailed),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LicenseTile extends StatelessWidget {
|
||||||
|
const _LicenseTile({required this.license, required this.onRemove});
|
||||||
|
|
||||||
|
final DownloadLicense license;
|
||||||
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return Container(
|
||||||
|
key: ValueKey('download-license-${license.episodeId}'),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.card,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
license.dramaTitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(l10n.episodeLabel(license.episodeNo)),
|
||||||
|
IconButton(
|
||||||
|
key: ValueKey('download-remove-${license.episodeId}'),
|
||||||
|
tooltip: l10n.remove,
|
||||||
|
onPressed: onRemove,
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 5,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.downloadStatus(license.status),
|
||||||
|
style: const TextStyle(color: AppColors.blueSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
l10n.downloadTaskStatus(license.taskStatus),
|
||||||
|
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
l10n.downloadProgress(license.progressPercent),
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
|
),
|
||||||
|
if (license.quality.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
license.quality,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
minHeight: 4,
|
||||||
|
value: (license.progressPercent / 100).clamp(0.0, 1.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (license.expireAt != null) ...[
|
||||||
|
const SizedBox(height: 7),
|
||||||
|
Text(
|
||||||
|
l10n.downloadExpiresAt(
|
||||||
|
formatLocalizedDateTime(context, license.expireAt!),
|
||||||
|
),
|
||||||
|
style: const TextStyle(color: Color(0xFF8D8990), fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _qualityChoices(String current) {
|
||||||
|
final choices = <String>['AUTO'];
|
||||||
|
if (current.isNotEmpty && current != 'AUTO') choices.add(current);
|
||||||
|
for (final value in const ['720P', '1080P']) {
|
||||||
|
if (!choices.contains(value)) choices.add(value);
|
||||||
|
}
|
||||||
|
return choices;
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/repositories/playback_repository.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
import 'drama_detail_logic.dart';
|
import 'drama_detail_logic.dart';
|
||||||
|
|
||||||
class DramaDetailBinding {
|
class DramaDetailBinding {
|
||||||
DramaDetailLogic create({
|
DramaDetailLogic create({
|
||||||
required Drama drama,
|
required Drama drama,
|
||||||
WatchHistoryRepository? history,
|
required WatchHistoryRepository history,
|
||||||
|
required PlaybackRepository playback,
|
||||||
|
required FavoriteController favorites,
|
||||||
}) => DramaDetailLogic(
|
}) => DramaDetailLogic(
|
||||||
drama: drama,
|
drama: drama,
|
||||||
history: history ?? SharedPreferencesWatchHistoryRepository(),
|
history: history,
|
||||||
|
playback: playback,
|
||||||
|
favorites: favorites,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,106 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/models/watch_progress.dart';
|
||||||
|
import '../../core/data/remote/api_client.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.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';
|
import '../player/player_view.dart';
|
||||||
|
|
||||||
class DramaDetailLogic extends ChangeNotifier {
|
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 WatchHistoryRepository history;
|
||||||
|
final PlaybackRepository playback;
|
||||||
|
final FavoriteController favorites;
|
||||||
|
bool loading = false;
|
||||||
|
bool failed = false;
|
||||||
|
|
||||||
|
/// 接口给出的失败原因;为空时界面回退到本地文案。
|
||||||
|
String? failureMessage;
|
||||||
int? continueIndex;
|
int? continueIndex;
|
||||||
|
WatchProgress? progress;
|
||||||
|
String? progressError;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
void notify() {
|
void notify() {
|
||||||
if (!_disposed) notifyListeners();
|
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 {
|
Future<void> loadContinue() async {
|
||||||
try {
|
try {
|
||||||
final id = await history.lastEpisodeId(demoProfileId, drama.id);
|
final record = await history.progressFor(drama.id);
|
||||||
if (id == null || _disposed) return;
|
if (_disposed) return;
|
||||||
final index = drama.episodes.indexWhere((episode) => episode.id == id);
|
progress = record;
|
||||||
if (index >= 0 && drama.episodes[index].isFree) {
|
progressError = null;
|
||||||
continueIndex = index;
|
continueIndex = record == null
|
||||||
notify();
|
? null
|
||||||
}
|
: _indexOf(record.episodeId) ?? continueIndex;
|
||||||
} catch (_) {
|
notify();
|
||||||
// Keep the first episode available if preferences cannot be read.
|
} 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 {
|
Future<void> openPlayer(BuildContext context, int index) async {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
@@ -41,6 +110,9 @@ class DramaDetailLogic extends ChangeNotifier {
|
|||||||
await loadContinue();
|
await loadContinue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。
|
||||||
|
Future<bool> toggleFavorite() => favorites.toggle(drama.id, card: drama);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/services/catalog_controller.dart';
|
||||||
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
|
import '../../core/data/services/interaction_controller.dart';
|
||||||
|
import '../../core/formatting/error_text.dart';
|
||||||
import '../../core/formatting/localized_content.dart';
|
import '../../core/formatting/localized_content.dart';
|
||||||
|
import '../../core/platform/haptics.dart';
|
||||||
|
import '../../core/theme/app_colors.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/remote_image.dart';
|
||||||
|
import '../../core/widgets/auth_gate.dart';
|
||||||
|
import '../../core/widgets/download_action.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../interaction/widgets/report_dialog.dart';
|
||||||
import 'drama_detail_binding.dart';
|
import 'drama_detail_binding.dart';
|
||||||
import 'drama_detail_logic.dart';
|
import 'drama_detail_logic.dart';
|
||||||
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
|
import '../../core/data/repositories/playback_repository.dart';
|
||||||
|
|
||||||
|
const String _reportMenuValue = 'report';
|
||||||
|
|
||||||
class DramaDetailPage extends StatefulWidget {
|
class DramaDetailPage extends StatefulWidget {
|
||||||
const DramaDetailPage({super.key, required this.drama});
|
const DramaDetailPage({super.key, required this.drama});
|
||||||
@@ -22,8 +40,21 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
logic = DramaDetailBinding().create(drama: widget.drama);
|
logic = DramaDetailBinding().create(
|
||||||
logic.loadContinue();
|
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
|
@override
|
||||||
@@ -34,15 +65,80 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListenableBuilder(
|
return Theme(
|
||||||
listenable: logic,
|
data: AppTheme.dark(),
|
||||||
builder: (context, _) => _page(context),
|
child: ListenableBuilder(
|
||||||
|
listenable: logic,
|
||||||
|
builder: (context, _) => _page(context),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _page(BuildContext context) {
|
Widget _page(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(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;
|
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(
|
return Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -51,8 +147,13 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: const Color(0xFF111111),
|
backgroundColor: const Color(0xFF111111),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
actions: [
|
||||||
|
_reportAction(context, l10n),
|
||||||
|
_downloadAction(context, l10n),
|
||||||
|
_favoriteAction(context, l10n),
|
||||||
|
],
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Image.asset(drama.posterAsset, fit: BoxFit.cover),
|
background: RemoteImage(drama.posterUrl),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
@@ -63,7 +164,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
l10n.technicalDemoCount(drama.episodes.length),
|
l10n.technicalDemoCount(drama.episodes.length),
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
letterSpacing: 1.4,
|
letterSpacing: 1.4,
|
||||||
color: const Color(0xFF9278EA),
|
color: AppColors.blueSoft,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -82,15 +183,54 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
FilledButton.icon(
|
Column(
|
||||||
onPressed: () =>
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
logic.openPlayer(context, logic.continueIndex ?? 0),
|
children: [
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
FilledButton.icon(
|
||||||
label: Text(
|
onPressed: () =>
|
||||||
logic.continueIndex == null
|
logic.openPlayer(context, logic.continueIndex ?? 0),
|
||||||
? l10n.watchEpisodeOne
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
: l10n.continueEpisode(logic.continueIndex! + 1),
|
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),
|
const SizedBox(height: 30),
|
||||||
Text(
|
Text(
|
||||||
@@ -108,18 +248,22 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
itemCount: drama.episodes.length,
|
itemCount: drama.episodes.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final episode = drama.episodes[index];
|
final episode = drama.episodes[index];
|
||||||
|
final fraction = logic.progressFractionFor(episode.id);
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
minTileHeight: 76,
|
minTileHeight: 76,
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: const Color(
|
backgroundColor: AppColors.blue.withValues(alpha: 0.18),
|
||||||
0xFFFF6B45,
|
|
||||||
).withValues(alpha: 0.18),
|
|
||||||
child: Text('${episode.number}'),
|
child: Text('${episode.number}'),
|
||||||
),
|
),
|
||||||
title: Text(l10n.episodeLabel(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),
|
trailing: const Icon(Icons.play_circle_outline_rounded),
|
||||||
onTap: () => logic.openPlayer(context, index),
|
onTap: () => logic.openPlayer(context, index),
|
||||||
),
|
),
|
||||||
@@ -131,4 +275,124 @@ 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 _downloadAction(BuildContext context, AppLocalizations l10n) {
|
||||||
|
final episodes = logic.drama.episodes;
|
||||||
|
final index = logic.continueIndex ?? 0;
|
||||||
|
final episode = episodes.isEmpty
|
||||||
|
? null
|
||||||
|
: episodes[index.clamp(0, episodes.length - 1)];
|
||||||
|
return IconButton(
|
||||||
|
key: const Key('detail-download'),
|
||||||
|
tooltip: l10n.downloads,
|
||||||
|
icon: const Icon(Icons.download_outlined),
|
||||||
|
onPressed: episode == null
|
||||||
|
? null
|
||||||
|
: () => requestEpisodeDownload(context, episode.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 详情页更多菜单:本批只提供举报短剧。
|
||||||
|
Widget _reportAction(BuildContext context, AppLocalizations l10n) {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
key: const Key('detail-more'),
|
||||||
|
tooltip: l10n.more,
|
||||||
|
onSelected: (_) => unawaited(_reportDrama(l10n)),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
PopupMenuItem(value: _reportMenuValue, child: Text(l10n.reportDrama)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reportDrama(AppLocalizations l10n) async {
|
||||||
|
if (!await ensureSignedIn(context) || !mounted) return;
|
||||||
|
final draft = await showReportDialog(context, title: l10n.reportDrama);
|
||||||
|
if (draft == null || !mounted) return;
|
||||||
|
unawaited(context.read<Haptics>().lightImpact());
|
||||||
|
final result = await context.read<InteractionController>().reportDrama(
|
||||||
|
logic.drama.id,
|
||||||
|
reason: draft.reason,
|
||||||
|
description: draft.description,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
result.isSuccess
|
||||||
|
? l10n.reportSubmitted
|
||||||
|
: result.errorMessage ?? l10n.reportFailed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
|
||||||
|
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;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
unawaited(context.read<Haptics>().lightImpact());
|
||||||
|
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(AppColors.blueSoft),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (label != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label!,
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
|
|
||||||
|
/// 收藏页依赖装配。
|
||||||
|
class FavoritesBinding {
|
||||||
|
/// 从服务端刷新收藏列表。
|
||||||
|
Future<void> load(FavoriteController favorites) => favorites.load();
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/services/auth_controller.dart';
|
||||||
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
|
import '../../core/formatting/error_text.dart';
|
||||||
|
import '../../core/platform/haptics.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/auth_gate.dart';
|
||||||
|
import '../../core/widgets/library_drama_tile.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../drama_detail/drama_detail_view.dart';
|
||||||
|
import 'favorites_binding.dart';
|
||||||
|
|
||||||
|
/// 独立我的收藏页。
|
||||||
|
class FavoritesPage extends StatefulWidget {
|
||||||
|
const FavoritesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FavoritesPage> createState() => _FavoritesPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FavoritesPageState extends State<FavoritesPage> {
|
||||||
|
bool _requested = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
|
if (!signedIn) {
|
||||||
|
_requested = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_requested) return;
|
||||||
|
_requested = true;
|
||||||
|
final favorites = context.read<FavoriteController>();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) unawaited(FavoritesBinding().load(favorites));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
|
return Theme(
|
||||||
|
data: AppTheme.dark(),
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0B0C10),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: const Color(0xFF0B0C10),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
title: Text(l10n.myFavorites),
|
||||||
|
),
|
||||||
|
body: signedIn
|
||||||
|
? _FavoritesBody(favorites: context.watch<FavoriteController>())
|
||||||
|
: const AuthGatePanel(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FavoritesBody extends StatelessWidget {
|
||||||
|
const _FavoritesBody({required this.favorites});
|
||||||
|
|
||||||
|
final FavoriteController favorites;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
if (favorites.loading && favorites.cards.isEmpty) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (favorites.error != null && favorites.cards.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
key: const Key('favorites-error'),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
errorText(l10n, favorites.error, l10n.favoritesError),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('favorites-retry'),
|
||||||
|
onPressed: () => unawaited(favorites.load()),
|
||||||
|
child: Text(l10n.retry),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (favorites.cards.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
key: const Key('favorites-empty'),
|
||||||
|
child: Text(
|
||||||
|
l10n.noFavorites,
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: favorites.load,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||||
|
itemCount: favorites.cards.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final drama = favorites.cards[index];
|
||||||
|
return LibraryDramaTile(
|
||||||
|
key: ValueKey('favorite-card-${drama.id}'),
|
||||||
|
drama: drama,
|
||||||
|
onTap: () => _open(context, drama),
|
||||||
|
trailing: IconButton(
|
||||||
|
key: ValueKey('favorite-remove-${drama.id}'),
|
||||||
|
tooltip: l10n.remove,
|
||||||
|
onPressed: () => _remove(context, drama.id),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.favorite_rounded,
|
||||||
|
color: Color(0xFFD74A7D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _open(BuildContext context, Drama drama) async {
|
||||||
|
await Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _remove(BuildContext context, String dramaId) async {
|
||||||
|
unawaited(context.read<Haptics>().lightImpact());
|
||||||
|
final ok = await favorites.remove(dramaId);
|
||||||
|
if (ok || !context.mounted) return;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
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,18 +1,21 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/services/catalog_controller.dart';
|
||||||
import '../discover/discover_logic.dart';
|
import '../discover/discover_logic.dart';
|
||||||
import '../drama_detail/drama_detail_view.dart';
|
import '../drama_detail/drama_detail_view.dart';
|
||||||
import '../mine/mine_logic.dart';
|
import '../mine/mine_logic.dart';
|
||||||
|
import '../player/player_view.dart';
|
||||||
|
|
||||||
class HomeLogic extends ChangeNotifier {
|
class HomeLogic extends ChangeNotifier {
|
||||||
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
|
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
|
||||||
|
|
||||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||||
Drama currentDrama;
|
Drama currentDrama;
|
||||||
int tab = 0;
|
int tab = 1;
|
||||||
int searchRequest = 0;
|
int searchRequest = 0;
|
||||||
int sectionRequest = 0;
|
int sectionRequest = 0;
|
||||||
int resumeRequest = 0;
|
int resumeRequest = 0;
|
||||||
@@ -21,6 +24,7 @@ class HomeLogic extends ChangeNotifier {
|
|||||||
bool drawerOpen = false;
|
bool drawerOpen = false;
|
||||||
bool detailOpen = false;
|
bool detailOpen = false;
|
||||||
bool videoImmersive = false;
|
bool videoImmersive = false;
|
||||||
|
bool profile = false;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
void notify() {
|
void notify() {
|
||||||
@@ -64,9 +68,7 @@ class HomeLogic extends ChangeNotifier {
|
|||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
try {
|
try {
|
||||||
await Navigator.of(context).push<void>(
|
await Navigator.of(context).push<void>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: currentDrama)),
|
||||||
builder: (_) => DramaDetailPage(drama: currentDrama),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -82,6 +84,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) {
|
void discoverAction(BuildContext context, DiscoverPageAction action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case DiscoverPageAction.home:
|
case DiscoverPageAction.home:
|
||||||
@@ -89,7 +119,7 @@ class HomeLogic extends ChangeNotifier {
|
|||||||
case DiscoverPageAction.my:
|
case DiscoverPageAction.my:
|
||||||
select(2);
|
select(2);
|
||||||
case DiscoverPageAction.watchlist:
|
case DiscoverPageAction.watchlist:
|
||||||
select(2, watchlist: true);
|
openLibrary();
|
||||||
case DiscoverPageAction.resume:
|
case DiscoverPageAction.resume:
|
||||||
select(0, resume: true);
|
select(0, resume: true);
|
||||||
case DiscoverPageAction.detail:
|
case DiscoverPageAction.detail:
|
||||||
@@ -114,11 +144,10 @@ class HomeLogic extends ChangeNotifier {
|
|||||||
} else if (videoImmersive) {
|
} else if (videoImmersive) {
|
||||||
backRequest++;
|
backRequest++;
|
||||||
notify();
|
notify();
|
||||||
} else if (tab != 0) {
|
} else if (tab != 1) {
|
||||||
select(0);
|
select(1);
|
||||||
} else {
|
} else {
|
||||||
backRequest++;
|
openMenu();
|
||||||
notify();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+122
-45
@@ -1,10 +1,19 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/services/auth_controller.dart';
|
||||||
import '../../core/data/services/catalog_controller.dart';
|
import '../../core/data/services/catalog_controller.dart';
|
||||||
|
import '../../core/platform/haptics.dart';
|
||||||
|
import '../../core/theme/app_colors.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/auth_gate.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../discover/discover_view.dart';
|
import '../discover/discover_view.dart';
|
||||||
import '../mine/mine_view.dart';
|
import '../mine/mine_view.dart';
|
||||||
@@ -21,7 +30,7 @@ class HomePage extends StatelessWidget {
|
|||||||
final catalog = context.watch<CatalogController>();
|
final catalog = context.watch<CatalogController>();
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: ThemeData.dark(useMaterial3: true),
|
data: AppTheme.dark(),
|
||||||
child: _buildHome(context, catalog, l10n),
|
child: _buildHome(context, catalog, l10n),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -31,7 +40,6 @@ class HomePage extends StatelessWidget {
|
|||||||
CatalogController catalog,
|
CatalogController catalog,
|
||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
if (catalog.loading) {
|
if (catalog.loading) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
@@ -48,6 +56,13 @@ class HomePage extends StatelessWidget {
|
|||||||
Text(catalog.error == null ? l10n.noStories : l10n.catalogError),
|
Text(catalog.error == null ? l10n.noStories : l10n.catalogError),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton(onPressed: catalog.load, child: Text(l10n.retry)),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -96,6 +111,8 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
|
|
||||||
Widget _scaffold(BuildContext context) {
|
Widget _scaffold(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
// 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关)
|
||||||
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: false,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
@@ -104,13 +121,13 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: logic.scaffoldKey,
|
key: logic.scaffoldKey,
|
||||||
backgroundColor: const Color(0xFF0B0C10),
|
backgroundColor: AppColors.ink,
|
||||||
drawer: HomeQuickDrawer(
|
drawer: HomeQuickDrawer(
|
||||||
drama: logic.currentDrama,
|
drama: logic.currentDrama,
|
||||||
onOpenSeries: () => logic.openCurrentSeries(context),
|
onOpenSeries: () => logic.openCurrentSeries(context),
|
||||||
onDiscover: () => logic.selectFromMenu(1),
|
onDiscover: () => logic.selectFromMenu(1),
|
||||||
onHistory: () => logic.selectFromMenu(2, mySection: 0),
|
onHistory: () => logic.openLibrary(section: 0),
|
||||||
onWatchlist: () => logic.selectFromMenu(2, mySection: 1),
|
onWatchlist: logic.openLibrary,
|
||||||
onLiked: () => logic.selectFromMenu(2, mySection: 2),
|
onLiked: () => logic.selectFromMenu(2, mySection: 2),
|
||||||
),
|
),
|
||||||
drawerEnableOpenDragGesture: false,
|
drawerEnableOpenDragGesture: false,
|
||||||
@@ -129,7 +146,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
resumeRequest: logic.resumeRequest,
|
resumeRequest: logic.resumeRequest,
|
||||||
backRequest: logic.backRequest,
|
backRequest: logic.backRequest,
|
||||||
onDiscover: () => logic.select(1),
|
onDiscover: () => logic.select(1),
|
||||||
onMy: () => logic.select(2),
|
onMy: logic.openProfile,
|
||||||
onSearch: () => logic.select(1, search: true),
|
onSearch: () => logic.select(1, search: true),
|
||||||
onMenu: logic.openMenu,
|
onMenu: logic.openMenu,
|
||||||
onImmersiveChanged: logic.onImmersiveChanged,
|
onImmersiveChanged: logic.onImmersiveChanged,
|
||||||
@@ -137,11 +154,15 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
DiscoverPage(
|
DiscoverPage(
|
||||||
drama: logic.currentDrama,
|
drama: logic.currentDrama,
|
||||||
dramas: widget.dramas,
|
dramas: widget.dramas,
|
||||||
onDramaSelected: (drama) => logic.openDrama(context, drama),
|
onDramaSelected: (drama) => logic.playDrama(context, drama),
|
||||||
duration: Duration.zero,
|
duration: Duration.zero,
|
||||||
embedded: true,
|
embedded: true,
|
||||||
active: logic.tab == 1,
|
active: logic.tab == 1,
|
||||||
searchRequest: logic.searchRequest,
|
searchRequest: logic.searchRequest,
|
||||||
|
signedIn: signedIn,
|
||||||
|
onSignIn: () => openAuthPage(context),
|
||||||
|
onCoins: logic.openProfile,
|
||||||
|
onRewards: logic.openProfile,
|
||||||
onAction: (action) => logic.discoverAction(context, action),
|
onAction: (action) => logic.discoverAction(context, action),
|
||||||
),
|
),
|
||||||
MyPage(
|
MyPage(
|
||||||
@@ -150,10 +171,14 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
onDramaSelected: (drama) => logic.openDrama(context, drama),
|
onDramaSelected: (drama) => logic.openDrama(context, drama),
|
||||||
duration: Duration.zero,
|
duration: Duration.zero,
|
||||||
initialSection: logic.mySection,
|
initialSection: logic.mySection,
|
||||||
|
profile: logic.profile,
|
||||||
sectionRequest: logic.sectionRequest,
|
sectionRequest: logic.sectionRequest,
|
||||||
embedded: true,
|
embedded: true,
|
||||||
active: logic.tab == 2,
|
active: logic.tab == 2,
|
||||||
onAction: logic.myAction,
|
onAction: logic.myAction,
|
||||||
|
signedIn: signedIn,
|
||||||
|
onSignIn: () => openAuthPage(context),
|
||||||
|
onSignOut: () => context.read<AuthController>().signOut(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -161,36 +186,51 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
? null
|
? null
|
||||||
: SafeArea(
|
: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
|
minimum: const EdgeInsets.only(bottom: 2),
|
||||||
child: Container(
|
child: Container(
|
||||||
key: const Key('home-tabs-bar'),
|
key: const Key('home-tabs-bar'),
|
||||||
height: 63,
|
height: 64,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFF0D0D11),
|
color: AppColors.bar,
|
||||||
border: Border(top: BorderSide(color: Color(0xFF242329))),
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
children: [
|
||||||
_TabButton(
|
_TabButton(
|
||||||
key: const Key('tab-home'),
|
key: const Key('tab-discover'),
|
||||||
icon: Icons.play_circle_outline_rounded,
|
icon: Icons.home_outlined,
|
||||||
|
selectedIcon: Icons.home_rounded,
|
||||||
label: l10n.homeTab,
|
label: l10n.homeTab,
|
||||||
selected: logic.tab == 0,
|
selected: logic.tab == 1,
|
||||||
onTap: () => logic.select(0),
|
onTap: () => _selectTab(context, 1),
|
||||||
),
|
),
|
||||||
_TabButton(
|
_TabButton(
|
||||||
key: const Key('tab-discover'),
|
key: const Key('tab-home'),
|
||||||
icon: Icons.explore_outlined,
|
icon: Icons.movie_outlined,
|
||||||
label: l10n.discoverTab,
|
selectedIcon: Icons.movie_rounded,
|
||||||
selected: logic.tab == 1,
|
label: l10n.shortsTab,
|
||||||
onTap: () => logic.select(1),
|
selected: logic.tab == 0,
|
||||||
|
onTap: () => _selectTab(context, 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: () => _selectTab(context, 2),
|
||||||
),
|
),
|
||||||
_TabButton(
|
_TabButton(
|
||||||
key: const Key('tab-my'),
|
key: const Key('tab-my'),
|
||||||
icon: Icons.video_library_outlined,
|
icon: Icons.person_outline_rounded,
|
||||||
|
selectedIcon: Icons.person_rounded,
|
||||||
label: l10n.myTab,
|
label: l10n.myTab,
|
||||||
selected: logic.tab == 2,
|
selected: logic.tab == 2 && logic.profile,
|
||||||
onTap: () => logic.select(2),
|
onTap: () => _selectTab(context, 2, myProfile: true),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -199,44 +239,81 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) {
|
||||||
|
final same = tab == 2
|
||||||
|
? logic.tab == 2 && logic.profile == myProfile
|
||||||
|
: logic.tab == tab;
|
||||||
|
if (!same) {
|
||||||
|
unawaited(context.read<Haptics>().selectionClick());
|
||||||
|
}
|
||||||
|
if (tab == 2) {
|
||||||
|
myProfile ? logic.openProfile() : logic.openLibrary();
|
||||||
|
} else {
|
||||||
|
logic.select(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TabButton extends StatelessWidget {
|
class _TabButton extends StatelessWidget {
|
||||||
const _TabButton({
|
const _TabButton({
|
||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
required this.selectedIcon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
final IconData selectedIcon;
|
||||||
final String label;
|
final String label;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => InkWell(
|
Widget build(BuildContext context) => Expanded(
|
||||||
onTap: onTap,
|
child: Semantics(
|
||||||
child: SizedBox(
|
button: true,
|
||||||
width: 80,
|
selected: selected,
|
||||||
child: Column(
|
label: label,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: InkWell(
|
||||||
children: [
|
onTap: onTap,
|
||||||
Icon(
|
child: ConstrainedBox(
|
||||||
icon,
|
constraints: const BoxConstraints(minHeight: 48),
|
||||||
size: 21,
|
child: Column(
|
||||||
color: selected ? const Color(0xFFFF5C37) : const Color(0xFFE6E1DE),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? AppColors.bluePressed : null,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
selected ? selectedIcon : icon,
|
||||||
|
size: 22,
|
||||||
|
color: selected ? AppColors.onPrimary : AppColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
color: selected ? AppColors.blueSoft : AppColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Text(
|
),
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: selected
|
|
||||||
? const Color(0xFFFF5C37)
|
|
||||||
: const Color(0xFFE6E1DE),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
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,172 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,8 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../../core/config/locale_controller.dart';
|
import '../../../core/config/locale_controller.dart';
|
||||||
import '../../../core/data/models/drama.dart';
|
import '../../../core/data/models/drama.dart';
|
||||||
import '../../../core/formatting/localized_content.dart';
|
import '../../../core/formatting/localized_content.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/remote_image.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Shortcuts for the existing root tabs; this widget owns no navigation state.
|
/// Shortcuts for the existing root tabs; this widget owns no navigation state.
|
||||||
@@ -29,7 +31,7 @@ class HomeQuickDrawer extends StatelessWidget {
|
|||||||
|
|
||||||
static const _panel = Color(0xFF222225);
|
static const _panel = Color(0xFF222225);
|
||||||
static const _card = Color(0xFF303033);
|
static const _card = Color(0xFF303033);
|
||||||
static const _orange = Color(0xFFFF5C37);
|
static const _accent = AppColors.blueSoft;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -90,11 +92,10 @@ class HomeQuickDrawer extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: Image.asset(
|
child: RemoteImage(
|
||||||
drama.posterAsset,
|
drama.posterUrl,
|
||||||
width: 74,
|
width: 74,
|
||||||
height: 100,
|
height: 100,
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -113,18 +114,9 @@ class HomeQuickDrawer extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 7),
|
const SizedBox(height: 7),
|
||||||
Text(
|
Text(
|
||||||
l10n.episodeCount(drama.episodes.length),
|
l10n.episodeCount(drama.episodeCount),
|
||||||
style: const TextStyle(color: Color(0xFFE7E2E0)),
|
style: const TextStyle(color: Color(0xFFE7E2E0)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 5),
|
|
||||||
Text(
|
|
||||||
l10n.freeTestMedia,
|
|
||||||
maxLines: 2,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xFFFFB99E),
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -137,7 +129,7 @@ class HomeQuickDrawer extends StatelessWidget {
|
|||||||
key: const Key('drawer-open-series'),
|
key: const Key('drawer-open-series'),
|
||||||
onPressed: onOpenSeries,
|
onPressed: onOpenSeries,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: _orange,
|
backgroundColor: _accent,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
@@ -201,22 +193,21 @@ class HomeQuickDrawer extends StatelessWidget {
|
|||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
title: l10n.appLanguage,
|
title: l10n.appLanguage,
|
||||||
child: Row(
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
for (final (code, label) in [
|
for (final (code, label) in [
|
||||||
('en', l10n.english),
|
('en', l10n.english),
|
||||||
('zh', l10n.simplifiedChinese),
|
('zh', l10n.simplifiedChinese),
|
||||||
]) ...[
|
('id', l10n.bahasaIndonesia),
|
||||||
Expanded(
|
])
|
||||||
child: ChoiceChip(
|
ChoiceChip(
|
||||||
label: Text(label, maxLines: 1),
|
label: Text(label, maxLines: 1),
|
||||||
selected: languageCode == code,
|
selected: languageCode == code,
|
||||||
onSelected: (_) =>
|
onSelected: (_) =>
|
||||||
context.read<LocaleController>().select(code),
|
context.read<LocaleController>().select(code),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (code == 'en') const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -277,7 +268,7 @@ class _QuickAction extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7),
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: HomeQuickDrawer._orange, size: 25),
|
Icon(icon, color: HomeQuickDrawer._accent, size: 25),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
|
|||||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user