Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ff9f1177 | ||
|
|
61454e36b1 | ||
|
|
0e5d550b50 | ||
|
|
1853e1845c | ||
|
|
ae49b10407 | ||
|
|
feecc349a0 | ||
|
|
e1b98df509 | ||
|
|
77d5748dbb |
No files matched your search
@@ -5,13 +5,46 @@
|
||||
- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。
|
||||
- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。
|
||||
- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。
|
||||
- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。
|
||||
- **互动与多语言**:本地演示评论、系统分享,以及英文、简体中文和印尼语界面。
|
||||
- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。
|
||||
|
||||
## 技术实现
|
||||
|
||||
项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。
|
||||
|
||||
## 对接后端
|
||||
|
||||
目录数据来自短剧服务的客户端接口(`FruitServer` 的 `fruitshorts-app-api`,默认 `http://127.0.0.1:8081`):
|
||||
|
||||
- `GET /app/home/feed`、`GET /app/discover/feed`:轮播 + 分区,一次请求渲染整页。
|
||||
每张卡片带 `preview`(首个可播免费集),首页竖屏播放可以直接起播;
|
||||
没有片源的剧不下发 `preview`,也不会出现在首页的滑动队列里
|
||||
- `GET /app/dramas/{id}`:详情(含剧集列表;可播放的剧集直接带播放地址)
|
||||
- `GET /app/episodes/{id}/play`:播放票据,返回可访问性与地址
|
||||
|
||||
启动后端后直接 `flutter run` 或 Xcode 里 Run 即可,接口地址按运行环境自动选择
|
||||
(见 `lib/core/config/app_config.dart`):
|
||||
|
||||
| 运行环境 | 默认地址 |
|
||||
| --- | --- |
|
||||
| iOS 模拟器 / 桌面端 | `http://127.0.0.1:8081` |
|
||||
| iPhone 真机 | [`devHostBaseUrl`],即电脑当前的局域网地址(`ipconfig getifaddr en0`) |
|
||||
| Android 模拟器 | 需手动指定 `--dart-define=API_BASE_URL=http://10.0.2.2:8081` |
|
||||
|
||||
任何环境都可以用 `--dart-define=API_BASE_URL=http://x.x.x.x:8081` 覆盖默认值。
|
||||
电脑换网络后局域网地址会变,改 `devHostBaseUrl` 即可;也可以换成 Bonjour 名字
|
||||
(`http://$(scutil --get LocalHostName).local:8081`),换网络也不用改。
|
||||
|
||||
真机调试还需注意:手机和电脑在同一个 Wi-Fi;首次启动时 iPhone 会弹「本地网络」权限,
|
||||
必须允许;iOS 的明文 HTTP 与局域网访问已在 `ios/Runner/Info.plist` 里放行
|
||||
(`NSAllowsLocalNetworking`、`NSLocalNetworkUsageDescription`),改动这些配置后
|
||||
要重新构建安装,热重载不生效;如果仍连不上,到「系统设置 → 网络 → 防火墙 → 选项」
|
||||
把 `java` 设为允许传入连接。
|
||||
|
||||
封面与视频都走 `http://<host>:8081/files/**`,本地不再打包演示素材
|
||||
(原先 `assets/media` 里的演示视频有 316MB,已从打包清单移除)。
|
||||
|
||||
## 当前说明
|
||||
|
||||
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
|
||||
收藏、点赞、评论与观看进度仍保存在本机(后端互动接口尚未开发);
|
||||
金币与会员相关界面仍是占位数据。
|
||||
@@ -1,5 +1,3 @@
|
||||
# Author: fruit.com.cn
|
||||
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
@@ -32,9 +30,6 @@ android {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// 反编译保护(法律红线配套):开启 R8 混淆 + 资源压缩,发布前替换正式签名。
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
@@ -1,8 +1,7 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="Nontonki"
|
||||
android:label="FruitShorts"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.stormg.lumadrama
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
<item>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
<item>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<resources>
|
||||
<color name="splash_background">#FF000000</color>
|
||||
<color name="ic_launcher_background">#D74A7D</color>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Author: fruit.com.cn
|
||||
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Author: fruit.com.cn
|
||||
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
@@ -1,2 +1 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
'Debug' => :debug,
|
||||
'Profile' => :release,
|
||||
'Release' => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
|
||||
end
|
||||
|
||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
end
|
||||
end
|
||||
@@ -360,7 +360,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -487,7 +487,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -538,7 +538,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Nontonki</string>
|
||||
<string>FruitShorts</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Nontonki</string>
|
||||
<string>FruitShorts</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -26,6 +26,13 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Author: fruit.com.cn
|
||||
|
||||
arb-dir: lib/l10n
|
||||
template-arb-file: app_en.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
+50
-37
@@ -1,56 +1,68 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'core/config/locale_controller.dart';
|
||||
import 'core/data/repositories/auth_repository.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/repositories/catalog_repository.dart';
|
||||
import 'core/data/repositories/engagement_repository.dart';
|
||||
import 'core/data/repositories/http_catalog_repository.dart';
|
||||
import 'core/data/services/api_client.dart';
|
||||
import 'core/data/services/app_catalog_controller.dart';
|
||||
import 'core/data/services/auth_controller.dart';
|
||||
import 'core/data/services/auth_session_store.dart';
|
||||
import 'core/data/services/catalog_controller.dart';
|
||||
import 'core/data/services/engagement_controller.dart';
|
||||
import 'core/data/services/event_tracker.dart';
|
||||
import 'core/data/services/playback_session.dart';
|
||||
import 'core/data/services/token_storage.dart';
|
||||
import 'core/data/services/video_player_port.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'pages/login/login_view.dart';
|
||||
import 'pages/home/home_view.dart';
|
||||
|
||||
class LumaDramaApp extends StatelessWidget {
|
||||
const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new});
|
||||
class LumaDramaApp extends StatefulWidget {
|
||||
const LumaDramaApp({
|
||||
super.key,
|
||||
this.playerFactory = VideoPlayerPort.new,
|
||||
this.catalogRepository,
|
||||
});
|
||||
|
||||
final PlayerFactory playerFactory;
|
||||
|
||||
/// 测试注入用;生产走 HTTP 仓库。
|
||||
final CatalogRepository? catalogRepository;
|
||||
|
||||
@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);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
|
||||
_auth.load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider(create: (_) => PlaybackCoordinator()),
|
||||
Provider<PlayerFactory>(create: (_) => playerFactory),
|
||||
Provider(create: (_) => ApiClient()),
|
||||
Provider(create: (ctx) => EventTracker(ctx.read<ApiClient>())),
|
||||
Provider(create: (_) => TokenStorage()),
|
||||
Provider(
|
||||
create: (ctx) => AuthRepository(
|
||||
ctx.read<ApiClient>(),
|
||||
ctx.read<TokenStorage>(),
|
||||
),
|
||||
),
|
||||
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
|
||||
ChangeNotifierProvider(
|
||||
create: (ctx) => AuthController(ctx.read<AuthRepository>()),
|
||||
),
|
||||
Provider(
|
||||
create: (ctx) => HttpCatalogRepository(ctx.read<ApiClient>()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (ctx) => AppCatalogController(ctx.read<HttpCatalogRepository>()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => CatalogController(DemoCatalogRepository())..load(),
|
||||
create: (_) => CatalogController(
|
||||
widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)),
|
||||
)..load(),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
|
||||
ChangeNotifierProvider(
|
||||
@@ -58,6 +70,7 @@ class LumaDramaApp extends StatelessWidget {
|
||||
EngagementController(SharedPreferencesEngagementRepository())
|
||||
..load(),
|
||||
),
|
||||
ChangeNotifierProvider.value(value: _auth),
|
||||
],
|
||||
child: Consumer<LocaleController>(
|
||||
builder: (context, locale, _) => MaterialApp(
|
||||
@@ -74,23 +87,23 @@ class LumaDramaApp extends StatelessWidget {
|
||||
return supported.first;
|
||||
},
|
||||
debugShowCheckedModeBanner: false,
|
||||
themeMode: ThemeMode.dark,
|
||||
themeMode: ThemeMode.system,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFFFF5B3D),
|
||||
seedColor: const Color(0xFF2F7BFF),
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF0B0C10),
|
||||
scaffoldBackgroundColor: const Color(0xFFF8F7FC),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFFFF5B3D),
|
||||
seedColor: const Color(0xFF2F7BFF),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF0B0C10),
|
||||
scaffoldBackgroundColor: const Color(0xFF101019),
|
||||
),
|
||||
home: const LoginPage(),
|
||||
home: const HomePage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 接口地址与超时配置。
|
||||
class ApiConfig {
|
||||
ApiConfig._();
|
||||
|
||||
/// 客户端 app-api 地址,可用 --dart-define=API_BASE_URL=... 覆盖。
|
||||
static const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://127.0.0.1:8081',
|
||||
);
|
||||
|
||||
static const int connectTimeoutSeconds = 10;
|
||||
static const int receiveTimeoutSeconds = 15;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
// 与后端 /app/** 内容接口对齐的数据模型。
|
||||
|
||||
class AppEpisode {
|
||||
AppEpisode({
|
||||
required this.id,
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.durationSec,
|
||||
required this.locked,
|
||||
required this.unlockCoin,
|
||||
this.playUrl,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int number;
|
||||
final String title;
|
||||
final int durationSec;
|
||||
final bool locked;
|
||||
final int unlockCoin;
|
||||
final String? playUrl;
|
||||
|
||||
factory AppEpisode.fromJson(Map<String, dynamic> json) => AppEpisode(
|
||||
id: json['id'] as int,
|
||||
number: json['number'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
durationSec: json['durationSec'] as int? ?? 0,
|
||||
locked: json['locked'] as bool? ?? false,
|
||||
unlockCoin: json['unlockCoin'] as int? ?? 0,
|
||||
playUrl: json['playUrl'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class AppDramaCard {
|
||||
AppDramaCard({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.cover,
|
||||
required this.synopsis,
|
||||
required this.genre,
|
||||
required this.category,
|
||||
required this.totalEpisodes,
|
||||
required this.isHot,
|
||||
required this.viewCount,
|
||||
this.preview,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String title;
|
||||
final String cover;
|
||||
final String synopsis;
|
||||
final String genre;
|
||||
final String category;
|
||||
final int totalEpisodes;
|
||||
final bool isHot;
|
||||
final int viewCount;
|
||||
final AppEpisode? preview;
|
||||
|
||||
factory AppDramaCard.fromJson(Map<String, dynamic> json) => AppDramaCard(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
cover: json['cover'] as String? ?? '',
|
||||
synopsis: json['synopsis'] as String? ?? '',
|
||||
genre: json['genre'] as String? ?? '',
|
||||
category: json['category'] as String? ?? '',
|
||||
totalEpisodes: json['totalEpisodes'] as int? ?? 0,
|
||||
isHot: json['isHot'] as bool? ?? false,
|
||||
viewCount: json['viewCount'] as int? ?? 0,
|
||||
preview: json['preview'] == null
|
||||
? null
|
||||
: AppEpisode.fromJson(json['preview'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
class AppDramaDetail {
|
||||
AppDramaDetail({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.synopsis,
|
||||
required this.cover,
|
||||
required this.genre,
|
||||
required this.category,
|
||||
required this.totalEpisodes,
|
||||
required this.freeEpisodes,
|
||||
required this.unlockCoin,
|
||||
required this.isHot,
|
||||
required this.viewCount,
|
||||
required this.episodes,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String title;
|
||||
final String synopsis;
|
||||
final String cover;
|
||||
final String genre;
|
||||
final String category;
|
||||
final int totalEpisodes;
|
||||
final int freeEpisodes;
|
||||
final int unlockCoin;
|
||||
final bool isHot;
|
||||
final int viewCount;
|
||||
final List<AppEpisode> episodes;
|
||||
|
||||
factory AppDramaDetail.fromJson(Map<String, dynamic> json) => AppDramaDetail(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
synopsis: json['synopsis'] as String? ?? '',
|
||||
cover: json['cover'] as String? ?? '',
|
||||
genre: json['genre'] as String? ?? '',
|
||||
category: json['category'] as String? ?? '',
|
||||
totalEpisodes: json['totalEpisodes'] as int? ?? 0,
|
||||
freeEpisodes: json['freeEpisodes'] as int? ?? 0,
|
||||
unlockCoin: json['unlockCoin'] as int? ?? 0,
|
||||
isHot: json['isHot'] as bool? ?? false,
|
||||
viewCount: json['viewCount'] as int? ?? 0,
|
||||
episodes: (json['episodes'] as List? ?? const [])
|
||||
.map((e) => AppEpisode.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
class AppBanner {
|
||||
AppBanner({
|
||||
required this.id,
|
||||
required this.imageUrl,
|
||||
required this.linkType,
|
||||
required this.linkValue,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String imageUrl;
|
||||
final String linkType;
|
||||
final String linkValue;
|
||||
|
||||
factory AppBanner.fromJson(Map<String, dynamic> json) => AppBanner(
|
||||
id: json['id'] as int,
|
||||
imageUrl: json['imageUrl'] as String? ?? '',
|
||||
linkType: json['linkType'] as String? ?? '',
|
||||
linkValue: json['linkValue'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
class AppCategory {
|
||||
AppCategory({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
factory AppCategory.fromJson(Map<String, dynamic> json) =>
|
||||
AppCategory(id: json['id'] as int, name: json['name'] as String? ?? '');
|
||||
}
|
||||
|
||||
class AppSection {
|
||||
AppSection({
|
||||
required this.code,
|
||||
required this.title,
|
||||
required this.dramas,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String title;
|
||||
final List<AppDramaCard> dramas;
|
||||
|
||||
factory AppSection.fromJson(Map<String, dynamic> json) => AppSection(
|
||||
code: json['code'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
dramas: (json['dramas'] as List? ?? const [])
|
||||
.map((e) => AppDramaCard.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
class AppFeed {
|
||||
AppFeed({required this.banners, required this.sections});
|
||||
|
||||
final List<AppBanner> banners;
|
||||
final List<AppSection> sections;
|
||||
|
||||
factory AppFeed.fromJson(Map<String, dynamic> json) => AppFeed(
|
||||
banners: (json['banners'] as List? ?? const [])
|
||||
.map((e) => AppBanner.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
sections: (json['sections'] as List? ?? const [])
|
||||
.map((e) => AppSection.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
+101
-10
@@ -1,21 +1,53 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
enum DramaFormat { animated, liveAction }
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。
|
||||
///
|
||||
/// 封面与播放地址都是后端返回的相对路径(`/files/...`),
|
||||
/// 渲染前用 `AppConfig.mediaUrl` 补上服务地址。
|
||||
class Episode {
|
||||
const Episode({
|
||||
required this.id,
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.assetPath,
|
||||
required this.isFree,
|
||||
this.videoUrl = '',
|
||||
this.durationSec = 0,
|
||||
this.locked = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final int number;
|
||||
final String title;
|
||||
final String assetPath;
|
||||
final bool isFree;
|
||||
|
||||
/// 播放地址:详情接口不下发,由 `/app/episodes/{id}/play` 返回后写入。
|
||||
final String videoUrl;
|
||||
final int durationSec;
|
||||
|
||||
/// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。
|
||||
final bool locked;
|
||||
|
||||
Episode withVideoUrl(String url) => Episode(
|
||||
id: id,
|
||||
number: number,
|
||||
title: title,
|
||||
isFree: isFree,
|
||||
videoUrl: url,
|
||||
durationSec: durationSec,
|
||||
locked: locked,
|
||||
);
|
||||
|
||||
factory Episode.fromJson(Map<String, dynamic> json) {
|
||||
return Episode(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
number: (json['number'] as num?)?.toInt() ?? 0,
|
||||
title: json['title'] as String? ?? '',
|
||||
isFree: json['isFree'] == true,
|
||||
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
|
||||
locked: json['locked'] == true,
|
||||
videoUrl: json['playUrl'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Drama {
|
||||
@@ -23,15 +55,74 @@ class Drama {
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.synopsis,
|
||||
required this.posterAsset,
|
||||
required this.episodes,
|
||||
this.format = DramaFormat.liveAction,
|
||||
required this.posterUrl,
|
||||
this.episodes = const [],
|
||||
this.category = '',
|
||||
this.genre = '',
|
||||
this.totalEpisodes = 0,
|
||||
this.freeEpisodes = 0,
|
||||
this.isHot = false,
|
||||
this.viewCount = 0,
|
||||
this.detailLoaded = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String synopsis;
|
||||
final String posterAsset;
|
||||
final String posterUrl;
|
||||
final List<Episode> episodes;
|
||||
final DramaFormat format;
|
||||
final String category;
|
||||
final String genre;
|
||||
final int totalEpisodes;
|
||||
final int freeEpisodes;
|
||||
final bool isHot;
|
||||
final int viewCount;
|
||||
|
||||
/// 详情接口是否已经取过完整剧集列表。
|
||||
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
|
||||
final bool detailLoaded;
|
||||
|
||||
/// 卡片只带一集预览,集数以后端给的总集数为准。
|
||||
int get episodeCount => totalEpisodes > 0 ? totalEpisodes : episodes.length;
|
||||
|
||||
/// 列表卡片:带一集可播预览(首页直接起播),完整剧集进详情再取。
|
||||
factory Drama.fromCardJson(Map<String, dynamic> json) {
|
||||
final preview = json['preview'];
|
||||
return Drama(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
title: json['title'] as String? ?? '',
|
||||
synopsis: json['synopsis'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
episodes: preview is Map<String, dynamic>
|
||||
? [Episode.fromJson(preview)]
|
||||
: const [],
|
||||
category: json['category'] as String? ?? '',
|
||||
genre: json['genre'] as String? ?? '',
|
||||
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
|
||||
isHot: json['isHot'] == true,
|
||||
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
|
||||
viewCount: jsonToInt(json['viewCount']),
|
||||
);
|
||||
}
|
||||
|
||||
/// 详情:带剧集列表。
|
||||
factory Drama.fromDetailJson(Map<String, dynamic> json) {
|
||||
return Drama(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
title: json['title'] as String? ?? '',
|
||||
synopsis: json['synopsis'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
category: json['category'] as String? ?? '',
|
||||
genre: json['genre'] as String? ?? '',
|
||||
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
|
||||
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
||||
isHot: json['isHot'] == true,
|
||||
viewCount: jsonToInt(json['viewCount']),
|
||||
detailLoaded: true,
|
||||
episodes: (json['episodes'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Episode.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
class EngagementSnapshot {
|
||||
const EngagementSnapshot({
|
||||
this.savedDramaIds = const {},
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'drama.dart';
|
||||
|
||||
/// 首页/发现页聚合数据,一次请求拿到轮播与分区。
|
||||
class HomeFeed {
|
||||
const HomeFeed({this.banners = const [], this.sections = const []});
|
||||
|
||||
final List<FeedBanner> banners;
|
||||
final List<FeedSection> sections;
|
||||
|
||||
bool get isEmpty => sections.every((section) => section.dramas.isEmpty);
|
||||
|
||||
/// 分区打平后的短剧列表,供现有 UI 的横滑/竖滑复用;按 id 去重。
|
||||
List<Drama> get dramas {
|
||||
final seen = <String>{};
|
||||
final result = <Drama>[];
|
||||
for (final section in sections) {
|
||||
for (final drama in section.dramas) {
|
||||
if (seen.add(drama.id)) result.add(drama);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
factory HomeFeed.fromJson(Map<String, dynamic> json) {
|
||||
final banners = (json['banners'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(FeedBanner.fromJson)
|
||||
.toList();
|
||||
final sections = (json['sections'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(FeedSection.fromJson)
|
||||
.toList();
|
||||
return HomeFeed(banners: banners, sections: sections);
|
||||
}
|
||||
}
|
||||
|
||||
class FeedSection {
|
||||
const FeedSection({
|
||||
required this.code,
|
||||
required this.title,
|
||||
this.dramas = const [],
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String title;
|
||||
final List<Drama> dramas;
|
||||
|
||||
factory FeedSection.fromJson(Map<String, dynamic> json) {
|
||||
return FeedSection(
|
||||
code: json['code'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
dramas: (json['dramas'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Drama.fromCardJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FeedBanner {
|
||||
const FeedBanner({
|
||||
required this.id,
|
||||
required this.imageUrl,
|
||||
this.linkType = 'NONE',
|
||||
this.linkValue = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String imageUrl;
|
||||
final String linkType;
|
||||
final String linkValue;
|
||||
|
||||
/// 点击轮播要打开的短剧 id,非短剧跳转返回 null。
|
||||
String? get dramaId => linkType == 'DRAMA' && linkValue.isNotEmpty
|
||||
? linkValue
|
||||
: null;
|
||||
|
||||
factory FeedBanner.fromJson(Map<String, dynamic> json) {
|
||||
return FeedBanner(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
imageUrl: json['imageUrl'] as String? ?? '',
|
||||
linkType: json['linkType'] as String? ?? 'NONE',
|
||||
linkValue: json['linkValue'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 数值字段的兼容解析。
|
||||
///
|
||||
/// 后端契约(FruitServer/docs/api-phase1.md §0):`Long`/`long` 字段**一律以字符串下发**
|
||||
/// (雪花 id 与计数都算),`Integer`/`int` 仍是数字。所以这里既接受数字也接受数字字符串,
|
||||
/// 否则一个 `"12345"` 就能让 `as num?` 直接抛异常、整页解析失败。
|
||||
int jsonToInt(dynamic value, {int fallback = 0}) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value.trim()) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。
|
||||
class PlayTicket {
|
||||
const PlayTicket({
|
||||
required this.episodeId,
|
||||
required this.accessible,
|
||||
required this.accessType,
|
||||
this.playUrl = '',
|
||||
this.durationSec = 0,
|
||||
this.unlockCoin = 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 bool accessible;
|
||||
final String accessType;
|
||||
final String playUrl;
|
||||
final int durationSec;
|
||||
final int unlockCoin;
|
||||
|
||||
bool get needsUnlock =>
|
||||
accessType == coinRequired || accessType == vipRequired;
|
||||
|
||||
factory PlayTicket.fromJson(Map<String, dynamic> json) {
|
||||
return PlayTicket(
|
||||
episodeId: (json['episodeId'] ?? '').toString(),
|
||||
accessible: json['accessible'] == true,
|
||||
accessType: json['accessType'] as String? ?? noMedia,
|
||||
playUrl: json['playUrl'] as String? ?? '',
|
||||
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
|
||||
unlockCoin: (json['unlockCoin'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
abstract interface class PlayerPort {
|
||||
Future<void> initialize();
|
||||
Future<void> play();
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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?> _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,36 @@
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../models/play_ticket.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')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<PlayTicket> playTicket(String episodeId) async {
|
||||
return PlayTicket.fromJson(
|
||||
_asMap(await _client.get('/app/episodes/$episodeId/play')),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _asMap(Object? data) {
|
||||
if (data is Map<String, dynamic>) return data;
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'api_client.dart';
|
||||
import 'token_storage.dart';
|
||||
|
||||
/// 登录/刷新成功后返回的会话令牌。
|
||||
class AuthSession {
|
||||
AuthSession({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresIn,
|
||||
required this.newUser,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int expiresIn;
|
||||
final bool newUser;
|
||||
|
||||
factory AuthSession.fromJson(Map<String, dynamic> json) {
|
||||
return AuthSession(
|
||||
accessToken: json['accessToken'] as String,
|
||||
refreshToken: json['refreshToken'] as String,
|
||||
expiresIn: (json['expiresIn'] as num?)?.toInt() ?? 7200,
|
||||
newUser: json['newUser'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号与鉴权接口封装。
|
||||
class AuthRepository {
|
||||
AuthRepository(this._client, this._tokenStorage);
|
||||
|
||||
final ApiClient _client;
|
||||
final TokenStorage _tokenStorage;
|
||||
|
||||
/// 发送验证码。
|
||||
Future<void> sendOtp({
|
||||
required String scene,
|
||||
required String targetType,
|
||||
required String target,
|
||||
}) async {
|
||||
await _client.post('/app/auth/otp', data: {
|
||||
'scene': scene,
|
||||
'targetType': targetType,
|
||||
'target': target,
|
||||
});
|
||||
}
|
||||
|
||||
/// 验证码登录。
|
||||
Future<AuthSession> loginWithCode({
|
||||
required String targetType,
|
||||
required String target,
|
||||
required String code,
|
||||
}) async {
|
||||
final data = await _client.post('/app/auth/login', data: {
|
||||
'targetType': targetType,
|
||||
'target': target,
|
||||
'code': code,
|
||||
});
|
||||
return AuthSession.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 密码登录。
|
||||
Future<AuthSession> loginWithPassword({
|
||||
required String targetType,
|
||||
required String target,
|
||||
required String password,
|
||||
}) async {
|
||||
final data = await _client.post('/app/auth/login', data: {
|
||||
'targetType': targetType,
|
||||
'target': target,
|
||||
'password': password,
|
||||
});
|
||||
return AuthSession.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 保存会话令牌。
|
||||
Future<void> saveSession(AuthSession session) async {
|
||||
await _tokenStorage.saveTokens(
|
||||
access: session.accessToken,
|
||||
refresh: session.refreshToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// 登出并清理本地令牌。
|
||||
Future<void> logout() async {
|
||||
await _client.post('/app/auth/logout');
|
||||
await _tokenStorage.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,36 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../models/play_ticket.dart';
|
||||
import '../remote/catalog_api.dart';
|
||||
|
||||
/// 目录数据来源:首页/发现页聚合、详情、播放票据。
|
||||
///
|
||||
/// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。
|
||||
abstract interface class CatalogRepository {
|
||||
Future<List<Drama>> fetchDramas();
|
||||
Future<HomeFeed> fetchHomeFeed();
|
||||
|
||||
Future<HomeFeed> fetchDiscoverFeed();
|
||||
|
||||
Future<Drama> fetchDrama(String dramaId);
|
||||
|
||||
Future<PlayTicket> resolvePlay(String episodeId);
|
||||
}
|
||||
|
||||
class DemoCatalogRepository implements CatalogRepository {
|
||||
class HttpCatalogRepository implements CatalogRepository {
|
||||
HttpCatalogRepository(this._api);
|
||||
|
||||
final CatalogApi _api;
|
||||
|
||||
@override
|
||||
Future<List<Drama>> fetchDramas() async => const [
|
||||
Drama(
|
||||
id: 'the-signal-demo',
|
||||
title: 'The Signal',
|
||||
synopsis:
|
||||
'A mock series for testing vertical episode playback with one reused local clip.',
|
||||
posterAsset: 'assets/images/stitch_signal_poster_clean.png',
|
||||
format: DramaFormat.animated,
|
||||
episodes: [
|
||||
Episode(
|
||||
id: 'signal-01',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'signal-02',
|
||||
number: 2,
|
||||
title: 'Episode 2',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'signal-03',
|
||||
number: 3,
|
||||
title: 'Episode 3',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
Drama(
|
||||
id: 'city-lights-demo',
|
||||
title: 'City Lights & Velvet Nights',
|
||||
synopsis:
|
||||
'A mock romance series using the same local video for navigation testing.',
|
||||
posterAsset: 'assets/images/stitch_city_poster_clean.png',
|
||||
format: DramaFormat.liveAction,
|
||||
episodes: [
|
||||
Episode(
|
||||
id: 'city-01',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'city-02',
|
||||
number: 2,
|
||||
title: 'Episode 2',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'city-03',
|
||||
number: 3,
|
||||
title: 'Episode 3',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
Drama(
|
||||
id: 'vengeance-demo',
|
||||
title: 'Vengeance Rising',
|
||||
synopsis:
|
||||
'A mock action series using the same local video for navigation testing.',
|
||||
posterAsset: 'assets/images/stitch_vengeance_poster_clean.png',
|
||||
format: DramaFormat.liveAction,
|
||||
episodes: [
|
||||
Episode(
|
||||
id: 'vengeance-01',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'vengeance-02',
|
||||
number: 2,
|
||||
title: 'Episode 2',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'vengeance-03',
|
||||
number: 3,
|
||||
title: 'Episode 3',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
Future<HomeFeed> fetchHomeFeed() => _api.homeFeed();
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() => _api.discoverFeed();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
|
||||
|
||||
@override
|
||||
Future<PlayTicket> resolvePlay(String episodeId) =>
|
||||
_api.playTicket(episodeId);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/app_catalog.dart';
|
||||
import '../services/api_client.dart';
|
||||
|
||||
/// 对接后端 `/app/**` 内容接口(A3)。
|
||||
class HttpCatalogRepository {
|
||||
HttpCatalogRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<AppFeed> fetchHomeFeed() async {
|
||||
final data = await _client.get('/app/home/feed');
|
||||
return AppFeed.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<AppFeed> fetchDiscoverFeed() async {
|
||||
final data = await _client.get('/app/discover/feed');
|
||||
return AppFeed.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<List<AppCategory>> fetchCategories() async {
|
||||
final data = await _client.get('/app/categories');
|
||||
return (data as List)
|
||||
.map((e) => AppCategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<AppDramaDetail> fetchDramaDetail(int id) async {
|
||||
final data = await _client.get('/app/dramas/$id');
|
||||
return AppDramaDetail.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../services/api_client.dart';
|
||||
|
||||
/// 评论、点赞、举报(A6)。
|
||||
class InteractionRepository {
|
||||
InteractionRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<void> postComment({
|
||||
required int dramaId,
|
||||
required String content,
|
||||
int? rootId,
|
||||
}) async {
|
||||
await _client.post('/app/comments', data: {
|
||||
'dramaId': dramaId,
|
||||
'content': content,
|
||||
if (rootId != null) 'rootId': rootId,
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<dynamic>> fetchComments({
|
||||
required int dramaId,
|
||||
int? cursor,
|
||||
int size = 20,
|
||||
}) async {
|
||||
final data = await _client.get('/app/comments', query: {
|
||||
'dramaId': dramaId,
|
||||
if (cursor != null) 'cursor': cursor,
|
||||
'size': size,
|
||||
});
|
||||
return data as List;
|
||||
}
|
||||
|
||||
Future<void> deleteComment(int id) async {
|
||||
await _client.delete('/app/comments/$id');
|
||||
}
|
||||
|
||||
Future<void> toggleLike({
|
||||
required String targetType,
|
||||
required int targetId,
|
||||
}) async {
|
||||
await _client.post('/app/likes/toggle', data: {
|
||||
'targetType': targetType,
|
||||
'targetId': targetId,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> report({
|
||||
required String targetType,
|
||||
required int targetId,
|
||||
required String reason,
|
||||
}) async {
|
||||
await _client.post('/app/reports', data: {
|
||||
'targetType': targetType,
|
||||
'targetId': targetId,
|
||||
'reason': reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../services/api_client.dart';
|
||||
|
||||
/// 播放票据(A4)。
|
||||
class PlaybackRepository {
|
||||
PlaybackRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 签发播放票据与签名地址,客户端原样使用 playUrl。
|
||||
Future<Map<String, dynamic>> fetchPlayTicket(int episodeId) async {
|
||||
final data = await _client.get('/app/episodes/$episodeId/play');
|
||||
return data as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../services/api_client.dart';
|
||||
|
||||
/// 钱包、金币解锁(09 收费支付)。
|
||||
class WalletRepository {
|
||||
WalletRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<Map<String, dynamic>> fetchWallet() async {
|
||||
final data = await _client.get('/app/wallet');
|
||||
return data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> unlockEpisode(int episodeId) async {
|
||||
final data = await _client.post('/app/episodes/$episodeId/unlock');
|
||||
return data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<List<dynamic>> fetchOrders() async {
|
||||
final data = await _client.get('/app/orders');
|
||||
return data as List;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/drama.dart';
|
||||
|
||||
const demoProfileId = 'demo-local-v1';
|
||||
|
||||
abstract interface class WatchHistoryRepository {
|
||||
@@ -62,6 +62,17 @@ class SharedPreferencesWatchHistoryRepository
|
||||
}
|
||||
}
|
||||
|
||||
/// 续看位置:列表卡片没有剧集列表,取不到首集时退回最近观看的那一集。
|
||||
extension WatchHistoryResume on WatchHistoryRepository {
|
||||
Future<Duration?> resumePositionFor(String profileId, Drama drama) async {
|
||||
final episodeId = drama.episodes.isNotEmpty
|
||||
? drama.episodes.first.id
|
||||
: await lastEpisodeId(profileId, drama.id);
|
||||
if (episodeId == null) return null;
|
||||
return positionFor(profileId, drama.id, episodeId);
|
||||
}
|
||||
}
|
||||
|
||||
Duration clampResumePosition(Duration saved, Duration duration) {
|
||||
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
|
||||
if (saved <= Duration.zero) return Duration.zero;
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../services/api_client.dart';
|
||||
|
||||
/// 观看记录与收藏(A5)。
|
||||
class WatchRepository {
|
||||
WatchRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<void> reportProgress({
|
||||
required int episodeId,
|
||||
required int positionSec,
|
||||
required int durationSec,
|
||||
}) async {
|
||||
await _client.post('/app/watch/progress', data: {
|
||||
'episodeId': episodeId,
|
||||
'positionSec': positionSec,
|
||||
'durationSec': durationSec,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fetchProgress(int dramaId) async {
|
||||
final data = await _client.get('/app/watch/progress', query: {
|
||||
'dramaId': dramaId,
|
||||
});
|
||||
return data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<List<dynamic>> fetchRecent({int limit = 20}) async {
|
||||
final data = await _client.get('/app/watch/recent', query: {'limit': limit});
|
||||
return data as List;
|
||||
}
|
||||
|
||||
Future<void> deleteProgress(int dramaId) async {
|
||||
await _client.delete('/app/watch/progress/$dramaId');
|
||||
}
|
||||
|
||||
Future<void> clearProgress() async {
|
||||
await _client.delete('/app/watch/progress');
|
||||
}
|
||||
|
||||
Future<bool> toggleFavorite(int dramaId) async {
|
||||
final data = await _client.post('/app/favorites/toggle', data: {
|
||||
'dramaId': dramaId,
|
||||
});
|
||||
final map = data as Map<String, dynamic>;
|
||||
return map['favorited'] as bool? ?? false;
|
||||
}
|
||||
|
||||
Future<List<dynamic>> fetchFavorites() async {
|
||||
final data = await _client.get('/app/favorites');
|
||||
return data as List;
|
||||
}
|
||||
|
||||
Future<void> deleteFavorite(int dramaId) async {
|
||||
await _client.delete('/app/favorites/$dramaId');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../config/api_config.dart';
|
||||
import 'token_storage.dart';
|
||||
|
||||
/// 统一业务异常:code 为后端业务码,message 可直接展示。
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.code, this.message);
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($code): $message';
|
||||
}
|
||||
|
||||
/// 统一 HTTP 客户端:BaseUrl、Token 注入、401 自动刷新、{code,message,data} 解包。
|
||||
class ApiClient {
|
||||
ApiClient({Dio? dio, TokenStorage? tokenStorage})
|
||||
: _dio = dio ?? Dio(),
|
||||
_tokenStorage = tokenStorage ?? TokenStorage() {
|
||||
_dio.options
|
||||
..baseUrl = ApiConfig.baseUrl
|
||||
..connectTimeout = const Duration(
|
||||
seconds: ApiConfig.connectTimeoutSeconds,
|
||||
)
|
||||
..receiveTimeout = const Duration(
|
||||
seconds: ApiConfig.receiveTimeoutSeconds,
|
||||
);
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(onRequest: _onRequest, onError: _onError),
|
||||
);
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
final TokenStorage _tokenStorage;
|
||||
|
||||
bool _refreshing = false;
|
||||
|
||||
Future<void> _onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final access = await _tokenStorage.readAccessToken();
|
||||
if (access != null && access.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $access';
|
||||
}
|
||||
// 设备标识:第一阶段固定占位,A2 接入真实 deviceId。
|
||||
options.headers['X-Device-Id'] = 'device-0001';
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
Future<void> _onError(
|
||||
DioException error,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
final status = error.response?.statusCode;
|
||||
final path = error.requestOptions.path;
|
||||
final isAuthEndpoint =
|
||||
path.endsWith('/auth/refresh') || path.endsWith('/auth/login');
|
||||
if (status == 401 && !isAuthEndpoint && !_refreshing) {
|
||||
final refreshed = await _refresh();
|
||||
if (refreshed) {
|
||||
handler.resolve(await _retry(error.requestOptions));
|
||||
return;
|
||||
}
|
||||
}
|
||||
handler.next(error);
|
||||
}
|
||||
|
||||
Future<bool> _refresh() async {
|
||||
_refreshing = true;
|
||||
try {
|
||||
final refresh = await _tokenStorage.readRefreshToken();
|
||||
if (refresh == null || refresh.isEmpty) return false;
|
||||
final response = await _dio.post<dynamic>(
|
||||
'/app/auth/refresh',
|
||||
data: {'refreshToken': refresh},
|
||||
);
|
||||
final body = response.data;
|
||||
if (body is Map && body['code'] == 0) {
|
||||
final data = body['data'] as Map;
|
||||
await _tokenStorage.saveTokens(
|
||||
access: data['accessToken'] as String,
|
||||
refresh: data['refreshToken'] as String,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
_refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response<dynamic>> _retry(RequestOptions options) {
|
||||
return _dio.fetch<dynamic>(options);
|
||||
}
|
||||
|
||||
Future<dynamic> get(String path, {Map<String, dynamic>? query}) {
|
||||
return _request(() => _dio.get<dynamic>(path, queryParameters: query));
|
||||
}
|
||||
|
||||
Future<dynamic> post(String path, {Object? data}) {
|
||||
return _request(() => _dio.post<dynamic>(path, data: data));
|
||||
}
|
||||
|
||||
Future<dynamic> put(String path, {Object? data}) {
|
||||
return _request(() => _dio.put<dynamic>(path, data: data));
|
||||
}
|
||||
|
||||
Future<dynamic> delete(String path, {Object? data}) {
|
||||
return _request(() => _dio.delete<dynamic>(path, data: data));
|
||||
}
|
||||
|
||||
Future<dynamic> _request(Future<Response<dynamic>> Function() send) async {
|
||||
try {
|
||||
final response = await send();
|
||||
final body = response.data;
|
||||
if (body is Map && body.containsKey('code')) {
|
||||
final code = body['code'];
|
||||
if (code == 0) return body['data'];
|
||||
throw ApiException(code as int, (body['message'] as String?) ?? '');
|
||||
}
|
||||
return body;
|
||||
} on DioException catch (e) {
|
||||
throw ApiException(
|
||||
e.response?.statusCode ?? -1,
|
||||
e.message ?? 'network error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/app_catalog.dart';
|
||||
import '../repositories/http_catalog_repository.dart';
|
||||
|
||||
/// 首页/发现页 feed 状态(A3 数据层)。
|
||||
class AppCatalogController extends ChangeNotifier {
|
||||
AppCatalogController(this._repository);
|
||||
|
||||
final HttpCatalogRepository _repository;
|
||||
|
||||
AppFeed? feed;
|
||||
bool loading = false;
|
||||
String? error;
|
||||
|
||||
Future<void> loadFeed() async {
|
||||
loading = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
feed = await _repository.fetchHomeFeed();
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppDramaDetail> loadDetail(int id) {
|
||||
return _repository.fetchDramaDetail(id);
|
||||
}
|
||||
|
||||
Future<List<AppCategory>> loadCategories() {
|
||||
return _repository.fetchCategories();
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,103 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../repositories/auth_repository.dart';
|
||||
import '../models/auth_session.dart';
|
||||
import '../remote/api_client.dart';
|
||||
import '../remote/auth_api.dart';
|
||||
import 'auth_session_store.dart';
|
||||
|
||||
enum AuthStatus { unknown, authenticated, unauthenticated }
|
||||
|
||||
/// 登录状态与账号操作。
|
||||
/// 登录态的唯一来源。
|
||||
///
|
||||
/// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新;
|
||||
/// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 ——
|
||||
/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。
|
||||
class AuthController extends ChangeNotifier {
|
||||
AuthController(this._repository);
|
||||
AuthController(this._api, this._store);
|
||||
|
||||
final AuthRepository _repository;
|
||||
final AuthApi _api;
|
||||
final AuthSessionStore _store;
|
||||
|
||||
AuthStatus _status = AuthStatus.unknown;
|
||||
AuthStatus get status => _status;
|
||||
AuthSession? _session;
|
||||
String _deviceId = '';
|
||||
bool _loaded = false;
|
||||
|
||||
Future<void> sendOtp({
|
||||
required String scene,
|
||||
required String targetType,
|
||||
required String target,
|
||||
}) {
|
||||
return _repository.sendOtp(
|
||||
scene: scene,
|
||||
targetType: targetType,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
bool get isLoaded => _loaded;
|
||||
bool get isSignedIn => _session != null;
|
||||
AuthUser? get user => _session?.user;
|
||||
String? get accessToken => _session?.accessToken;
|
||||
String get deviceId => _deviceId;
|
||||
|
||||
Future<void> loginWithCode({
|
||||
required String targetType,
|
||||
required String target,
|
||||
required String code,
|
||||
}) async {
|
||||
final session = await _repository.loginWithCode(
|
||||
targetType: targetType,
|
||||
target: target,
|
||||
code: code,
|
||||
);
|
||||
await _repository.saveSession(session);
|
||||
_status = AuthStatus.authenticated;
|
||||
/// App 启动时调用:恢复本地令牌与设备号。
|
||||
Future<void> load() async {
|
||||
_deviceId = await _store.deviceId();
|
||||
_session = await _store.read();
|
||||
_loaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loginWithPassword({
|
||||
required String targetType,
|
||||
required String target,
|
||||
required String password,
|
||||
}) async {
|
||||
final session = await _repository.loginWithPassword(
|
||||
targetType: targetType,
|
||||
target: target,
|
||||
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 _repository.saveSession(session);
|
||||
_status = AuthStatus.authenticated;
|
||||
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> logout() async {
|
||||
await _repository.logout();
|
||||
_status = AuthStatus.unauthenticated;
|
||||
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,72 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/drama.dart';
|
||||
import '../repositories/catalog_repository.dart';
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../repositories/catalog_repository.dart';
|
||||
import '../remote/api_client.dart';
|
||||
|
||||
/// 目录状态:首页/发现页聚合数据 + 详情按需加载。
|
||||
///
|
||||
/// 列表卡片不含剧集,只有进详情或播放时才取一次详情,
|
||||
/// 这样首页接口能保持很小的响应体。
|
||||
class CatalogController extends ChangeNotifier {
|
||||
CatalogController(this.repository);
|
||||
|
||||
final CatalogRepository repository;
|
||||
|
||||
List<Drama> dramas = const [];
|
||||
HomeFeed feed = const HomeFeed();
|
||||
bool loading = false;
|
||||
String? error;
|
||||
|
||||
List<Drama> get dramas => feed.dramas;
|
||||
List<FeedBanner> get banners => feed.banners;
|
||||
List<FeedSection> get sections => feed.sections;
|
||||
|
||||
Future<void> load() async {
|
||||
loading = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
dramas = await repository.fetchDramas();
|
||||
feed = await repository.fetchHomeFeed();
|
||||
} on ApiException catch (exception) {
|
||||
error = exception.message;
|
||||
} catch (_) {
|
||||
error = 'The catalog could not be loaded.';
|
||||
error = 'catalogError';
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 需要完整剧集时补一次详情;已加载过则直接返回,避免重复请求。
|
||||
Future<Drama> requireDetail(Drama drama) async {
|
||||
if (drama.detailLoaded) return drama;
|
||||
final detail = await repository.fetchDrama(drama.id);
|
||||
_replace(detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/// 播放前解析票据:返回带播放地址的剧集,锁定集保持无地址。
|
||||
Future<Episode> resolvePlay(Episode episode) async {
|
||||
if (episode.videoUrl.isNotEmpty) return episode;
|
||||
final ticket = await repository.resolvePlay(episode.id);
|
||||
return ticket.accessible ? episode.withVideoUrl(ticket.playUrl) : episode;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/engagement_snapshot.dart';
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 核心埋点事件名(第一阶段口径,后续与运营对齐后可扩展)。
|
||||
class AppEvents {
|
||||
AppEvents._();
|
||||
|
||||
static const String playbackStart = 'playback_start';
|
||||
static const String playbackEnd = 'playback_end';
|
||||
static const String paySuccess = 'pay_success';
|
||||
static const String register = 'register';
|
||||
static const String click = 'click';
|
||||
}
|
||||
|
||||
/// 埋点上报:本地缓冲、批量上报,失败静默丢弃不影响主流程。
|
||||
class EventTracker {
|
||||
EventTracker(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
static const int _flushSize = 10;
|
||||
static const Duration _flushInterval = Duration(seconds: 10);
|
||||
|
||||
final List<Map<String, Object?>> _buffer = [];
|
||||
Timer? _timer;
|
||||
|
||||
void track(
|
||||
String eventName, {
|
||||
String? page,
|
||||
Map<String, Object?>? properties,
|
||||
}) {
|
||||
_buffer.add({
|
||||
'eventName': eventName,
|
||||
'eventTime': DateTime.now().toIso8601String(),
|
||||
'page': page,
|
||||
'properties': properties,
|
||||
});
|
||||
if (_buffer.length >= _flushSize) {
|
||||
flush();
|
||||
} else {
|
||||
_timer ??= Timer(_flushInterval, flush);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> flush() async {
|
||||
if (_buffer.isEmpty) return;
|
||||
final events = List<Map<String, Object?>>.from(_buffer);
|
||||
_buffer.clear();
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
try {
|
||||
await _api.post('/app/events', data: {
|
||||
'deviceId': 'device-0001',
|
||||
'sessionId': 'session-0001',
|
||||
'events': events,
|
||||
});
|
||||
} catch (_) {
|
||||
// 埋点失败不影响主流程,静默丢弃。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class PlatformShareService {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/drama.dart';
|
||||
|
||||
/// A player session has one navigation meaning: browse dramas or browse episodes.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -130,6 +128,9 @@ class PlaybackSession extends ChangeNotifier {
|
||||
if (!slot.retiring && !_closed) slot.prepared = true;
|
||||
} catch (error) {
|
||||
slot.failure = error;
|
||||
if (kDebugMode) {
|
||||
debugPrint('片源准备失败 ${episodes[index].videoUrl}: $error');
|
||||
}
|
||||
}
|
||||
}();
|
||||
return slot;
|
||||
@@ -151,14 +152,23 @@ class PlaybackSession extends ChangeNotifier {
|
||||
/// use exactly the same mechanism without warming an unrelated episode.
|
||||
Future<void> select(int index, {int? preloadIndex}) async {
|
||||
if (_closed || index < 0 || index >= episodes.length) return;
|
||||
// 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。
|
||||
// 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。
|
||||
if (!_selected || index != _currentIndex) {
|
||||
_userWantsPlay = true;
|
||||
}
|
||||
final request = ++_generation;
|
||||
_selected = true;
|
||||
_currentIndex = index;
|
||||
_nextIndex = preloadIndex;
|
||||
_error = null;
|
||||
if (!_visible) {
|
||||
_loading = false;
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
_loading = preparedPlayerAt(index) == null && !isLocked;
|
||||
_notify();
|
||||
if (!_visible) return;
|
||||
final acquired = await _coordinator._acquire(
|
||||
this,
|
||||
() => _visible && request == _generation,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 令牌本地存储。第一阶段用 shared_preferences,后续可换 secure storage。
|
||||
class TokenStorage {
|
||||
static const _accessKey = 'auth_access_token';
|
||||
static const _refreshKey = 'auth_refresh_token';
|
||||
|
||||
Future<void> saveTokens({
|
||||
required String access,
|
||||
required String refresh,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_accessKey, access);
|
||||
await prefs.setString(_refreshKey, refresh);
|
||||
}
|
||||
|
||||
Future<String?> readAccessToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_accessKey);
|
||||
}
|
||||
|
||||
Future<String?> readRefreshToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_refreshKey);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_accessKey);
|
||||
await prefs.remove(_refreshKey);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import '../models/drama.dart';
|
||||
import '../models/player_port.dart';
|
||||
|
||||
@@ -9,11 +8,25 @@ class VideoPlayerPort implements PlayerPort {
|
||||
VideoPlayerPort(
|
||||
Episode episode, {
|
||||
Future<ClosedCaptionFile>? closedCaptionFile,
|
||||
}) : controller = VideoPlayerController.asset(
|
||||
episode.assetPath,
|
||||
closedCaptionFile: closedCaptionFile,
|
||||
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false),
|
||||
);
|
||||
}) : controller = _open(episode, closedCaptionFile);
|
||||
|
||||
/// 后端下发的是相对路径(`/files/...`),交给播放器前补上服务地址。
|
||||
/// 否则 Uri 没有 scheme/host,AVPlayer 和 ExoPlayer 都加载不了。
|
||||
static Uri resolveUri(Episode episode) =>
|
||||
Uri.parse(AppConfig.mediaUrl(episode.videoUrl));
|
||||
|
||||
static VideoPlayerController _open(
|
||||
Episode episode,
|
||||
Future<ClosedCaptionFile>? closedCaptionFile,
|
||||
) {
|
||||
final options = VideoPlayerOptions(mixWithOthers: false);
|
||||
// 片源统一由后端下发(详情里的可播放地址 / 播放票据)
|
||||
return VideoPlayerController.networkUrl(
|
||||
resolveUri(episode),
|
||||
closedCaptionFile: closedCaptionFile,
|
||||
videoPlayerOptions: options,
|
||||
);
|
||||
}
|
||||
|
||||
final VideoPlayerController controller;
|
||||
Future<void>? _initialization;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../data/models/drama.dart';
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared colors. Blue is the primary action color.
|
||||
abstract final class AppColors {
|
||||
static const blue = Color(0xFF2F7BFF);
|
||||
static const blueSoft = Color(0xFF8FB4FF);
|
||||
static const ink = Color(0xFF0B0C10);
|
||||
static const bar = Color(0xFF101114);
|
||||
static const card = Color(0xFF17191F);
|
||||
static const muted = Color(0xFFB7BDC9);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../pages/auth/auth_view.dart';
|
||||
import '../data/services/auth_controller.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库)
|
||||
Future<void> openAuthPage(BuildContext context, {bool signUpMode = false}) {
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => AuthPage(signUpMode: signUpMode),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 受限动作的登录闸:已登录返回 true;未登录弹底部提示并引导登录,返回 false。
|
||||
///
|
||||
/// 首页/发现/详情保持匿名可看,只有收藏、点赞、评论这类"要对到具体账号"的动作才走这里。
|
||||
Future<bool> ensureSignedIn(BuildContext context) async {
|
||||
final auth = context.read<AuthController>();
|
||||
if (auth.isSignedIn) return true;
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final go = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.card,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.authGateTitle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.authGateMessage,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
),
|
||||
onPressed: () => Navigator.of(sheetContext).pop(true),
|
||||
child: Text(l10n.authGateAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (go == true && context.mounted) {
|
||||
await openAuthPage(context);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。
|
||||
class RemoteImage extends StatelessWidget {
|
||||
const RemoteImage(
|
||||
this.path, {
|
||||
super.key,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = AppConfig.mediaUrl(path);
|
||||
if (url.isEmpty) return _fallback();
|
||||
return Image.network(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
errorBuilder: (_, _, _) => _fallback(),
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return _fallback();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fallback() {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
color: const Color(0xFF1C1F26),
|
||||
);
|
||||
}
|
||||
}
|
||||
+53
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"appName": "Nontonki",
|
||||
"appName": "FruitShorts",
|
||||
"demo": "DEMO",
|
||||
"homeHeadline": "Watch now",
|
||||
"homeSubtitle": "Free short drama preview.",
|
||||
@@ -96,6 +96,8 @@
|
||||
"lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.",
|
||||
"playbackError": "This clip could not be played.",
|
||||
"catalogError": "The catalog could not be loaded.",
|
||||
"detailError": "The episode list could not be loaded.",
|
||||
"noMediaNotice": "This episode has no playable media yet.",
|
||||
"noStories": "No stories available yet.",
|
||||
"retry": "Retry",
|
||||
"backToSeries": "Back to series",
|
||||
@@ -129,6 +131,54 @@
|
||||
"genreDramaMystery": "Drama · Mystery",
|
||||
"continueWatchingHeading": "CONTINUE WATCHING",
|
||||
"preferencesHeading": "PREFERENCES",
|
||||
"brandMark": "NONTONKI · DEMO",
|
||||
"shareFooter": "Nontonki demo"
|
||||
"brandMark": "FRUITSHORTS",
|
||||
"shareFooter": "FruitShorts",
|
||||
"shortsTab": "Shorts",
|
||||
"myListTab": "My List",
|
||||
"watchFull": "Watch Full",
|
||||
"swipeForNext": "Swipe up for next",
|
||||
"clearScreen": "Clear screen",
|
||||
"exitClearScreen": "Exit clear screen",
|
||||
"logIn": "Log in",
|
||||
"logInHint": "Keep your coins and list",
|
||||
"rewards": "Rewards",
|
||||
"forYou": "For You",
|
||||
"hot": "Hot",
|
||||
"newest": "New",
|
||||
"favorite": "Favorite",
|
||||
"myAccount": "My account",
|
||||
"coins": "Coins",
|
||||
"topUpWallet": "Top Up Wallet",
|
||||
"vipMembership": "VIP Membership",
|
||||
"notActive": "Not Active",
|
||||
"joinVip": "Join VIP",
|
||||
"earnRewards": "Earn Rewards",
|
||||
"freeBonus": "Get Free Bonus",
|
||||
"watchHistory": "Watch History",
|
||||
"settings": "Settings",
|
||||
"signOut": "Sign Out",
|
||||
"mockUserName": "Vicky sheng",
|
||||
"mockUserId": "ID: M1CZWVYE",
|
||||
"mockNotice": "Sample data only.",
|
||||
"topBadge": "TOP 1",
|
||||
"signIn": "Sign In",
|
||||
"signUp": "Sign Up",
|
||||
"authSignInTitle": "Welcome back",
|
||||
"authSignUpTitle": "Create your account",
|
||||
"authSubtitle": "Sign in with your email and password.",
|
||||
"authEmailLabel": "Email",
|
||||
"authPasswordLabel": "Password",
|
||||
"authPasswordHint": "At least 8 characters, letters and numbers",
|
||||
"authEmailRequired": "Enter your email",
|
||||
"authEmailInvalid": "That email doesn't look right",
|
||||
"authPasswordRequired": "Enter your password",
|
||||
"authPasswordLength": "Password must be 8-64 characters",
|
||||
"authPasswordWeak": "Password needs both letters and numbers",
|
||||
"authNetworkError": "Network error, please try again",
|
||||
"authSwitchToSignUp": "New here? Create an account",
|
||||
"authSwitchToSignIn": "Already have an account? Sign in",
|
||||
"authGateTitle": "Sign in to continue",
|
||||
"authGateMessage": "Favorites and comments need an account.",
|
||||
"authGateAction": "Sign In",
|
||||
"cancel": "Not now"
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"@@locale": "id",
|
||||
"appName": "FruitShorts",
|
||||
"demo": "Demo",
|
||||
"homeHeadline": "Tonton sekarang",
|
||||
"homeSubtitle": "Pratinjau drama pendek gratis.",
|
||||
"mediaNotice": "Video demo lokal dari pengguna · Pratinjau gratis",
|
||||
"featuredDemo": "UNGGULAN · DEMO TEKNIS",
|
||||
"demoTitle": "Sinyal",
|
||||
"demoSynopsis": "Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.",
|
||||
"homeTab": "Beranda",
|
||||
"openQuickMenu": "Buka menu cepat",
|
||||
"quickAccess": "Akses cepat",
|
||||
"currentSeries": "Serial saat ini",
|
||||
"discoverTab": "Jelajahi",
|
||||
"searchDiscover": "Cari drama, aktor, genre...",
|
||||
"featured": "Unggulan",
|
||||
"suspense": "Thriller",
|
||||
"romance": "Romantis",
|
||||
"actionGenre": "Aksi",
|
||||
"filter": "Filter",
|
||||
"topCharts": "Peringkat Teratas",
|
||||
"newReleases": "Rilisan Baru",
|
||||
"trendingMicroDramas": "Drama Mini Populer",
|
||||
"seeAll": "Lihat Semua",
|
||||
"artworkOnly": "Gambar",
|
||||
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
|
||||
"videoPending": "Video belum siap",
|
||||
"watchNow": "Tonton gratis",
|
||||
"noSearchResults": "Tidak ada drama yang cocok.",
|
||||
"demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.",
|
||||
"myTab": "Saya",
|
||||
"localViewer": "Penonton Lokal",
|
||||
"localDemoProfile": "Profil demo lokal",
|
||||
"continueWatching": "Lanjut Tonton",
|
||||
"watchedPercent": "Sudah ditonton {percent}%",
|
||||
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
|
||||
"resume": "Lanjutkan",
|
||||
"history": "Riwayat",
|
||||
"watchlist": "Daftar Tonton",
|
||||
"likedTab": "Disukai",
|
||||
"all": "Semua",
|
||||
"inProgress": "Sedang Berjalan",
|
||||
"completed": "Selesai",
|
||||
"noSavedShows": "Belum ada tontonan di sini.",
|
||||
"preferences": "Preferensi",
|
||||
"appLanguage": "Bahasa Aplikasi",
|
||||
"followingTab": "Mengikuti",
|
||||
"animatedTab": "Animasi",
|
||||
"recommendedTab": "Rekomendasi",
|
||||
"noCategoryDramas": "Belum ada drama di kategori ini.",
|
||||
"trendingTab": "Sedang Tren",
|
||||
"liveActionTab": "Live Action",
|
||||
"search": "Cari",
|
||||
"free": "Gratis",
|
||||
"mute": "Bisukan",
|
||||
"unmute": "Nyalakan suara",
|
||||
"like": "Suka",
|
||||
"save": "Simpan",
|
||||
"comments": "Komentar",
|
||||
"share": "Bagikan",
|
||||
"localOnlyComments": "Hanya terlihat di perangkat ini",
|
||||
"noComments": "Belum ada komentar.",
|
||||
"writeComment": "Tulis komentar",
|
||||
"postComment": "Kirim",
|
||||
"shareFailed": "Tidak bisa membuka menu berbagi.",
|
||||
"nowPlaying": "Sedang diputar",
|
||||
"watchFullscreen": "Tonton mode lanskap",
|
||||
"exitFullscreen": "Keluar dari layar penuh",
|
||||
"pictureInPicture": "Gambar dalam gambar",
|
||||
"pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini",
|
||||
"originalTag": "Original",
|
||||
"dramaTag": "Drama pendek",
|
||||
"episodeLabel": "Episode {number}",
|
||||
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
|
||||
"exploreSeries": "Lihat serial",
|
||||
"episodeCount": "{count} EPISODE",
|
||||
"@episodeCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"technicalDemoCount": "PRATINJAU GRATIS · {count} EPISODE",
|
||||
"@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"episodes": "Episode",
|
||||
"watchEpisodeOne": "Tonton episode 1",
|
||||
"continueEpisode": "Lanjut episode {number}",
|
||||
"@continueEpisode": {"placeholders": {"number": {"type": "int"}}},
|
||||
"freeTestMedia": "Klip demo sama · gratis",
|
||||
"lockedStoreUnavailable": "Terkunci · toko belum tersedia",
|
||||
"freeTestClip": "Klip uji gratis",
|
||||
"locked": "Terkunci",
|
||||
"freeSamplesNotice": "Dua sampel gratis · pengaturan toko belum selesai",
|
||||
"demoTestMedia": "GRATIS · KLIP DEMO SAMA",
|
||||
"episodeProgress": "EPISODE {number} / {total}",
|
||||
"@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}},
|
||||
"testVideoNotice": "Video uji teknis buatan sendiri",
|
||||
"lockedTitle": "Episode {number} terkunci",
|
||||
"@lockedTitle": {"placeholders": {"number": {"type": "int"}}},
|
||||
"lockedBody": "Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.",
|
||||
"playbackError": "Klip ini tidak bisa diputar.",
|
||||
"catalogError": "Katalog gagal dimuat.",
|
||||
"detailError": "Daftar episode gagal dimuat.",
|
||||
"noMediaNotice": "Episode ini belum punya media yang bisa diputar.",
|
||||
"noStories": "Belum ada cerita.",
|
||||
"retry": "Coba lagi",
|
||||
"backToSeries": "Kembali ke serial",
|
||||
"nextEpisode": "Episode berikutnya",
|
||||
"pause": "Jeda",
|
||||
"play": "Putar",
|
||||
"seekVideo": "Geser posisi video",
|
||||
"seekFailed": "Gagal mengubah posisi putar.",
|
||||
"captions": "Subtitle",
|
||||
"captionsOff": "Subtitle nonaktif",
|
||||
"englishCaptions": "Subtitle bahasa Inggris",
|
||||
"chineseCaptions": "Subtitle bahasa Mandarin",
|
||||
"language": "Bahasa",
|
||||
"systemLanguage": "Sistem",
|
||||
"english": "English",
|
||||
"simplifiedChinese": "简体中文",
|
||||
"episodeOneTitle": "Cahaya Pertama",
|
||||
"episodeTwoTitle": "Frekuensi Baru",
|
||||
"episodeThreeTitle": "Menyeberang",
|
||||
"episodeFourTitle": "Setelah Gema",
|
||||
"dramaSignalTitle": "Sinyal",
|
||||
"dramaSignalSynopsis": "Serial demo untuk tontonan vertikal. Setiap episode memakai satu klip lokal yang sama.",
|
||||
"dramaCityTitle": "Cahaya Kota & Malam Beludru",
|
||||
"dramaCitySynopsis": "Serial romansa demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.",
|
||||
"dramaVengeanceTitle": "Badai Pembalasan",
|
||||
"dramaVengeanceSynopsis": "Serial aksi demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.",
|
||||
"dramaEchoesTitle": "Gema Takdir",
|
||||
"genreSuspenseMystery": "Thriller · Misteri",
|
||||
"genreUrbanRomance": "Romansa Kota",
|
||||
"genreActionThriller": "Aksi · Thriller",
|
||||
"genreDramaMystery": "Drama · Misteri",
|
||||
"continueWatchingHeading": "LANJUT TONTON",
|
||||
"preferencesHeading": "PREFERENSI",
|
||||
"brandMark": "FRUITSHORTS",
|
||||
"shareFooter": "FruitShorts",
|
||||
"shortsTab": "Drama Pendek",
|
||||
"myListTab": "Daftar Saya",
|
||||
"watchFull": "Tonton Penuh",
|
||||
"swipeForNext": "Geser ke atas untuk lanjut",
|
||||
"clearScreen": "Bersihkan layar",
|
||||
"exitClearScreen": "Keluar dari mode bersih",
|
||||
"logIn": "Masuk",
|
||||
"logInHint": "Simpan koin dan daftar kamu",
|
||||
"rewards": "Hadiah",
|
||||
"forYou": "Untuk Kamu",
|
||||
"hot": "Populer",
|
||||
"newest": "Terbaru",
|
||||
"favorite": "Favorit",
|
||||
"myAccount": "Akun saya",
|
||||
"coins": "Koin",
|
||||
"topUpWallet": "Isi Ulang Dompet",
|
||||
"vipMembership": "Keanggotaan VIP",
|
||||
"notActive": "Tidak Aktif",
|
||||
"joinVip": "Gabung VIP",
|
||||
"earnRewards": "Dapatkan Hadiah",
|
||||
"freeBonus": "Bonus Gratis",
|
||||
"watchHistory": "Riwayat Tontonan",
|
||||
"settings": "Pengaturan",
|
||||
"signOut": "Keluar",
|
||||
"mockUserName": "Vicky sheng",
|
||||
"mockUserId": "ID: M1CZWVYE",
|
||||
"mockNotice": "Hanya data contoh.",
|
||||
"topBadge": "TOP 1",
|
||||
"signIn": "Masuk",
|
||||
"signUp": "Daftar",
|
||||
"authSignInTitle": "Selamat datang kembali",
|
||||
"authSignUpTitle": "Buat akun kamu",
|
||||
"authSubtitle": "Masuk dengan email dan kata sandi.",
|
||||
"authEmailLabel": "Email",
|
||||
"authPasswordLabel": "Kata sandi",
|
||||
"authPasswordHint": "Minimal 8 karakter, berisi huruf dan angka",
|
||||
"authEmailRequired": "Masukkan email kamu",
|
||||
"authEmailInvalid": "Email itu sepertinya tidak benar",
|
||||
"authPasswordRequired": "Masukkan kata sandi kamu",
|
||||
"authPasswordLength": "Kata sandi harus 8-64 karakter",
|
||||
"authPasswordWeak": "Kata sandi perlu huruf dan angka",
|
||||
"authNetworkError": "Jaringan bermasalah, coba lagi",
|
||||
"authSwitchToSignUp": "Baru di sini? Buat akun",
|
||||
"authSwitchToSignIn": "Sudah punya akun? Masuk",
|
||||
"authGateTitle": "Masuk untuk melanjutkan",
|
||||
"authGateMessage": "Favorit dan komentar perlu akun.",
|
||||
"authGateAction": "Masuk",
|
||||
"cancel": "Nanti saja"
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -8,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations_en.dart';
|
||||
import 'app_localizations_id.dart';
|
||||
import 'app_localizations_zh.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -97,13 +96,14 @@ abstract class AppLocalizations {
|
||||
/// A list of this localizations delegate's supported locales.
|
||||
static const List<Locale> supportedLocales = <Locale>[
|
||||
Locale('en'),
|
||||
Locale('id'),
|
||||
Locale('zh'),
|
||||
];
|
||||
|
||||
/// No description provided for @appName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nontonki'**
|
||||
/// **'FruitShorts'**
|
||||
String get appName;
|
||||
|
||||
/// No description provided for @demo.
|
||||
@@ -634,6 +634,18 @@ abstract class AppLocalizations {
|
||||
/// **'The catalog could not be loaded.'**
|
||||
String get catalogError;
|
||||
|
||||
/// No description provided for @detailError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The episode list could not be loaded.'**
|
||||
String get detailError;
|
||||
|
||||
/// No description provided for @noMediaNotice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This episode has no playable media yet.'**
|
||||
String get noMediaNotice;
|
||||
|
||||
/// No description provided for @noStories.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -835,14 +847,302 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @brandMark.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'NONTONKI · DEMO'**
|
||||
/// **'FRUITSHORTS'**
|
||||
String get brandMark;
|
||||
|
||||
/// No description provided for @shareFooter.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nontonki demo'**
|
||||
/// **'FruitShorts'**
|
||||
String get shareFooter;
|
||||
|
||||
/// No description provided for @shortsTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shorts'**
|
||||
String get shortsTab;
|
||||
|
||||
/// No description provided for @myListTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'My List'**
|
||||
String get myListTab;
|
||||
|
||||
/// No description provided for @watchFull.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Watch Full'**
|
||||
String get watchFull;
|
||||
|
||||
/// No description provided for @swipeForNext.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Swipe up for next'**
|
||||
String get swipeForNext;
|
||||
|
||||
/// No description provided for @clearScreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear screen'**
|
||||
String get clearScreen;
|
||||
|
||||
/// No description provided for @exitClearScreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exit clear screen'**
|
||||
String get exitClearScreen;
|
||||
|
||||
/// No description provided for @logIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Log in'**
|
||||
String get logIn;
|
||||
|
||||
/// No description provided for @logInHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep your coins and list'**
|
||||
String get logInHint;
|
||||
|
||||
/// No description provided for @rewards.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rewards'**
|
||||
String get rewards;
|
||||
|
||||
/// No description provided for @forYou.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'For You'**
|
||||
String get forYou;
|
||||
|
||||
/// No description provided for @hot.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hot'**
|
||||
String get hot;
|
||||
|
||||
/// No description provided for @newest.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New'**
|
||||
String get newest;
|
||||
|
||||
/// No description provided for @favorite.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Favorite'**
|
||||
String get favorite;
|
||||
|
||||
/// No description provided for @myAccount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'My account'**
|
||||
String get myAccount;
|
||||
|
||||
/// No description provided for @coins.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Coins'**
|
||||
String get coins;
|
||||
|
||||
/// No description provided for @topUpWallet.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Top Up Wallet'**
|
||||
String get topUpWallet;
|
||||
|
||||
/// No description provided for @vipMembership.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'VIP Membership'**
|
||||
String get vipMembership;
|
||||
|
||||
/// No description provided for @notActive.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not Active'**
|
||||
String get notActive;
|
||||
|
||||
/// No description provided for @joinVip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Join VIP'**
|
||||
String get joinVip;
|
||||
|
||||
/// No description provided for @earnRewards.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Earn Rewards'**
|
||||
String get earnRewards;
|
||||
|
||||
/// No description provided for @freeBonus.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Get Free Bonus'**
|
||||
String get freeBonus;
|
||||
|
||||
/// No description provided for @watchHistory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Watch History'**
|
||||
String get watchHistory;
|
||||
|
||||
/// No description provided for @settings.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Settings'**
|
||||
String get settings;
|
||||
|
||||
/// No description provided for @signOut.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign Out'**
|
||||
String get signOut;
|
||||
|
||||
/// No description provided for @mockUserName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Vicky sheng'**
|
||||
String get mockUserName;
|
||||
|
||||
/// No description provided for @mockUserId.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ID: M1CZWVYE'**
|
||||
String get mockUserId;
|
||||
|
||||
/// No description provided for @mockNotice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sample data only.'**
|
||||
String get mockNotice;
|
||||
|
||||
/// No description provided for @topBadge.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'TOP 1'**
|
||||
String get topBadge;
|
||||
|
||||
/// No description provided for @signIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign In'**
|
||||
String get signIn;
|
||||
|
||||
/// No description provided for @signUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign Up'**
|
||||
String get signUp;
|
||||
|
||||
/// No description provided for @authSignInTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Welcome back'**
|
||||
String get authSignInTitle;
|
||||
|
||||
/// No description provided for @authSignUpTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create your account'**
|
||||
String get authSignUpTitle;
|
||||
|
||||
/// No description provided for @authSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in with your email and password.'**
|
||||
String get authSubtitle;
|
||||
|
||||
/// No description provided for @authEmailLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Email'**
|
||||
String get authEmailLabel;
|
||||
|
||||
/// No description provided for @authPasswordLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Password'**
|
||||
String get authPasswordLabel;
|
||||
|
||||
/// No description provided for @authPasswordHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'At least 8 characters, letters and numbers'**
|
||||
String get authPasswordHint;
|
||||
|
||||
/// No description provided for @authEmailRequired.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter your email'**
|
||||
String get authEmailRequired;
|
||||
|
||||
/// No description provided for @authEmailInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'That email doesn\'t look right'**
|
||||
String get authEmailInvalid;
|
||||
|
||||
/// No description provided for @authPasswordRequired.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter your password'**
|
||||
String get authPasswordRequired;
|
||||
|
||||
/// No description provided for @authPasswordLength.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Password must be 8-64 characters'**
|
||||
String get authPasswordLength;
|
||||
|
||||
/// No description provided for @authPasswordWeak.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Password needs both letters and numbers'**
|
||||
String get authPasswordWeak;
|
||||
|
||||
/// No description provided for @authNetworkError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Network error, please try again'**
|
||||
String get authNetworkError;
|
||||
|
||||
/// No description provided for @authSwitchToSignUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New here? Create an account'**
|
||||
String get authSwitchToSignUp;
|
||||
|
||||
/// No description provided for @authSwitchToSignIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Already have an account? Sign in'**
|
||||
String get authSwitchToSignIn;
|
||||
|
||||
/// No description provided for @authGateTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in to continue'**
|
||||
String get authGateTitle;
|
||||
|
||||
/// No description provided for @authGateMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Favorites and comments need an account.'**
|
||||
String get authGateMessage;
|
||||
|
||||
/// No description provided for @authGateAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign In'**
|
||||
String get authGateAction;
|
||||
|
||||
/// No description provided for @cancel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not now'**
|
||||
String get cancel;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
@@ -856,7 +1156,7 @@ class _AppLocalizationsDelegate
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) =>
|
||||
<String>['en', 'zh'].contains(locale.languageCode);
|
||||
<String>['en', 'id', 'zh'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
||||
@@ -867,6 +1167,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
switch (locale.languageCode) {
|
||||
case 'en':
|
||||
return AppLocalizationsEn();
|
||||
case 'id':
|
||||
return AppLocalizationsId();
|
||||
case 'zh':
|
||||
return AppLocalizationsZh();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
@@ -11,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'Nontonki';
|
||||
String get appName => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get demo => 'DEMO';
|
||||
@@ -296,6 +294,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get catalogError => 'The catalog could not be loaded.';
|
||||
|
||||
@override
|
||||
String get detailError => 'The episode list could not be loaded.';
|
||||
|
||||
@override
|
||||
String get noMediaNotice => 'This episode has no playable media yet.';
|
||||
|
||||
@override
|
||||
String get noStories => 'No stories available yet.';
|
||||
|
||||
@@ -399,8 +403,152 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get preferencesHeading => 'PREFERENCES';
|
||||
|
||||
@override
|
||||
String get brandMark => 'NONTONKI · DEMO';
|
||||
String get brandMark => 'FRUITSHORTS';
|
||||
|
||||
@override
|
||||
String get shareFooter => 'Nontonki demo';
|
||||
String get shareFooter => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get shortsTab => 'Shorts';
|
||||
|
||||
@override
|
||||
String get myListTab => 'My List';
|
||||
|
||||
@override
|
||||
String get watchFull => 'Watch Full';
|
||||
|
||||
@override
|
||||
String get swipeForNext => 'Swipe up for next';
|
||||
|
||||
@override
|
||||
String get clearScreen => 'Clear screen';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => 'Exit clear screen';
|
||||
|
||||
@override
|
||||
String get logIn => 'Log in';
|
||||
|
||||
@override
|
||||
String get logInHint => 'Keep your coins and list';
|
||||
|
||||
@override
|
||||
String get rewards => 'Rewards';
|
||||
|
||||
@override
|
||||
String get forYou => 'For You';
|
||||
|
||||
@override
|
||||
String get hot => 'Hot';
|
||||
|
||||
@override
|
||||
String get newest => 'New';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get myAccount => 'My account';
|
||||
|
||||
@override
|
||||
String get coins => 'Coins';
|
||||
|
||||
@override
|
||||
String get topUpWallet => 'Top Up Wallet';
|
||||
|
||||
@override
|
||||
String get vipMembership => 'VIP Membership';
|
||||
|
||||
@override
|
||||
String get notActive => 'Not Active';
|
||||
|
||||
@override
|
||||
String get joinVip => 'Join VIP';
|
||||
|
||||
@override
|
||||
String get earnRewards => 'Earn Rewards';
|
||||
|
||||
@override
|
||||
String get freeBonus => 'Get Free Bonus';
|
||||
|
||||
@override
|
||||
String get watchHistory => 'Watch History';
|
||||
|
||||
@override
|
||||
String get settings => 'Settings';
|
||||
|
||||
@override
|
||||
String get signOut => 'Sign Out';
|
||||
|
||||
@override
|
||||
String get mockUserName => 'Vicky sheng';
|
||||
|
||||
@override
|
||||
String get mockUserId => 'ID: M1CZWVYE';
|
||||
|
||||
@override
|
||||
String get mockNotice => 'Sample data only.';
|
||||
|
||||
@override
|
||||
String get topBadge => 'TOP 1';
|
||||
|
||||
@override
|
||||
String get signIn => 'Sign In';
|
||||
|
||||
@override
|
||||
String get signUp => 'Sign Up';
|
||||
|
||||
@override
|
||||
String get authSignInTitle => 'Welcome back';
|
||||
|
||||
@override
|
||||
String get authSignUpTitle => 'Create your account';
|
||||
|
||||
@override
|
||||
String get authSubtitle => 'Sign in with your email and password.';
|
||||
|
||||
@override
|
||||
String get authEmailLabel => 'Email';
|
||||
|
||||
@override
|
||||
String get authPasswordLabel => 'Password';
|
||||
|
||||
@override
|
||||
String get authPasswordHint => 'At least 8 characters, letters and numbers';
|
||||
|
||||
@override
|
||||
String get authEmailRequired => 'Enter your email';
|
||||
|
||||
@override
|
||||
String get authEmailInvalid => 'That email doesn\'t look right';
|
||||
|
||||
@override
|
||||
String get authPasswordRequired => 'Enter your password';
|
||||
|
||||
@override
|
||||
String get authPasswordLength => 'Password must be 8-64 characters';
|
||||
|
||||
@override
|
||||
String get authPasswordWeak => 'Password needs both letters and numbers';
|
||||
|
||||
@override
|
||||
String get authNetworkError => 'Network error, please try again';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignUp => 'New here? Create an account';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignIn => 'Already have an account? Sign in';
|
||||
|
||||
@override
|
||||
String get authGateTitle => 'Sign in to continue';
|
||||
|
||||
@override
|
||||
String get authGateMessage => 'Favorites and comments need an account.';
|
||||
|
||||
@override
|
||||
String get authGateAction => 'Sign In';
|
||||
|
||||
@override
|
||||
String get cancel => 'Not now';
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for Indonesian (`id`).
|
||||
class AppLocalizationsId extends AppLocalizations {
|
||||
AppLocalizationsId([String locale = 'id']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get demo => 'Demo';
|
||||
|
||||
@override
|
||||
String get homeHeadline => 'Tonton sekarang';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Pratinjau drama pendek gratis.';
|
||||
|
||||
@override
|
||||
String get mediaNotice => 'Video demo lokal dari pengguna · Pratinjau gratis';
|
||||
|
||||
@override
|
||||
String get featuredDemo => 'UNGGULAN · DEMO TEKNIS';
|
||||
|
||||
@override
|
||||
String get demoTitle => 'Sinyal';
|
||||
|
||||
@override
|
||||
String get demoSynopsis =>
|
||||
'Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.';
|
||||
|
||||
@override
|
||||
String get homeTab => 'Beranda';
|
||||
|
||||
@override
|
||||
String get openQuickMenu => 'Buka menu cepat';
|
||||
|
||||
@override
|
||||
String get quickAccess => 'Akses cepat';
|
||||
|
||||
@override
|
||||
String get currentSeries => 'Serial saat ini';
|
||||
|
||||
@override
|
||||
String get discoverTab => 'Jelajahi';
|
||||
|
||||
@override
|
||||
String get searchDiscover => 'Cari drama, aktor, genre...';
|
||||
|
||||
@override
|
||||
String get featured => 'Unggulan';
|
||||
|
||||
@override
|
||||
String get suspense => 'Thriller';
|
||||
|
||||
@override
|
||||
String get romance => 'Romantis';
|
||||
|
||||
@override
|
||||
String get actionGenre => 'Aksi';
|
||||
|
||||
@override
|
||||
String get filter => 'Filter';
|
||||
|
||||
@override
|
||||
String get topCharts => 'Peringkat Teratas';
|
||||
|
||||
@override
|
||||
String get newReleases => 'Rilisan Baru';
|
||||
|
||||
@override
|
||||
String get trendingMicroDramas => 'Drama Mini Populer';
|
||||
|
||||
@override
|
||||
String get seeAll => 'Lihat Semua';
|
||||
|
||||
@override
|
||||
String get artworkOnly => 'Gambar';
|
||||
|
||||
@override
|
||||
String get artworkOnlyNotice =>
|
||||
'Ini hanya gambar katalog. Videonya belum tersedia.';
|
||||
|
||||
@override
|
||||
String get videoPending => 'Video belum siap';
|
||||
|
||||
@override
|
||||
String get watchNow => 'Tonton gratis';
|
||||
|
||||
@override
|
||||
String get noSearchResults => 'Tidak ada drama yang cocok.';
|
||||
|
||||
@override
|
||||
String get demoCatalogNotice =>
|
||||
'Tiga drama demo memakai video lokal yang sama.';
|
||||
|
||||
@override
|
||||
String get myTab => 'Saya';
|
||||
|
||||
@override
|
||||
String get localViewer => 'Penonton Lokal';
|
||||
|
||||
@override
|
||||
String get localDemoProfile => 'Profil demo lokal';
|
||||
|
||||
@override
|
||||
String get continueWatching => 'Lanjut Tonton';
|
||||
|
||||
@override
|
||||
String watchedPercent(int percent) {
|
||||
return 'Sudah ditonton $percent%';
|
||||
}
|
||||
|
||||
@override
|
||||
String get resume => 'Lanjutkan';
|
||||
|
||||
@override
|
||||
String get history => 'Riwayat';
|
||||
|
||||
@override
|
||||
String get watchlist => 'Daftar Tonton';
|
||||
|
||||
@override
|
||||
String get likedTab => 'Disukai';
|
||||
|
||||
@override
|
||||
String get all => 'Semua';
|
||||
|
||||
@override
|
||||
String get inProgress => 'Sedang Berjalan';
|
||||
|
||||
@override
|
||||
String get completed => 'Selesai';
|
||||
|
||||
@override
|
||||
String get noSavedShows => 'Belum ada tontonan di sini.';
|
||||
|
||||
@override
|
||||
String get preferences => 'Preferensi';
|
||||
|
||||
@override
|
||||
String get appLanguage => 'Bahasa Aplikasi';
|
||||
|
||||
@override
|
||||
String get followingTab => 'Mengikuti';
|
||||
|
||||
@override
|
||||
String get animatedTab => 'Animasi';
|
||||
|
||||
@override
|
||||
String get recommendedTab => 'Rekomendasi';
|
||||
|
||||
@override
|
||||
String get noCategoryDramas => 'Belum ada drama di kategori ini.';
|
||||
|
||||
@override
|
||||
String get trendingTab => 'Sedang Tren';
|
||||
|
||||
@override
|
||||
String get liveActionTab => 'Live Action';
|
||||
|
||||
@override
|
||||
String get search => 'Cari';
|
||||
|
||||
@override
|
||||
String get free => 'Gratis';
|
||||
|
||||
@override
|
||||
String get mute => 'Bisukan';
|
||||
|
||||
@override
|
||||
String get unmute => 'Nyalakan suara';
|
||||
|
||||
@override
|
||||
String get like => 'Suka';
|
||||
|
||||
@override
|
||||
String get save => 'Simpan';
|
||||
|
||||
@override
|
||||
String get comments => 'Komentar';
|
||||
|
||||
@override
|
||||
String get share => 'Bagikan';
|
||||
|
||||
@override
|
||||
String get localOnlyComments => 'Hanya terlihat di perangkat ini';
|
||||
|
||||
@override
|
||||
String get noComments => 'Belum ada komentar.';
|
||||
|
||||
@override
|
||||
String get writeComment => 'Tulis komentar';
|
||||
|
||||
@override
|
||||
String get postComment => 'Kirim';
|
||||
|
||||
@override
|
||||
String get shareFailed => 'Tidak bisa membuka menu berbagi.';
|
||||
|
||||
@override
|
||||
String get nowPlaying => 'Sedang diputar';
|
||||
|
||||
@override
|
||||
String get watchFullscreen => 'Tonton mode lanskap';
|
||||
|
||||
@override
|
||||
String get exitFullscreen => 'Keluar dari layar penuh';
|
||||
|
||||
@override
|
||||
String get pictureInPicture => 'Gambar dalam gambar';
|
||||
|
||||
@override
|
||||
String get pipUnavailable =>
|
||||
'Gambar dalam gambar tidak tersedia di perangkat ini';
|
||||
|
||||
@override
|
||||
String get originalTag => 'Original';
|
||||
|
||||
@override
|
||||
String get dramaTag => 'Drama pendek';
|
||||
|
||||
@override
|
||||
String episodeLabel(int number) {
|
||||
return 'Episode $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get exploreSeries => 'Lihat serial';
|
||||
|
||||
@override
|
||||
String episodeCount(int count) {
|
||||
return '$count EPISODE';
|
||||
}
|
||||
|
||||
@override
|
||||
String technicalDemoCount(int count) {
|
||||
return 'PRATINJAU GRATIS · $count EPISODE';
|
||||
}
|
||||
|
||||
@override
|
||||
String get episodes => 'Episode';
|
||||
|
||||
@override
|
||||
String get watchEpisodeOne => 'Tonton episode 1';
|
||||
|
||||
@override
|
||||
String continueEpisode(int number) {
|
||||
return 'Lanjut episode $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get freeTestMedia => 'Klip demo sama · gratis';
|
||||
|
||||
@override
|
||||
String get lockedStoreUnavailable => 'Terkunci · toko belum tersedia';
|
||||
|
||||
@override
|
||||
String get freeTestClip => 'Klip uji gratis';
|
||||
|
||||
@override
|
||||
String get locked => 'Terkunci';
|
||||
|
||||
@override
|
||||
String get freeSamplesNotice =>
|
||||
'Dua sampel gratis · pengaturan toko belum selesai';
|
||||
|
||||
@override
|
||||
String get demoTestMedia => 'GRATIS · KLIP DEMO SAMA';
|
||||
|
||||
@override
|
||||
String episodeProgress(int number, int total) {
|
||||
return 'EPISODE $number / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get testVideoNotice => 'Video uji teknis buatan sendiri';
|
||||
|
||||
@override
|
||||
String lockedTitle(int number) {
|
||||
return 'Episode $number terkunci';
|
||||
}
|
||||
|
||||
@override
|
||||
String get lockedBody =>
|
||||
'Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.';
|
||||
|
||||
@override
|
||||
String get playbackError => 'Klip ini tidak bisa diputar.';
|
||||
|
||||
@override
|
||||
String get catalogError => 'Katalog gagal dimuat.';
|
||||
|
||||
@override
|
||||
String get detailError => 'Daftar episode gagal dimuat.';
|
||||
|
||||
@override
|
||||
String get noMediaNotice =>
|
||||
'Episode ini belum punya media yang bisa diputar.';
|
||||
|
||||
@override
|
||||
String get noStories => 'Belum ada cerita.';
|
||||
|
||||
@override
|
||||
String get retry => 'Coba lagi';
|
||||
|
||||
@override
|
||||
String get backToSeries => 'Kembali ke serial';
|
||||
|
||||
@override
|
||||
String get nextEpisode => 'Episode berikutnya';
|
||||
|
||||
@override
|
||||
String get pause => 'Jeda';
|
||||
|
||||
@override
|
||||
String get play => 'Putar';
|
||||
|
||||
@override
|
||||
String get seekVideo => 'Geser posisi video';
|
||||
|
||||
@override
|
||||
String get seekFailed => 'Gagal mengubah posisi putar.';
|
||||
|
||||
@override
|
||||
String get captions => 'Subtitle';
|
||||
|
||||
@override
|
||||
String get captionsOff => 'Subtitle nonaktif';
|
||||
|
||||
@override
|
||||
String get englishCaptions => 'Subtitle bahasa Inggris';
|
||||
|
||||
@override
|
||||
String get chineseCaptions => 'Subtitle bahasa Mandarin';
|
||||
|
||||
@override
|
||||
String get language => 'Bahasa';
|
||||
|
||||
@override
|
||||
String get systemLanguage => 'Sistem';
|
||||
|
||||
@override
|
||||
String get english => 'English';
|
||||
|
||||
@override
|
||||
String get simplifiedChinese => '简体中文';
|
||||
|
||||
@override
|
||||
String get episodeOneTitle => 'Cahaya Pertama';
|
||||
|
||||
@override
|
||||
String get episodeTwoTitle => 'Frekuensi Baru';
|
||||
|
||||
@override
|
||||
String get episodeThreeTitle => 'Menyeberang';
|
||||
|
||||
@override
|
||||
String get episodeFourTitle => 'Setelah Gema';
|
||||
|
||||
@override
|
||||
String get dramaSignalTitle => 'Sinyal';
|
||||
|
||||
@override
|
||||
String get dramaSignalSynopsis =>
|
||||
'Serial demo untuk tontonan vertikal. Setiap episode memakai satu klip lokal yang sama.';
|
||||
|
||||
@override
|
||||
String get dramaCityTitle => 'Cahaya Kota & Malam Beludru';
|
||||
|
||||
@override
|
||||
String get dramaCitySynopsis =>
|
||||
'Serial romansa demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.';
|
||||
|
||||
@override
|
||||
String get dramaVengeanceTitle => 'Badai Pembalasan';
|
||||
|
||||
@override
|
||||
String get dramaVengeanceSynopsis =>
|
||||
'Serial aksi demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.';
|
||||
|
||||
@override
|
||||
String get dramaEchoesTitle => 'Gema Takdir';
|
||||
|
||||
@override
|
||||
String get genreSuspenseMystery => 'Thriller · Misteri';
|
||||
|
||||
@override
|
||||
String get genreUrbanRomance => 'Romansa Kota';
|
||||
|
||||
@override
|
||||
String get genreActionThriller => 'Aksi · Thriller';
|
||||
|
||||
@override
|
||||
String get genreDramaMystery => 'Drama · Misteri';
|
||||
|
||||
@override
|
||||
String get continueWatchingHeading => 'LANJUT TONTON';
|
||||
|
||||
@override
|
||||
String get preferencesHeading => 'PREFERENSI';
|
||||
|
||||
@override
|
||||
String get brandMark => 'FRUITSHORTS';
|
||||
|
||||
@override
|
||||
String get shareFooter => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get shortsTab => 'Drama Pendek';
|
||||
|
||||
@override
|
||||
String get myListTab => 'Daftar Saya';
|
||||
|
||||
@override
|
||||
String get watchFull => 'Tonton Penuh';
|
||||
|
||||
@override
|
||||
String get swipeForNext => 'Geser ke atas untuk lanjut';
|
||||
|
||||
@override
|
||||
String get clearScreen => 'Bersihkan layar';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => 'Keluar dari mode bersih';
|
||||
|
||||
@override
|
||||
String get logIn => 'Masuk';
|
||||
|
||||
@override
|
||||
String get logInHint => 'Simpan koin dan daftar kamu';
|
||||
|
||||
@override
|
||||
String get rewards => 'Hadiah';
|
||||
|
||||
@override
|
||||
String get forYou => 'Untuk Kamu';
|
||||
|
||||
@override
|
||||
String get hot => 'Populer';
|
||||
|
||||
@override
|
||||
String get newest => 'Terbaru';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorit';
|
||||
|
||||
@override
|
||||
String get myAccount => 'Akun saya';
|
||||
|
||||
@override
|
||||
String get coins => 'Koin';
|
||||
|
||||
@override
|
||||
String get topUpWallet => 'Isi Ulang Dompet';
|
||||
|
||||
@override
|
||||
String get vipMembership => 'Keanggotaan VIP';
|
||||
|
||||
@override
|
||||
String get notActive => 'Tidak Aktif';
|
||||
|
||||
@override
|
||||
String get joinVip => 'Gabung VIP';
|
||||
|
||||
@override
|
||||
String get earnRewards => 'Dapatkan Hadiah';
|
||||
|
||||
@override
|
||||
String get freeBonus => 'Bonus Gratis';
|
||||
|
||||
@override
|
||||
String get watchHistory => 'Riwayat Tontonan';
|
||||
|
||||
@override
|
||||
String get settings => 'Pengaturan';
|
||||
|
||||
@override
|
||||
String get signOut => 'Keluar';
|
||||
|
||||
@override
|
||||
String get mockUserName => 'Vicky sheng';
|
||||
|
||||
@override
|
||||
String get mockUserId => 'ID: M1CZWVYE';
|
||||
|
||||
@override
|
||||
String get mockNotice => 'Hanya data contoh.';
|
||||
|
||||
@override
|
||||
String get topBadge => 'TOP 1';
|
||||
|
||||
@override
|
||||
String get signIn => 'Masuk';
|
||||
|
||||
@override
|
||||
String get signUp => 'Daftar';
|
||||
|
||||
@override
|
||||
String get authSignInTitle => 'Selamat datang kembali';
|
||||
|
||||
@override
|
||||
String get authSignUpTitle => 'Buat akun kamu';
|
||||
|
||||
@override
|
||||
String get authSubtitle => 'Masuk dengan email dan kata sandi.';
|
||||
|
||||
@override
|
||||
String get authEmailLabel => 'Email';
|
||||
|
||||
@override
|
||||
String get authPasswordLabel => 'Kata sandi';
|
||||
|
||||
@override
|
||||
String get authPasswordHint => 'Minimal 8 karakter, berisi huruf dan angka';
|
||||
|
||||
@override
|
||||
String get authEmailRequired => 'Masukkan email kamu';
|
||||
|
||||
@override
|
||||
String get authEmailInvalid => 'Email itu sepertinya tidak benar';
|
||||
|
||||
@override
|
||||
String get authPasswordRequired => 'Masukkan kata sandi kamu';
|
||||
|
||||
@override
|
||||
String get authPasswordLength => 'Kata sandi harus 8-64 karakter';
|
||||
|
||||
@override
|
||||
String get authPasswordWeak => 'Kata sandi perlu huruf dan angka';
|
||||
|
||||
@override
|
||||
String get authNetworkError => 'Jaringan bermasalah, coba lagi';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignUp => 'Baru di sini? Buat akun';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignIn => 'Sudah punya akun? Masuk';
|
||||
|
||||
@override
|
||||
String get authGateTitle => 'Masuk untuk melanjutkan';
|
||||
|
||||
@override
|
||||
String get authGateMessage => 'Favorit dan komentar perlu akun.';
|
||||
|
||||
@override
|
||||
String get authGateAction => 'Masuk';
|
||||
|
||||
@override
|
||||
String get cancel => 'Nanti saja';
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
@@ -11,7 +9,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
AppLocalizationsZh([String locale = 'zh']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'Nontonki';
|
||||
String get appName => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get demo => '演示';
|
||||
@@ -291,6 +289,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get catalogError => '剧目列表加载失败。';
|
||||
|
||||
@override
|
||||
String get detailError => '剧集信息加载失败。';
|
||||
|
||||
@override
|
||||
String get noMediaNotice => '该剧集还没有可播放的片源。';
|
||||
|
||||
@override
|
||||
String get noStories => '暂无剧目。';
|
||||
|
||||
@@ -391,8 +395,152 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get preferencesHeading => '偏好设置';
|
||||
|
||||
@override
|
||||
String get brandMark => 'NONTONKI · 演示';
|
||||
String get brandMark => 'FRUITSHORTS';
|
||||
|
||||
@override
|
||||
String get shareFooter => 'Nontonki 演示';
|
||||
String get shareFooter => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get shortsTab => '短剧';
|
||||
|
||||
@override
|
||||
String get myListTab => '片单';
|
||||
|
||||
@override
|
||||
String get watchFull => '看全集';
|
||||
|
||||
@override
|
||||
String get swipeForNext => '上滑看下一部';
|
||||
|
||||
@override
|
||||
String get clearScreen => '清屏';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => '退出清屏';
|
||||
|
||||
@override
|
||||
String get logIn => '登录';
|
||||
|
||||
@override
|
||||
String get logInHint => '登录后保存金币和片单';
|
||||
|
||||
@override
|
||||
String get rewards => '奖励';
|
||||
|
||||
@override
|
||||
String get forYou => '为你推荐';
|
||||
|
||||
@override
|
||||
String get hot => '热门';
|
||||
|
||||
@override
|
||||
String get newest => '最新';
|
||||
|
||||
@override
|
||||
String get favorite => '收藏';
|
||||
|
||||
@override
|
||||
String get myAccount => '我的账户';
|
||||
|
||||
@override
|
||||
String get coins => '金币';
|
||||
|
||||
@override
|
||||
String get topUpWallet => '充值';
|
||||
|
||||
@override
|
||||
String get vipMembership => '会员';
|
||||
|
||||
@override
|
||||
String get notActive => '未开通';
|
||||
|
||||
@override
|
||||
String get joinVip => '开通会员';
|
||||
|
||||
@override
|
||||
String get earnRewards => '赚奖励';
|
||||
|
||||
@override
|
||||
String get freeBonus => '领免费奖励';
|
||||
|
||||
@override
|
||||
String get watchHistory => '观看历史';
|
||||
|
||||
@override
|
||||
String get settings => '设置';
|
||||
|
||||
@override
|
||||
String get signOut => '退出登录';
|
||||
|
||||
@override
|
||||
String get mockUserName => 'Vicky sheng';
|
||||
|
||||
@override
|
||||
String get mockUserId => 'ID: M1CZWVYE';
|
||||
|
||||
@override
|
||||
String get mockNotice => '当前是示例数据。';
|
||||
|
||||
@override
|
||||
String get topBadge => 'TOP 1';
|
||||
|
||||
@override
|
||||
String get signIn => '登录';
|
||||
|
||||
@override
|
||||
String get signUp => '注册';
|
||||
|
||||
@override
|
||||
String get authSignInTitle => '欢迎回来';
|
||||
|
||||
@override
|
||||
String get authSignUpTitle => '创建账号';
|
||||
|
||||
@override
|
||||
String get authSubtitle => '用邮箱和密码登录。';
|
||||
|
||||
@override
|
||||
String get authEmailLabel => '邮箱';
|
||||
|
||||
@override
|
||||
String get authPasswordLabel => '密码';
|
||||
|
||||
@override
|
||||
String get authPasswordHint => '至少 8 位,需包含字母和数字';
|
||||
|
||||
@override
|
||||
String get authEmailRequired => '请输入邮箱';
|
||||
|
||||
@override
|
||||
String get authEmailInvalid => '邮箱格式不正确';
|
||||
|
||||
@override
|
||||
String get authPasswordRequired => '请输入密码';
|
||||
|
||||
@override
|
||||
String get authPasswordLength => '密码长度需在 8-64 位之间';
|
||||
|
||||
@override
|
||||
String get authPasswordWeak => '密码需同时包含字母和数字';
|
||||
|
||||
@override
|
||||
String get authNetworkError => '网络异常,请稍后重试';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignUp => '还没有账号?去注册';
|
||||
|
||||
@override
|
||||
String get authSwitchToSignIn => '已有账号?去登录';
|
||||
|
||||
@override
|
||||
String get authGateTitle => '登录后可继续';
|
||||
|
||||
@override
|
||||
String get authGateMessage => '收藏、评论等操作需要登录。';
|
||||
|
||||
@override
|
||||
String get authGateAction => '去登录';
|
||||
|
||||
@override
|
||||
String get cancel => '暂不';
|
||||
}
|
||||
+53
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"@@locale": "zh",
|
||||
"appName": "Nontonki",
|
||||
"appName": "FruitShorts",
|
||||
"demo": "演示",
|
||||
"homeHeadline": "即刻观看",
|
||||
"homeSubtitle": "免费短剧预览。",
|
||||
@@ -91,6 +91,8 @@
|
||||
"lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。",
|
||||
"playbackError": "此视频暂时无法播放。",
|
||||
"catalogError": "剧目列表加载失败。",
|
||||
"detailError": "剧集信息加载失败。",
|
||||
"noMediaNotice": "该剧集还没有可播放的片源。",
|
||||
"noStories": "暂无剧目。",
|
||||
"retry": "重试",
|
||||
"backToSeries": "返回剧集详情",
|
||||
@@ -124,6 +126,54 @@
|
||||
"genreDramaMystery": "剧情 · 悬疑",
|
||||
"continueWatchingHeading": "继续观看",
|
||||
"preferencesHeading": "偏好设置",
|
||||
"brandMark": "NONTONKI · 演示",
|
||||
"shareFooter": "Nontonki 演示"
|
||||
"brandMark": "FRUITSHORTS",
|
||||
"shareFooter": "FruitShorts",
|
||||
"shortsTab": "短剧",
|
||||
"myListTab": "片单",
|
||||
"watchFull": "看全集",
|
||||
"swipeForNext": "上滑看下一部",
|
||||
"clearScreen": "清屏",
|
||||
"exitClearScreen": "退出清屏",
|
||||
"logIn": "登录",
|
||||
"logInHint": "登录后保存金币和片单",
|
||||
"rewards": "奖励",
|
||||
"forYou": "为你推荐",
|
||||
"hot": "热门",
|
||||
"newest": "最新",
|
||||
"favorite": "收藏",
|
||||
"myAccount": "我的账户",
|
||||
"coins": "金币",
|
||||
"topUpWallet": "充值",
|
||||
"vipMembership": "会员",
|
||||
"notActive": "未开通",
|
||||
"joinVip": "开通会员",
|
||||
"earnRewards": "赚奖励",
|
||||
"freeBonus": "领免费奖励",
|
||||
"watchHistory": "观看历史",
|
||||
"settings": "设置",
|
||||
"signOut": "退出登录",
|
||||
"mockUserName": "Vicky sheng",
|
||||
"mockUserId": "ID: M1CZWVYE",
|
||||
"mockNotice": "当前是示例数据。",
|
||||
"topBadge": "TOP 1",
|
||||
"signIn": "登录",
|
||||
"signUp": "注册",
|
||||
"authSignInTitle": "欢迎回来",
|
||||
"authSignUpTitle": "创建账号",
|
||||
"authSubtitle": "用邮箱和密码登录。",
|
||||
"authEmailLabel": "邮箱",
|
||||
"authPasswordLabel": "密码",
|
||||
"authPasswordHint": "至少 8 位,需包含字母和数字",
|
||||
"authEmailRequired": "请输入邮箱",
|
||||
"authEmailInvalid": "邮箱格式不正确",
|
||||
"authPasswordRequired": "请输入密码",
|
||||
"authPasswordLength": "密码长度需在 8-64 位之间",
|
||||
"authPasswordWeak": "密码需同时包含字母和数字",
|
||||
"authNetworkError": "网络异常,请稍后重试",
|
||||
"authSwitchToSignUp": "还没有账号?去注册",
|
||||
"authSwitchToSignIn": "已有账号?去登录",
|
||||
"authGateTitle": "登录后可继续",
|
||||
"authGateMessage": "收藏、评论等操作需要登录。",
|
||||
"authGateAction": "去登录",
|
||||
"cancel": "暂不"
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'app.dart';
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'auth_logic.dart';
|
||||
|
||||
/// 与其它页保持一致的三层写法:binding 负责装配,logic 放状态与逻辑,view 只渲染。
|
||||
class AuthBinding {
|
||||
AuthLogic create({bool signUpMode = false}) => AuthLogic(signUpMode: signUpMode);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/// 登录 / 注册页的表单状态与提交逻辑。
|
||||
///
|
||||
/// 校验规则与后端一致(邮箱格式、密码 8-64 位且含字母数字),本地先拦一次是为了少一次网络往返、
|
||||
/// 也让用户立刻看到哪个字段不合格;后端仍会再校验一遍(前端校验不作为安全边界)。
|
||||
class AuthLogic {
|
||||
bool signUpMode;
|
||||
String email = '';
|
||||
String password = '';
|
||||
bool busy = false;
|
||||
String? errorMessage;
|
||||
|
||||
AuthLogic({this.signUpMode = false, this.errorMessage});
|
||||
|
||||
void toggleMode() {
|
||||
signUpMode = !signUpMode;
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
/// 返回 null 表示校验通过,否则返回错误文案(由视图用 l10n 映射)
|
||||
AuthFieldError? validate() {
|
||||
final mail = email.trim();
|
||||
if (mail.isEmpty) return AuthFieldError.emailRequired;
|
||||
if (!_emailPattern.hasMatch(mail)) return AuthFieldError.emailInvalid;
|
||||
if (password.isEmpty) return AuthFieldError.passwordRequired;
|
||||
if (password.length < 8 || password.length > 64) {
|
||||
return AuthFieldError.passwordLength;
|
||||
}
|
||||
if (!_hasLetter.hasMatch(password) || !_hasDigit.hasMatch(password)) {
|
||||
return AuthFieldError.passwordWeak;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String get normalizedEmail => email.trim().toLowerCase();
|
||||
|
||||
static final _emailPattern = RegExp(
|
||||
r'^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?'
|
||||
r'(\.[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,}$',
|
||||
);
|
||||
static final _hasLetter = RegExp('[A-Za-z]');
|
||||
static final _hasDigit = RegExp('[0-9]');
|
||||
}
|
||||
|
||||
enum AuthFieldError {
|
||||
emailRequired,
|
||||
emailInvalid,
|
||||
passwordRequired,
|
||||
passwordLength,
|
||||
passwordWeak,
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'auth_logic.dart';
|
||||
|
||||
/// 登录 / 注册页:邮箱 + 密码。
|
||||
///
|
||||
/// 风格沿用 FruitShorts 现有页面(深色卡片 + 圆角 + 主色蓝),不另起一套视觉。
|
||||
class AuthPage extends StatefulWidget {
|
||||
const AuthPage({super.key, this.signUpMode = false});
|
||||
|
||||
final bool signUpMode;
|
||||
|
||||
@override
|
||||
State<AuthPage> createState() => _AuthPageState();
|
||||
}
|
||||
|
||||
class _AuthPageState extends State<AuthPage> {
|
||||
late AuthLogic _logic;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_logic = AuthLogic(signUpMode: widget.signUpMode);
|
||||
}
|
||||
|
||||
String _errorText(AppLocalizations l10n, AuthFieldError error) {
|
||||
switch (error) {
|
||||
case AuthFieldError.emailRequired:
|
||||
return l10n.authEmailRequired;
|
||||
case AuthFieldError.emailInvalid:
|
||||
return l10n.authEmailInvalid;
|
||||
case AuthFieldError.passwordRequired:
|
||||
return l10n.authPasswordRequired;
|
||||
case AuthFieldError.passwordLength:
|
||||
return l10n.authPasswordLength;
|
||||
case AuthFieldError.passwordWeak:
|
||||
return l10n.authPasswordWeak;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final invalid = _logic.validate();
|
||||
if (invalid != null) {
|
||||
setState(() => _logic.errorMessage = _errorText(l10n, invalid));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_logic.busy = true;
|
||||
_logic.errorMessage = null;
|
||||
});
|
||||
final auth = context.read<AuthController>();
|
||||
try {
|
||||
if (_logic.signUpMode) {
|
||||
await auth.register(
|
||||
email: _logic.normalizedEmail,
|
||||
password: _logic.password,
|
||||
);
|
||||
} else {
|
||||
await auth.signIn(
|
||||
email: _logic.normalizedEmail,
|
||||
password: _logic.password,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).maybePop();
|
||||
} on ApiException catch (error) {
|
||||
// 后端文案已经是可直接展示的中文/印尼语提示,错误码不外露
|
||||
setState(() => _logic.errorMessage = error.message);
|
||||
} catch (_) {
|
||||
setState(() => _logic.errorMessage = l10n.authNetworkError);
|
||||
} finally {
|
||||
if (mounted) setState(() => _logic.busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
elevation: 0,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(_logic.signUpMode ? l10n.signUp : l10n.signIn),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
|
||||
children: [
|
||||
Text(
|
||||
_logic.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.authSubtitle,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
_field(
|
||||
label: l10n.authEmailLabel,
|
||||
hint: 'name@example.com',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
onChanged: (value) => _logic.email = value,
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_field(
|
||||
label: l10n.authPasswordLabel,
|
||||
hint: l10n.authPasswordHint,
|
||||
obscure: true,
|
||||
onChanged: (value) => _logic.password = value,
|
||||
),
|
||||
if (_logic.errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Color(0xFFFF6B6B), size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_logic.errorMessage!,
|
||||
style: const TextStyle(color: Color(0xFFFF6B6B), fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
onPressed: _logic.busy ? null : _submit,
|
||||
child: _logic.busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_logic.signUpMode ? l10n.signUp : l10n.signIn,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextButton(
|
||||
onPressed: _logic.busy
|
||||
? null
|
||||
: () => setState(() => _logic.toggleMode()),
|
||||
child: Text(
|
||||
_logic.signUpMode ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp,
|
||||
style: const TextStyle(color: AppColors.blueSoft, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field({
|
||||
required String label,
|
||||
required String hint,
|
||||
required ValueChanged<String> onChanged,
|
||||
bool obscure = false,
|
||||
bool autofocus = false,
|
||||
TextInputType? keyboardType,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
autofocus: autofocus,
|
||||
obscureText: obscure,
|
||||
keyboardType: keyboardType,
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(color: Color(0xFF6C7280), fontSize: 14),
|
||||
filled: true,
|
||||
fillColor: AppColors.card,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import 'discover_logic.dart';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
@@ -20,6 +18,7 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
final FocusNode searchFocus = FocusNode();
|
||||
final ScrollController scroll = ScrollController();
|
||||
int category = 0;
|
||||
bool searchOpen = false;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
@@ -27,11 +26,7 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void refreshPosition() {
|
||||
position = history.positionFor(
|
||||
demoProfileId,
|
||||
drama.id,
|
||||
drama.episodes.first.id,
|
||||
);
|
||||
position = history.resumePositionFor(demoProfileId, drama);
|
||||
}
|
||||
|
||||
void selectCategory(int index) {
|
||||
@@ -41,6 +36,14 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
|
||||
void onSearchChanged() => notify();
|
||||
|
||||
bool get showSearch => searchOpen || search.text.isNotEmpty;
|
||||
|
||||
void openSearch() {
|
||||
searchOpen = true;
|
||||
notify();
|
||||
searchFocus.requestFocus();
|
||||
}
|
||||
|
||||
void syncDrama({
|
||||
required bool active,
|
||||
required bool wasActive,
|
||||
@@ -56,6 +59,8 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
notify();
|
||||
}
|
||||
if (active && searchRequest != previousSearchRequest) {
|
||||
searchOpen = true;
|
||||
notify();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (isActive()) searchFocus.requestFocus();
|
||||
});
|
||||
@@ -115,8 +120,9 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
void showFilters(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final labels = [
|
||||
l10n.featured,
|
||||
l10n.suspense,
|
||||
l10n.forYou,
|
||||
l10n.hot,
|
||||
l10n.newest,
|
||||
l10n.romance,
|
||||
l10n.actionGenre,
|
||||
];
|
||||
@@ -133,7 +139,7 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
ListTile(
|
||||
title: Text(labels[index]),
|
||||
trailing: index == category
|
||||
? const Icon(Icons.check_rounded, color: Color(0xFFFF5B3D))
|
||||
? const Icon(Icons.check_rounded, color: Color(0xFF2F7BFF))
|
||||
: null,
|
||||
onTap: () {
|
||||
selectCategory(index);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/data/models/home_feed.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'discover_binding.dart';
|
||||
import 'discover_logic.dart';
|
||||
import 'widgets/cover_carousel.dart';
|
||||
|
||||
class DiscoverPage extends StatefulWidget {
|
||||
const DiscoverPage({
|
||||
@@ -20,6 +23,10 @@ class DiscoverPage extends StatefulWidget {
|
||||
this.embedded = false,
|
||||
this.active = true,
|
||||
this.searchRequest = 0,
|
||||
this.signedIn = false,
|
||||
this.onSignIn,
|
||||
this.onCoins,
|
||||
this.onRewards,
|
||||
this.onAction,
|
||||
this.dramas,
|
||||
this.onDramaSelected,
|
||||
@@ -32,6 +39,10 @@ class DiscoverPage extends StatefulWidget {
|
||||
final bool embedded;
|
||||
final bool active;
|
||||
final int searchRequest;
|
||||
final bool signedIn;
|
||||
final VoidCallback? onSignIn;
|
||||
final VoidCallback? onCoins;
|
||||
final VoidCallback? onRewards;
|
||||
final ValueChanged<DiscoverPageAction>? onAction;
|
||||
final List<Drama>? dramas;
|
||||
final ValueChanged<Drama>? onDramaSelected;
|
||||
@@ -50,6 +61,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
drama: widget.drama,
|
||||
history: widget.history,
|
||||
);
|
||||
if (widget.focusSearch) logic.searchOpen = true;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -82,50 +94,64 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cards = _cardsFor(l10n, logic.category, logic.search.text);
|
||||
final catalog = context.watch<CatalogController>();
|
||||
final allDramas = widget.dramas ?? [logic.drama];
|
||||
final cards = _cardsFor(
|
||||
allDramas,
|
||||
catalog.sections,
|
||||
logic.category,
|
||||
'',
|
||||
);
|
||||
final content = SafeArea(
|
||||
bottom: !widget.embedded,
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(l10n),
|
||||
_header(l10n),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: logic.scroll,
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
_searchBar(l10n),
|
||||
const SizedBox(height: 12),
|
||||
_categories(l10n),
|
||||
const SizedBox(height: 15),
|
||||
_continueCard(l10n),
|
||||
const SizedBox(height: 15),
|
||||
_quickActions(l10n),
|
||||
const SizedBox(height: 20),
|
||||
_sectionTitle(l10n),
|
||||
const SizedBox(height: 13),
|
||||
if (cards.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
l10n.noSearchResults,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
)
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: cards.length,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 14,
|
||||
childAspectRatio: 0.59,
|
||||
),
|
||||
itemBuilder: (context, index) =>
|
||||
_posterCard(l10n, cards[index]),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: _carousel(l10n, catalog.banners, allDramas),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_categories(l10n),
|
||||
const SizedBox(height: 18),
|
||||
_continueHeader(l10n),
|
||||
const SizedBox(height: 12),
|
||||
if (cards.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
l10n.noSearchResults,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 196,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final card in cards) ...[
|
||||
_continueTile(l10n, card),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -144,412 +170,233 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar(AppLocalizations l10n) => SizedBox(
|
||||
height: 51,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 29,
|
||||
height: 29,
|
||||
decoration: BoxDecoration(
|
||||
color: _orange,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
l10n.discoverTab,
|
||||
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: l10n.search,
|
||||
onPressed: () => logic.searchFocus.requestFocus(),
|
||||
icon: const Icon(Icons.search_rounded, size: 22),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: () => logic.act(
|
||||
context,
|
||||
DiscoverPageAction.my,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
child: const CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: Color(0xFF34353B),
|
||||
child: Icon(Icons.person_rounded, size: 19),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _searchBar(AppLocalizations l10n) => Container(
|
||||
height: 43,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF25252A),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 11),
|
||||
const Icon(Icons.search_rounded, color: Color(0xFFF2EEEF), size: 20),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: logic.search,
|
||||
focusNode: logic.searchFocus,
|
||||
autofocus: widget.focusSearch,
|
||||
onChanged: (_) => logic.onSearchChanged(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchDiscover,
|
||||
hintStyle: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 12,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.filter,
|
||||
onPressed: () => logic.showFilters(context),
|
||||
icon: const Icon(Icons.tune_rounded, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _categories(AppLocalizations l10n) {
|
||||
final labels = [
|
||||
l10n.featured,
|
||||
l10n.suspense,
|
||||
l10n.romance,
|
||||
l10n.actionGenre,
|
||||
];
|
||||
return SizedBox(
|
||||
height: 30,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: labels.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 7),
|
||||
itemBuilder: (context, index) => ChoiceChip(
|
||||
label: Text(labels[index]),
|
||||
selected: logic.category == index,
|
||||
onSelected: (_) => logic.selectCategory(index),
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
backgroundColor: const Color(0xFF29272B),
|
||||
selectedColor: _orange,
|
||||
side: BorderSide.none,
|
||||
labelStyle: TextStyle(
|
||||
color: logic.category == index ? Colors.white : const Color(0xFFDDD5D7),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _continueCard(AppLocalizations l10n) => FutureBuilder<Duration?>(
|
||||
future: logic.position,
|
||||
builder: (context, snapshot) {
|
||||
final position = snapshot.data ?? Duration.zero;
|
||||
final total = widget.duration > Duration.zero
|
||||
? widget.duration
|
||||
: const Duration(seconds: 12);
|
||||
final progress = (position.inMilliseconds / total.inMilliseconds).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1C1D21),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: SizedBox(
|
||||
width: 55,
|
||||
height: 69,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(widget.drama.posterAsset, fit: BoxFit.cover),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 12,
|
||||
child: ColoredBox(color: Color(0xFF1C1D21)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.continueWatchingHeading,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFA490),
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
localizedDramaTitle(l10n, widget.drama),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.episodeProgress(1, widget.drama.episodes.length),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 3,
|
||||
backgroundColor: const Color(0xFF474248),
|
||||
color: _orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
FilledButton.icon(
|
||||
onPressed: () => logic.act(
|
||||
context,
|
||||
DiscoverPageAction.resume,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFFBD5C),
|
||||
foregroundColor: const Color(0xFF2A160B),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
minimumSize: const Size(0, 32),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 14),
|
||||
label: Text(l10n.resume),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _quickActions(AppLocalizations l10n) {
|
||||
final actions = [
|
||||
(Icons.tune_rounded, l10n.filter, () => logic.showFilters(context)),
|
||||
(Icons.bar_chart_rounded, l10n.topCharts, () => logic.catalogInfo(context)),
|
||||
(
|
||||
Icons.auto_awesome_rounded,
|
||||
l10n.newReleases,
|
||||
() => logic.catalogInfo(context),
|
||||
),
|
||||
(
|
||||
Icons.bookmark_outline_rounded,
|
||||
l10n.watchlist,
|
||||
() {
|
||||
logic.act(
|
||||
context,
|
||||
DiscoverPageAction.watchlist,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
return Row(
|
||||
children: [
|
||||
for (var index = 0; index < actions.length; index++) ...[
|
||||
const _BrandMark(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: actions[index].$3,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 77,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF202126),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: const Color(0xFF333036),
|
||||
child: Icon(
|
||||
actions[index].$1,
|
||||
size: 19,
|
||||
color: index == 1
|
||||
? const Color(0xFFFFC15E)
|
||||
: const Color(0xFFFFAB96),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
actions[index].$2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
l10n.appName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (index < actions.length - 1) const SizedBox(width: 7),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.signedIn) ...[
|
||||
_HeaderPill(
|
||||
icon: Icons.monetization_on_rounded,
|
||||
label: r'$ 0',
|
||||
onTap: widget.onCoins,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_HeaderPill(
|
||||
icon: Icons.card_giftcard_rounded,
|
||||
label: l10n.rewards,
|
||||
onTap: widget.onRewards,
|
||||
),
|
||||
] else
|
||||
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _carousel(
|
||||
AppLocalizations l10n,
|
||||
List<FeedBanner> banners,
|
||||
List<Drama> allDramas,
|
||||
) {
|
||||
final items = banners.where((banner) => banner.imageUrl.isNotEmpty).toList();
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return CoverCarousel(
|
||||
items: [
|
||||
for (final banner in items)
|
||||
CoverItem(
|
||||
title: _bannerTitle(banner, allDramas),
|
||||
subtitle: '',
|
||||
image: banner.imageUrl,
|
||||
onOpen: () => _openBanner(banner, allDramas),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(AppLocalizations l10n) => Row(
|
||||
String _bannerTitle(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return '';
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) return drama.title;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
void _openBanner(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return;
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) {
|
||||
logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _continueHeader(AppLocalizations l10n) => Row(
|
||||
children: [
|
||||
Container(width: 3, height: 20, color: _orange),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.trendingMicroDramas,
|
||||
l10n.continueWatching,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => logic.selectCategory(0),
|
||||
child: Text(
|
||||
l10n.seeAll,
|
||||
style: const TextStyle(color: Color(0xFFFFB3A1)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.seeAll,
|
||||
style: const TextStyle(
|
||||
color: AppColors.blue,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) {
|
||||
final playable =
|
||||
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]) != null;
|
||||
return InkWell(
|
||||
onTap: () => logic.openCard(
|
||||
context,
|
||||
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]),
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(card.image, fit: BoxFit.cover),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 37,
|
||||
child: ColoredBox(color: Color(0xFF101116)),
|
||||
Widget _continueTile(AppLocalizations l10n, _ShowcaseCard card) {
|
||||
final drama = logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]);
|
||||
return SizedBox(
|
||||
width: 132,
|
||||
child: InkWell(
|
||||
onTap: () => logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(card.image),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0xE6000000)],
|
||||
stops: [0.45, 1],
|
||||
),
|
||||
Positioned(
|
||||
top: 6,
|
||||
left: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: playable ? _orange : const Color(0xDD37343B),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Text(
|
||||
playable ? l10n.free : l10n.artworkOnly,
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (drama == null)
|
||||
const ColoredBox(
|
||||
color: Color(0x99000000),
|
||||
child: Center(
|
||||
child: Icon(Icons.image_outlined, color: Colors.white),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: Text(
|
||||
playable ? l10n.freeTestMedia : l10n.videoPending,
|
||||
),
|
||||
Positioned(
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 10,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
card.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFC789),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
drama == null ? l10n.videoPending : card.genre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xE6FFFFFF),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _categories(AppLocalizations l10n) {
|
||||
final labels = [
|
||||
l10n.forYou,
|
||||
l10n.hot,
|
||||
l10n.newest,
|
||||
l10n.romance,
|
||||
l10n.actionGenre,
|
||||
];
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: labels.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final selected = logic.category == index;
|
||||
return GestureDetector(
|
||||
onTap: () => logic.selectCategory(index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.blue : const Color(0xFF1C1E24),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
labels[index],
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : AppColors.muted,
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
card.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
card.genre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -639,60 +486,131 @@ class _ShowcaseCard {
|
||||
required this.title,
|
||||
required this.genre,
|
||||
required this.image,
|
||||
required this.category,
|
||||
this.dramaId,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String genre;
|
||||
final String image;
|
||||
final int category;
|
||||
final String? dramaId;
|
||||
}
|
||||
|
||||
/// 分区由服务端下发:0 为全部,其余按分区顺序对应筛选菜单(热门 / 最新 / 各分类)。
|
||||
List<_ShowcaseCard> _cardsFor(
|
||||
AppLocalizations l10n,
|
||||
List<Drama> dramas,
|
||||
List<FeedSection> sections,
|
||||
int category,
|
||||
String query,
|
||||
) {
|
||||
final cards = [
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaSignalTitle,
|
||||
genre: l10n.genreSuspenseMystery,
|
||||
image: 'assets/images/stitch_signal_poster_clean.png',
|
||||
category: 1,
|
||||
dramaId: 'the-signal-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaCityTitle,
|
||||
genre: l10n.genreUrbanRomance,
|
||||
image: 'assets/images/stitch_city_poster_clean.png',
|
||||
category: 2,
|
||||
dramaId: 'city-lights-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaVengeanceTitle,
|
||||
genre: l10n.genreActionThriller,
|
||||
image: 'assets/images/stitch_vengeance_poster_clean.png',
|
||||
category: 3,
|
||||
dramaId: 'vengeance-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaEchoesTitle,
|
||||
genre: l10n.genreDramaMystery,
|
||||
image: 'assets/images/stitch_rain_scene.jpg',
|
||||
category: 1,
|
||||
),
|
||||
];
|
||||
final source = switch (category) {
|
||||
0 => dramas,
|
||||
_ => category < sections.length ? sections[category].dramas : const <Drama>[],
|
||||
};
|
||||
final normalized = query.trim().toLowerCase();
|
||||
return cards.where((card) {
|
||||
final categoryMatches = category == 0 || card.category == category;
|
||||
final queryMatches =
|
||||
normalized.isEmpty ||
|
||||
card.title.toLowerCase().contains(normalized) ||
|
||||
card.genre.toLowerCase().contains(normalized);
|
||||
return categoryMatches && queryMatches;
|
||||
}).toList();
|
||||
return [
|
||||
for (final drama in source)
|
||||
if (normalized.isEmpty ||
|
||||
drama.title.toLowerCase().contains(normalized) ||
|
||||
drama.genre.toLowerCase().contains(normalized) ||
|
||||
drama.category.toLowerCase().contains(normalized))
|
||||
_ShowcaseCard(
|
||||
title: drama.title,
|
||||
genre: drama.genre.isEmpty ? drama.category : drama.genre,
|
||||
image: drama.posterUrl,
|
||||
dramaId: drama.id,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const _orange = Color(0xFFFF5B3D);
|
||||
const _orange = AppColors.blue;
|
||||
const _coin = Color(0xFFF5C451);
|
||||
|
||||
class _BrandMark extends StatelessWidget {
|
||||
const _BrandMark();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderPill extends StatelessWidget {
|
||||
const _HeaderPill({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color(0xFF1C1E24),
|
||||
shape: const StadiumBorder(side: BorderSide(color: Color(0x66F5C451))),
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 15, color: _coin),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginButton extends StatelessWidget {
|
||||
const _LoginButton({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
onPressed: onTap,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: AppColors.blue,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
child: Text(label, maxLines: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
|
||||
class CoverItem {
|
||||
const CoverItem({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.image,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String image;
|
||||
final VoidCallback onOpen;
|
||||
}
|
||||
|
||||
/// Full-bleed posters that turn as faces of a cube. Titles sit on the image.
|
||||
class CoverCarousel extends StatefulWidget {
|
||||
const CoverCarousel({super.key, required this.items});
|
||||
|
||||
final List<CoverItem> items;
|
||||
|
||||
@override
|
||||
State<CoverCarousel> createState() => _CoverCarouselState();
|
||||
}
|
||||
|
||||
class _CoverCarouselState extends State<CoverCarousel>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _snap;
|
||||
double _page = 0;
|
||||
double _from = 0;
|
||||
double _to = 0;
|
||||
int? _pointer;
|
||||
double _drag = 0;
|
||||
double _dragPage = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_snap = AnimationController(vsync: this)..addListener(_onSnap);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_snap.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get _last => math.max(0, widget.items.length - 1).toDouble();
|
||||
|
||||
void _onSnap() {
|
||||
final t = Curves.easeOutCubic.transform(_snap.value);
|
||||
setState(() => _page = _from + (_to - _from) * t);
|
||||
}
|
||||
|
||||
void _turnTo(double target) {
|
||||
_from = _page;
|
||||
_to = target.clamp(0, _last);
|
||||
_snap.duration = const Duration(milliseconds: 420);
|
||||
_snap.forward(from: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final height = math.min(width * 0.52, 210).toDouble();
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: height,
|
||||
child: _stage(width),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stage(double width) {
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (event) {
|
||||
_snap.stop();
|
||||
_pointer = event.pointer;
|
||||
_drag = 0;
|
||||
_dragPage = _page;
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
if (event.pointer != _pointer) return;
|
||||
_drag += event.delta.dx;
|
||||
setState(() {
|
||||
_page = (_dragPage - _drag / width).clamp(0.0, _last);
|
||||
});
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
if (event.pointer != _pointer) return;
|
||||
_pointer = null;
|
||||
_turnTo(_page.roundToDouble());
|
||||
},
|
||||
onPointerCancel: (event) {
|
||||
if (event.pointer != _pointer) return;
|
||||
_pointer = null;
|
||||
_turnTo(_page.roundToDouble());
|
||||
},
|
||||
child: ClipRect(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [for (final index in _visible) _face(index, width)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<int> get _visible {
|
||||
final first = _page.floor();
|
||||
final indexes = <int>[first, first + 1];
|
||||
indexes.retainWhere((index) => index >= 0 && index < widget.items.length);
|
||||
indexes.sort((a, b) => (b - _page).abs().compareTo((a - _page).abs()));
|
||||
return indexes;
|
||||
}
|
||||
|
||||
Widget _face(int index, double width) {
|
||||
final delta = index - _page;
|
||||
if (delta <= -1 || delta >= 1) return const SizedBox.shrink();
|
||||
final item = widget.items[index];
|
||||
final angle = delta * math.pi / 2;
|
||||
// Push the face out to the cube surface, spin the whole cube on its
|
||||
// vertical center, then bring that center back to the banner.
|
||||
final face = Matrix4.identity()
|
||||
..translateByDouble(0, 0, -width / 2, 1)
|
||||
..rotateY(angle)
|
||||
..translateByDouble(0, 0, width / 2, 1);
|
||||
final transform = Matrix4.identity()
|
||||
..setEntry(3, 2, 0.0018)
|
||||
..multiply(face);
|
||||
return Positioned.fill(
|
||||
child: Transform(
|
||||
alignment: Alignment.center,
|
||||
filterQuality: FilterQuality.medium,
|
||||
transform: transform,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if ((_page - index).abs() < 0.08) item.onOpen();
|
||||
},
|
||||
child: _poster(item),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _poster(CoverItem item) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(item.image),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0x00000000), Color(0xE6000000)],
|
||||
stops: [0, 0.5, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.15,
|
||||
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xF2FFFFFF),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../player/player_view.dart';
|
||||
|
||||
class DramaDetailLogic extends ChangeNotifier {
|
||||
DramaDetailLogic({required this.drama, required this.history});
|
||||
|
||||
final Drama drama;
|
||||
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
|
||||
Drama drama;
|
||||
final WatchHistoryRepository history;
|
||||
bool loading = false;
|
||||
bool failed = false;
|
||||
|
||||
/// 接口给出的失败原因;为空时界面回退到本地文案。
|
||||
String? failureMessage;
|
||||
int? continueIndex;
|
||||
bool _disposed = false;
|
||||
|
||||
@@ -18,6 +23,30 @@ class DramaDetailLogic extends ChangeNotifier {
|
||||
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;
|
||||
} catch (_) {
|
||||
failed = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadContinue() async {
|
||||
try {
|
||||
final id = await history.lastEpisodeId(demoProfileId, drama.id);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// 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/catalog_controller.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'drama_detail_binding.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
@@ -23,7 +26,15 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
logic = DramaDetailBinding().create(drama: widget.drama);
|
||||
logic.loadContinue();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
unawaited(
|
||||
logic.load(
|
||||
() => context.read<CatalogController>().requireDetail(widget.drama),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -42,6 +53,34 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (logic.loading) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Color(0xFF111111),
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (logic.failed) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
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;
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
@@ -52,7 +91,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
foregroundColor: Colors.white,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Image.asset(drama.posterAsset, fit: BoxFit.cover),
|
||||
background: RemoteImage(drama.posterUrl),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
@@ -63,7 +102,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
l10n.technicalDemoCount(drama.episodes.length),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
letterSpacing: 1.4,
|
||||
color: const Color(0xFF9278EA),
|
||||
color: const Color(0xFF2F7BFF),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -114,12 +153,11 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
minTileHeight: 76,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: const Color(
|
||||
0xFFFF6B45,
|
||||
0xFF2F7BFF,
|
||||
).withValues(alpha: 0.18),
|
||||
child: Text('${episode.number}'),
|
||||
),
|
||||
title: Text(l10n.episodeLabel(episode.number)),
|
||||
subtitle: Text(l10n.freeTestMedia),
|
||||
trailing: const Icon(Icons.play_circle_outline_rounded),
|
||||
onTap: () => logic.openPlayer(context, index),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import 'home_logic.dart';
|
||||
|
||||
|
||||
@@ -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,19 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../discover/discover_logic.dart';
|
||||
import '../drama_detail/drama_detail_view.dart';
|
||||
import '../mine/mine_logic.dart';
|
||||
import '../player/player_view.dart';
|
||||
|
||||
class HomeLogic extends ChangeNotifier {
|
||||
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
|
||||
|
||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
Drama currentDrama;
|
||||
int tab = 0;
|
||||
int tab = 1;
|
||||
int searchRequest = 0;
|
||||
int sectionRequest = 0;
|
||||
int resumeRequest = 0;
|
||||
@@ -21,6 +22,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
bool drawerOpen = false;
|
||||
bool detailOpen = false;
|
||||
bool videoImmersive = false;
|
||||
bool profile = false;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
@@ -64,9 +66,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => DramaDetailPage(drama: currentDrama),
|
||||
),
|
||||
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: currentDrama)),
|
||||
);
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
@@ -82,6 +82,34 @@ class HomeLogic extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> playDrama(BuildContext context, Drama drama) async {
|
||||
// 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。
|
||||
var playable = drama;
|
||||
try {
|
||||
playable = await context.read<CatalogController>().requireDetail(drama);
|
||||
} catch (_) {
|
||||
// 取详情失败就用卡片自带的那一集起播。
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlayerPage(drama: playable, initialIndex: 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void openProfile() {
|
||||
profile = true;
|
||||
scaffoldKey.currentState?.closeDrawer();
|
||||
select(2, mySection: 0);
|
||||
}
|
||||
|
||||
void openLibrary({int section = 1}) {
|
||||
profile = false;
|
||||
scaffoldKey.currentState?.closeDrawer();
|
||||
select(2, mySection: section);
|
||||
}
|
||||
|
||||
void discoverAction(BuildContext context, DiscoverPageAction action) {
|
||||
switch (action) {
|
||||
case DiscoverPageAction.home:
|
||||
@@ -89,7 +117,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
case DiscoverPageAction.my:
|
||||
select(2);
|
||||
case DiscoverPageAction.watchlist:
|
||||
select(2, watchlist: true);
|
||||
openLibrary();
|
||||
case DiscoverPageAction.resume:
|
||||
select(0, resume: true);
|
||||
case DiscoverPageAction.detail:
|
||||
@@ -114,11 +142,10 @@ class HomeLogic extends ChangeNotifier {
|
||||
} else if (videoImmersive) {
|
||||
backRequest++;
|
||||
notify();
|
||||
} else if (tab != 0) {
|
||||
select(0);
|
||||
} else if (tab != 1) {
|
||||
select(1);
|
||||
} else {
|
||||
backRequest++;
|
||||
notify();
|
||||
openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../discover/discover_view.dart';
|
||||
import '../mine/mine_view.dart';
|
||||
@@ -31,7 +34,6 @@ class HomePage extends StatelessWidget {
|
||||
CatalogController catalog,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
|
||||
if (catalog.loading) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
@@ -48,6 +50,13 @@ class HomePage extends StatelessWidget {
|
||||
Text(catalog.error == null ? l10n.noStories : l10n.catalogError),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: catalog.load, child: Text(l10n.retry)),
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppConfig.apiBaseUrl,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white54),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -94,8 +103,11 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _scaffold(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
// 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关)
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
@@ -109,8 +121,8 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
drama: logic.currentDrama,
|
||||
onOpenSeries: () => logic.openCurrentSeries(context),
|
||||
onDiscover: () => logic.selectFromMenu(1),
|
||||
onHistory: () => logic.selectFromMenu(2, mySection: 0),
|
||||
onWatchlist: () => logic.selectFromMenu(2, mySection: 1),
|
||||
onHistory: () => logic.openLibrary(section: 0),
|
||||
onWatchlist: logic.openLibrary,
|
||||
onLiked: () => logic.selectFromMenu(2, mySection: 2),
|
||||
),
|
||||
drawerEnableOpenDragGesture: false,
|
||||
@@ -129,7 +141,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
resumeRequest: logic.resumeRequest,
|
||||
backRequest: logic.backRequest,
|
||||
onDiscover: () => logic.select(1),
|
||||
onMy: () => logic.select(2),
|
||||
onMy: logic.openProfile,
|
||||
onSearch: () => logic.select(1, search: true),
|
||||
onMenu: logic.openMenu,
|
||||
onImmersiveChanged: logic.onImmersiveChanged,
|
||||
@@ -137,11 +149,15 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
DiscoverPage(
|
||||
drama: logic.currentDrama,
|
||||
dramas: widget.dramas,
|
||||
onDramaSelected: (drama) => logic.openDrama(context, drama),
|
||||
onDramaSelected: (drama) => logic.playDrama(context, drama),
|
||||
duration: Duration.zero,
|
||||
embedded: true,
|
||||
active: logic.tab == 1,
|
||||
searchRequest: logic.searchRequest,
|
||||
signedIn: signedIn,
|
||||
onSignIn: () => openAuthPage(context),
|
||||
onCoins: logic.openProfile,
|
||||
onRewards: logic.openProfile,
|
||||
onAction: (action) => logic.discoverAction(context, action),
|
||||
),
|
||||
MyPage(
|
||||
@@ -150,10 +166,15 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
onDramaSelected: (drama) => logic.openDrama(context, drama),
|
||||
duration: Duration.zero,
|
||||
initialSection: logic.mySection,
|
||||
profile: logic.profile,
|
||||
sectionRequest: logic.sectionRequest,
|
||||
embedded: true,
|
||||
active: logic.tab == 2,
|
||||
onAction: logic.myAction,
|
||||
onOpenHistory: () => logic.openLibrary(section: 0),
|
||||
signedIn: signedIn,
|
||||
onSignIn: () => openAuthPage(context),
|
||||
onSignOut: () => context.read<AuthController>().signOut(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -169,28 +190,38 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
border: Border(top: BorderSide(color: Color(0xFF242329))),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_TabButton(
|
||||
key: const Key('tab-home'),
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
label: l10n.homeTab,
|
||||
selected: logic.tab == 0,
|
||||
onTap: () => logic.select(0),
|
||||
),
|
||||
_TabButton(
|
||||
key: const Key('tab-discover'),
|
||||
icon: Icons.explore_outlined,
|
||||
label: l10n.discoverTab,
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home_rounded,
|
||||
label: l10n.homeTab,
|
||||
selected: logic.tab == 1,
|
||||
onTap: () => logic.select(1),
|
||||
),
|
||||
_TabButton(
|
||||
key: const Key('tab-home'),
|
||||
icon: Icons.movie_outlined,
|
||||
selectedIcon: Icons.movie_rounded,
|
||||
label: l10n.shortsTab,
|
||||
selected: logic.tab == 0,
|
||||
onTap: () => logic.select(0),
|
||||
),
|
||||
_TabButton(
|
||||
key: const Key('tab-my-list'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
selectedIcon: Icons.bookmark_rounded,
|
||||
label: l10n.myListTab,
|
||||
selected: logic.tab == 2 && !logic.profile,
|
||||
onTap: logic.openLibrary,
|
||||
),
|
||||
_TabButton(
|
||||
key: const Key('tab-my'),
|
||||
icon: Icons.video_library_outlined,
|
||||
icon: Icons.person_outline_rounded,
|
||||
selectedIcon: Icons.person_rounded,
|
||||
label: l10n.myTab,
|
||||
selected: logic.tab == 2,
|
||||
onTap: () => logic.select(2),
|
||||
selected: logic.tab == 2 && logic.profile,
|
||||
onTap: logic.openProfile,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -205,35 +236,47 @@ class _TabButton extends StatelessWidget {
|
||||
const _TabButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.selectedIcon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
final IconData icon;
|
||||
final IconData selectedIcon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
Widget build(BuildContext context) => Expanded(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 21,
|
||||
color: selected ? const Color(0xFFFF5C37) : const Color(0xFFE6E1DE),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 3),
|
||||
decoration: selected
|
||||
? BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
)
|
||||
: null,
|
||||
child: Icon(
|
||||
selected ? selectedIcon : icon,
|
||||
size: 20,
|
||||
color: selected ? Colors.white : AppColors.muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: selected
|
||||
? const Color(0xFFFF5C37)
|
||||
: const Color(0xFFE6E1DE),
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected ? AppColors.blue : AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/config/locale_controller.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// Shortcuts for the existing root tabs; this widget owns no navigation state.
|
||||
@@ -29,7 +28,7 @@ class HomeQuickDrawer extends StatelessWidget {
|
||||
|
||||
static const _panel = Color(0xFF222225);
|
||||
static const _card = Color(0xFF303033);
|
||||
static const _orange = Color(0xFFFF5C37);
|
||||
static const _orange = Color(0xFF2F7BFF);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -90,11 +89,10 @@ class HomeQuickDrawer extends StatelessWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.asset(
|
||||
drama.posterAsset,
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
width: 74,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -113,18 +111,9 @@ class HomeQuickDrawer extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
l10n.episodeCount(drama.episodes.length),
|
||||
l10n.episodeCount(drama.episodeCount),
|
||||
style: const TextStyle(color: Color(0xFFE7E2E0)),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
l10n.freeTestMedia,
|
||||
maxLines: 2,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFB99E),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../home/home_view.dart';
|
||||
|
||||
/// 登录页:Google / 手机号 / 邮箱 三种登录入口 + 游客浏览。
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
static const _orange = Color(0xFFFF5B3D);
|
||||
static const _purple = Color(0xFF9278EA);
|
||||
|
||||
void _enterHome() {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute<void>(builder: (_) => const HomePage()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showLoginSheet(String targetType) async {
|
||||
final auth = context.read<AuthController>();
|
||||
final targetCtrl = TextEditingController();
|
||||
final codeCtrl = TextEditingController();
|
||||
final isPhone = targetType == 'PHONE';
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: const Color(0xFF161822),
|
||||
builder: (sheetContext) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
isPhone ? 'Phone Login' : 'Email Login',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: targetCtrl,
|
||||
keyboardType: isPhone
|
||||
? TextInputType.phone
|
||||
: TextInputType.emailAddress,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
hintText: isPhone ? 'Phone number' : 'Email',
|
||||
hintStyle: const TextStyle(color: Colors.white38),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: codeCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Code',
|
||||
hintStyle: const TextStyle(color: Colors.white38),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await auth.sendOtp(
|
||||
scene: 'LOGIN',
|
||||
targetType: targetType,
|
||||
target: targetCtrl.text.trim(),
|
||||
);
|
||||
if (sheetContext.mounted) {
|
||||
ScaffoldMessenger.of(sheetContext).showSnackBar(
|
||||
const SnackBar(content: Text('Code sent')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (sheetContext.mounted) {
|
||||
ScaffoldMessenger.of(sheetContext).showSnackBar(
|
||||
SnackBar(content: Text('$e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Send Code'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await auth.loginWithCode(
|
||||
targetType: targetType,
|
||||
target: targetCtrl.text.trim(),
|
||||
code: codeCtrl.text.trim(),
|
||||
);
|
||||
if (sheetContext.mounted) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
}
|
||||
_enterHome();
|
||||
} catch (e) {
|
||||
if (sheetContext.mounted) {
|
||||
ScaffoldMessenger.of(sheetContext).showSnackBar(
|
||||
SnackBar(content: Text('$e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _orange,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('Sign In'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部:关闭 + 游客浏览
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_IconButton(icon: Icons.close, onTap: () {}),
|
||||
TextButton(
|
||||
onPressed: _enterHome,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white54,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.05),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
child: const Text('Guest Explore'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 中央内容
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const _BrandLogo(),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'DramaPulse',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Sign in to sync your progress and coins',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
_GoogleButton(onTap: _enterHome),
|
||||
const SizedBox(height: 14),
|
||||
_DarkButton(
|
||||
icon: Icons.phone_iphone,
|
||||
iconColor: _orange,
|
||||
label: 'Continue with Phone Number',
|
||||
onTap: () => _showLoginSheet('PHONE'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_DarkButton(
|
||||
icon: Icons.email_outlined,
|
||||
iconColor: _purple,
|
||||
label: 'Continue with Email',
|
||||
onTap: () => _showLoginSheet('EMAIL'),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const _BonusPill(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部协议
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 24),
|
||||
child: Text(
|
||||
'By continuing, you agree to DramaPulse\'s\n'
|
||||
'Terms of Service · Privacy Policy · Content Policy',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 11, color: Colors.white38),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconButton extends StatelessWidget {
|
||||
const _IconButton({required this.icon, required this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
border: Border.all(color: Colors.white12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: Colors.white54, size: 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BrandLogo extends StatelessWidget {
|
||||
const _BrandLogo();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFFF5B3D), Color(0xFFD8536C), Color(0xFF9278EA)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoogleButton extends StatelessWidget {
|
||||
const _GoogleButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _AuthButton(
|
||||
onTap: onTap,
|
||||
background: Colors.white,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const _GoogleG(),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Continue with Google',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 简化版 Google「G」logo。
|
||||
class _GoogleG extends StatelessWidget {
|
||||
const _GoogleG();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xFF4285F4),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'G',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DarkButton extends StatelessWidget {
|
||||
const _DarkButton({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _AuthButton(
|
||||
onTap: onTap,
|
||||
background: const Color(0xFF161822),
|
||||
border: const Color(0xFF232634),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: iconColor, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthButton extends StatelessWidget {
|
||||
const _AuthButton({
|
||||
required this.onTap,
|
||||
required this.background,
|
||||
required this.child,
|
||||
this.border,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
final Color background;
|
||||
final Color? border;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: Material(
|
||||
color: background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: border == null ? BorderSide.none : BorderSide(color: border!),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BonusPill extends StatelessWidget {
|
||||
const _BonusPill();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.04),
|
||||
border: Border.all(color: Colors.white12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF34D399),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Get ',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white54),
|
||||
),
|
||||
const Text(
|
||||
'100 Free Bonus Coins',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
' upon first sign-in',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white54),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import 'mine_logic.dart';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -31,11 +29,7 @@ class MineLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void refreshPosition() {
|
||||
position = history.positionFor(
|
||||
demoProfileId,
|
||||
drama.id,
|
||||
drama.episodes.first.id,
|
||||
);
|
||||
position = history.resumePositionFor(demoProfileId, drama);
|
||||
}
|
||||
|
||||
void selectSection(int index) {
|
||||
|
||||
+274
-218
@@ -1,12 +1,13 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/engagement_controller.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'mine_binding.dart';
|
||||
import 'mine_logic.dart';
|
||||
@@ -17,11 +18,16 @@ class MyPage extends StatefulWidget {
|
||||
required this.drama,
|
||||
required this.duration,
|
||||
this.initialSection = 0,
|
||||
this.profile = false,
|
||||
this.onOpenHistory,
|
||||
this.history,
|
||||
this.embedded = false,
|
||||
this.active = true,
|
||||
this.sectionRequest = 0,
|
||||
this.onAction,
|
||||
this.signedIn = false,
|
||||
this.onSignIn,
|
||||
this.onSignOut,
|
||||
this.dramas,
|
||||
this.onDramaSelected,
|
||||
});
|
||||
@@ -29,11 +35,16 @@ class MyPage extends StatefulWidget {
|
||||
final Drama drama;
|
||||
final Duration duration;
|
||||
final int initialSection;
|
||||
final bool profile;
|
||||
final VoidCallback? onOpenHistory;
|
||||
final WatchHistoryRepository? history;
|
||||
final bool embedded;
|
||||
final bool active;
|
||||
final int sectionRequest;
|
||||
final ValueChanged<MyPageAction>? onAction;
|
||||
final bool signedIn;
|
||||
final VoidCallback? onSignIn;
|
||||
final VoidCallback? onSignOut;
|
||||
final List<Drama>? dramas;
|
||||
final ValueChanged<Drama>? onDramaSelected;
|
||||
|
||||
@@ -88,7 +99,7 @@ class _MyPageState extends State<MyPage> {
|
||||
bottom: !widget.embedded,
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(l10n),
|
||||
if (!widget.profile) _topBar(l10n),
|
||||
Expanded(
|
||||
child: FutureBuilder<Duration?>(
|
||||
future: logic.position,
|
||||
@@ -103,15 +114,13 @@ class _MyPageState extends State<MyPage> {
|
||||
1.0,
|
||||
);
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
||||
children: [
|
||||
_profile(l10n),
|
||||
const SizedBox(height: 22),
|
||||
_continueCard(l10n, fraction, position, total),
|
||||
const SizedBox(height: 26),
|
||||
_library(l10n, position, total, fraction),
|
||||
const SizedBox(height: 26),
|
||||
_preferences(l10n),
|
||||
if (widget.profile)
|
||||
..._accountPage(l10n)
|
||||
else ...[
|
||||
_library(l10n, position, total, fraction),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Text(
|
||||
@@ -149,8 +158,8 @@ class _MyPageState extends State<MyPage> {
|
||||
const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
l10n.myTab,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
||||
l10n.myListTab,
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
@@ -174,158 +183,247 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _profile(AppLocalizations l10n) => Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundColor: Color(0xFF32343A),
|
||||
child: Icon(Icons.person_rounded, color: Colors.white, size: 34),
|
||||
List<Widget> _accountPage(AppLocalizations l10n) => [
|
||||
if (widget.signedIn) _signedInRow(l10n) else _guestRow(l10n),
|
||||
if (widget.signedIn) ...[const SizedBox(height: 18), _wallet(l10n)],
|
||||
const SizedBox(height: 14),
|
||||
_menuCard(l10n),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
l10n.preferencesHeading,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.localViewer,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.localDemoProfile,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF24252A),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(l10n.demo, style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _continueCard(
|
||||
AppLocalizations l10n,
|
||||
double fraction,
|
||||
Duration position,
|
||||
Duration total,
|
||||
) => Container(
|
||||
padding: const EdgeInsets.all(13),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF201B1E),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.language_rounded, color: AppColors.blueSoft),
|
||||
title: Text(l10n.appLanguage),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => logic.showLanguage(context),
|
||||
),
|
||||
];
|
||||
|
||||
Widget _guestRow(AppLocalizations l10n) {
|
||||
return Row(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.history_rounded, color: _orange, size: 17),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.continueWatching,
|
||||
const CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: Color(0xFF32343A),
|
||||
child: Icon(Icons.person_rounded, color: Colors.white, size: 30),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.logInHint,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: widget.onSignIn,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: AppColors.blue,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
child: Text(l10n.logIn),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _signedInRow(AppLocalizations l10n) {
|
||||
// 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案
|
||||
final user = context.watch<AuthController>().user;
|
||||
final name = (user?.nickname.isNotEmpty ?? false) ? user!.nickname : l10n.mockUserName;
|
||||
final uidText = (user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.mockUserId;
|
||||
return Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: Color(0xFF32343A),
|
||||
child: Icon(Icons.person_rounded, color: Colors.white, size: 30),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.episodeProgress(1, widget.drama.episodes.length),
|
||||
style: const TextStyle(color: Color(0xFFFFA38F), fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_poster(width: 75, height: 96),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedDramaTitle(l10n, widget.drama),
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
l10n.dramaTag,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
l10n.watchedPercent((fraction * 100).round()),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE7DEDD),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: fraction,
|
||||
minHeight: 4,
|
||||
backgroundColor: const Color(0xFF444046),
|
||||
color: _orange,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${_clock(position)} / ${_clock(total)}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
uidText,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => logic.act(
|
||||
context,
|
||||
MyPageAction.resume,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _orange,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(37),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
||||
label: Text(l10n.resume),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onSignOut ?? () => _mockSnack(l10n),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2A2C33),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
child: Text(l10n.signOut),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wallet(AppLocalizations l10n) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF17191F),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.myAccount,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _accountTile(
|
||||
label: l10n.coins,
|
||||
value: '\$ 0',
|
||||
action: l10n.topUpWallet,
|
||||
color: AppColors.blue,
|
||||
onPressed: () => _mockSnack(l10n),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _accountTile(
|
||||
label: l10n.vipMembership,
|
||||
value: l10n.notActive,
|
||||
action: l10n.joinVip,
|
||||
color: const Color(0xFFF5C451),
|
||||
onPressed: () => _mockSnack(l10n),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _mockSnack(AppLocalizations l10n) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.mockNotice)));
|
||||
}
|
||||
|
||||
Widget _accountTile({
|
||||
required String label,
|
||||
required String value,
|
||||
required String action,
|
||||
required Color color,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF12141A),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: color.withValues(alpha: 0.45)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: color, fontSize: 12)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: onPressed,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: color == AppColors.blue
|
||||
? Colors.white
|
||||
: const Color(0xFF2A2110),
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
child: Text(action, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _menuCard(AppLocalizations l10n) {
|
||||
return Material(
|
||||
color: const Color(0xFF17191F),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.card_giftcard_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
title: Text(l10n.earnRewards),
|
||||
trailing: Text(
|
||||
l10n.freeBonus,
|
||||
style: const TextStyle(color: AppColors.blue, fontSize: 12),
|
||||
),
|
||||
onTap: () => _mockSnack(l10n),
|
||||
),
|
||||
const Divider(height: 1, color: Color(0xFF2A2C33)),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.history_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
title: Text(l10n.watchHistory),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: widget.onOpenHistory,
|
||||
),
|
||||
const Divider(height: 1, color: Color(0xFF2A2C33)),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.settings_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
title: Text(l10n.settings),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => logic.showLanguage(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _library(
|
||||
AppLocalizations l10n,
|
||||
@@ -355,14 +453,25 @@ class _MyPageState extends State<MyPage> {
|
||||
for (var index = 0; index < tabs.length; index++) ...[
|
||||
InkWell(
|
||||
onTap: () => logic.selectSection(index),
|
||||
child: Text(
|
||||
tabs[index],
|
||||
style: TextStyle(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: logic.section == index
|
||||
? _orange
|
||||
: const Color(0xFFE4DBDD),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
? AppColors.blue
|
||||
: const Color(0xFF24262E),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
tabs[index],
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -448,7 +557,7 @@ class _MyPageState extends State<MyPage> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFA18D),
|
||||
color: AppColors.blueSoft,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
@@ -509,12 +618,7 @@ class _MyPageState extends State<MyPage> {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.asset(
|
||||
drama.posterAsset,
|
||||
width: 74,
|
||||
height: 103,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
child: RemoteImage(drama.posterUrl, width: 74, height: 103),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
@@ -532,7 +636,7 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
l10n.episodeCount(drama.episodes.length),
|
||||
l10n.episodeCount(drama.episodeCount),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 12,
|
||||
@@ -547,49 +651,6 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _preferences(AppLocalizations l10n) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.preferencesHeading,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFCEBBB9),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Material(
|
||||
color: const Color(0xFF1B1C21),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.translate_rounded,
|
||||
color: Color(0xFFFFBC65),
|
||||
),
|
||||
title: Text(l10n.appLanguage),
|
||||
subtitle: Text(l10n.language),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
Localizations.localeOf(context).languageCode == 'zh'
|
||||
? l10n.simplifiedChinese
|
||||
: l10n.english,
|
||||
style: const TextStyle(color: Color(0xFFFFB2A1), fontSize: 11),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right_rounded, size: 18),
|
||||
],
|
||||
),
|
||||
onTap: () => logic.showLanguage(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _bottomNav(AppLocalizations l10n) => Container(
|
||||
height: 63,
|
||||
decoration: const BoxDecoration(
|
||||
@@ -631,12 +692,7 @@ class _MyPageState extends State<MyPage> {
|
||||
|
||||
Widget _poster({required double width, required double height}) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: Image.asset(
|
||||
widget.drama.posterAsset,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
child: RemoteImage(widget.drama.posterUrl, width: width, height: height),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -685,4 +741,4 @@ String _clock(Duration duration) {
|
||||
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
const _orange = Color(0xFFFF5B3D);
|
||||
const _orange = AppColors.blue;
|
||||
@@ -1,5 +1,3 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/android_picture_in_picture.dart';
|
||||
import 'player_logic.dart';
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/player_port.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/android_picture_in_picture.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/platform_share_service.dart';
|
||||
import '../../core/data/services/playback_queue.dart';
|
||||
import '../../core/data/services/playback_session.dart';
|
||||
@@ -18,7 +18,6 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../discover/discover_logic.dart';
|
||||
import '../discover/discover_view.dart';
|
||||
import '../drama_detail/drama_detail_view.dart';
|
||||
import '../home/home_feed_sections.dart';
|
||||
import '../mine/mine_logic.dart';
|
||||
import '../mine/mine_view.dart';
|
||||
import 'player_view.dart';
|
||||
@@ -32,10 +31,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
late PageController pages;
|
||||
late PlaybackQueue queue;
|
||||
late HomeFeedSections homeSections;
|
||||
late List<int> visibleIndices;
|
||||
HomeFeedCategory category = HomeFeedCategory.recommended;
|
||||
int categoryRequest = 0;
|
||||
late PlaybackSession session;
|
||||
VideoPlayerPort? trackedPlayer;
|
||||
Episode? trackedEpisode;
|
||||
@@ -49,6 +45,8 @@ class PlayerLogic extends ChangeNotifier {
|
||||
bool fullscreen = false;
|
||||
bool pip = false;
|
||||
bool pipPreparing = false;
|
||||
bool screenCleared = false;
|
||||
final Map<String, int> pickedEpisode = {};
|
||||
|
||||
bool Function() isMounted = () => false;
|
||||
bool homeMode = false;
|
||||
@@ -69,6 +67,24 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
bool get immersive => fullscreen || pip || pipPreparing;
|
||||
|
||||
bool get chromeHidden => immersive || screenCleared;
|
||||
|
||||
Episode displayEpisode(Drama drama, Episode fallback) {
|
||||
final number = pickedEpisode[drama.id];
|
||||
if (number == null) return fallback;
|
||||
for (final episode in drama.episodes) {
|
||||
if (episode.number == number) return episode;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
void setScreenCleared(bool value) {
|
||||
if (screenCleared == value) return;
|
||||
screenCleared = value;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
}
|
||||
|
||||
void notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
@@ -99,17 +115,13 @@ class PlayerLogic extends ChangeNotifier {
|
||||
queue = feedDramas == null
|
||||
? PlaybackQueue.series(pageDrama)
|
||||
: PlaybackQueue.home(feedDramas!);
|
||||
homeSections = HomeFeedSections([
|
||||
for (final entry in queue.entries) entry.drama,
|
||||
]);
|
||||
visibleIndices = homeMode
|
||||
? homeSections.indicesFor(category)
|
||||
: List<int>.generate(queue.length, (index) => index);
|
||||
visibleIndices = List<int>.generate(queue.length, (index) => index);
|
||||
pages = PageController(initialPage: initialIndex);
|
||||
session = PlaybackSession(
|
||||
episodes: [for (final entry in queue.entries) entry.episode],
|
||||
factory: factory,
|
||||
canPlay: (episode) => episode.isFree,
|
||||
// 后端只在可播放时才下发地址(免费集或已解锁),拿不到地址即视为锁定
|
||||
canPlay: (episode) => episode.videoUrl.isNotEmpty,
|
||||
prepare: preparePlayer,
|
||||
activate: activatePlayer,
|
||||
coordinator: coordinator,
|
||||
@@ -137,8 +149,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (isMounted() && backRequest == request) {
|
||||
if (fullscreen) {
|
||||
unawaited(setFullscreen(false));
|
||||
} else if (screenCleared) {
|
||||
setScreenCleared(false);
|
||||
} else {
|
||||
shiftCategory(-1);
|
||||
onMenu?.call();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -172,7 +186,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void notifyImmersive() => onImmersiveChanged?.call(immersive);
|
||||
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
|
||||
|
||||
Future<void> setFullscreen(bool value) async {
|
||||
if (fullscreen == value || !isMounted()) return;
|
||||
@@ -194,7 +208,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
} catch (_) {
|
||||
if (value && isMounted()) {
|
||||
fullscreen = false;
|
||||
notify();
|
||||
notify();
|
||||
notifyImmersive();
|
||||
}
|
||||
}
|
||||
@@ -228,7 +242,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
session.setAppResumed(true);
|
||||
} else {
|
||||
pipPreparing = false;
|
||||
notify();
|
||||
notify();
|
||||
notifyImmersive();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)),
|
||||
@@ -329,7 +343,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
} catch (_) {
|
||||
// A category change can detach the old page controller mid-animation.
|
||||
// The page can leave the tree before the animation finishes.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,49 +387,6 @@ class PlayerLogic extends ChangeNotifier {
|
||||
: null;
|
||||
}
|
||||
|
||||
void shiftCategory(int offset) {
|
||||
if (!homeMode) return;
|
||||
final next = category.index + offset;
|
||||
if (next < 0) {
|
||||
onMenu?.call();
|
||||
return;
|
||||
}
|
||||
if (next >= HomeFeedCategory.values.length) return;
|
||||
unawaited(selectCategory(HomeFeedCategory.values[next]));
|
||||
}
|
||||
|
||||
Future<void> selectCategory(HomeFeedCategory next) async {
|
||||
if (!homeMode || next == category) return;
|
||||
final request = ++categoryRequest;
|
||||
++pageRequest;
|
||||
session.setRouteVisible(false);
|
||||
cancelScrub();
|
||||
await saveCurrentProgress();
|
||||
if (!isMounted() || request != categoryRequest) return;
|
||||
|
||||
final oldPages = pages;
|
||||
final indices = homeSections.indicesFor(next);
|
||||
category = next;
|
||||
visibleIndices = indices;
|
||||
pages = PageController();
|
||||
notify();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => oldPages.dispose());
|
||||
|
||||
if (indices.isEmpty) return;
|
||||
onDramaChanged?.call(queue.entryAt(indices.first).drama);
|
||||
await session.select(
|
||||
indices.first,
|
||||
preloadIndex: nextVisibleIndex(indices.first),
|
||||
);
|
||||
if (isMounted() && request == categoryRequest) {
|
||||
if (stoppedAtFeedEnd) {
|
||||
stoppedAtFeedEnd = false;
|
||||
session.setUserWantsPlay(true);
|
||||
}
|
||||
session.setRouteVisible(active);
|
||||
}
|
||||
}
|
||||
|
||||
void beginScrub(VideoPlayerPort player) {
|
||||
if (session.active != player) return;
|
||||
scrubbingPlayer = player;
|
||||
@@ -428,7 +399,11 @@ class PlayerLogic extends ChangeNotifier {
|
||||
session.setOverlayOpen(false);
|
||||
}
|
||||
|
||||
Future<void> seekTo(BuildContext context, VideoPlayerPort player, Duration position) async {
|
||||
Future<void> seekTo(
|
||||
BuildContext context,
|
||||
VideoPlayerPort player,
|
||||
Duration position,
|
||||
) async {
|
||||
if (scrubbingPlayer != player || session.active != player) return;
|
||||
try {
|
||||
await session.playbackSettled;
|
||||
@@ -466,12 +441,31 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (isMounted()) session.setRouteVisible(active);
|
||||
}
|
||||
|
||||
Future<void> openSeriesAt(
|
||||
BuildContext context,
|
||||
Drama drama,
|
||||
int index,
|
||||
) async {
|
||||
session.setRouteVisible(false);
|
||||
await session.playbackSettled;
|
||||
await saveCurrentProgress();
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlayerPage(drama: drama, initialIndex: index),
|
||||
),
|
||||
);
|
||||
if (isMounted()) session.setRouteVisible(active);
|
||||
}
|
||||
|
||||
Future<void> showEpisodes(BuildContext context) async {
|
||||
if (feedDramas != null) {
|
||||
await openDetail(context);
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final entry = queue.entryAt(session.currentIndex);
|
||||
final drama = await _withFullEpisodes(context, entry.drama);
|
||||
if (!context.mounted) return;
|
||||
final homeFeed = feedDramas != null;
|
||||
final episodes = drama.episodes;
|
||||
final currentNumber = entry.episode.number;
|
||||
session.setOverlayOpen(true);
|
||||
await session.playbackSettled;
|
||||
if (!context.mounted) {
|
||||
@@ -481,49 +475,57 @@ class PlayerLogic extends ChangeNotifier {
|
||||
try {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF202024),
|
||||
backgroundColor: const Color(0xFF16171C),
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * 0.6,
|
||||
height: MediaQuery.sizeOf(sheetContext).height * 0.46,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||||
child: Text(
|
||||
l10n.episodes,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFB7B3B8),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: queue.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = queue.entryAt(index).episode;
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: const Color(0xFF3A3030),
|
||||
child: Text('${episode.number}'),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 6,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 1.45,
|
||||
),
|
||||
title: Text(l10n.episodeLabel(episode.number)),
|
||||
subtitle: Text(l10n.freeTestMedia),
|
||||
trailing: const Icon(Icons.play_arrow_rounded),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
unawaited(
|
||||
pages.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
itemCount: episodes.length,
|
||||
itemBuilder: (_, index) => _episodeCell(
|
||||
context,
|
||||
sheetContext,
|
||||
drama,
|
||||
episodes[index],
|
||||
index,
|
||||
homeFeed,
|
||||
currentNumber,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -536,6 +538,68 @@ class PlayerLogic extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
|
||||
/// 取不到(或页面外没有目录来源)就退回卡片本身。
|
||||
Future<Drama> _withFullEpisodes(BuildContext context, Drama drama) async {
|
||||
if (drama.detailLoaded) return drama;
|
||||
final CatalogController catalog;
|
||||
try {
|
||||
catalog = context.read<CatalogController>();
|
||||
} catch (_) {
|
||||
return drama;
|
||||
}
|
||||
try {
|
||||
return await catalog.requireDetail(drama);
|
||||
} catch (_) {
|
||||
return drama;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _episodeCell(
|
||||
BuildContext pageContext,
|
||||
BuildContext sheetContext,
|
||||
Drama drama,
|
||||
Episode episode,
|
||||
int index,
|
||||
bool homeFeed,
|
||||
int currentNumber,
|
||||
) {
|
||||
final selected = episode.number == currentNumber;
|
||||
return Material(
|
||||
color: selected ? const Color(0xFF2F7BFF) : const Color(0xFF2A2C33),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (homeFeed) {
|
||||
if (episode.number == currentNumber) return;
|
||||
unawaited(openSeriesAt(pageContext, drama, index));
|
||||
return;
|
||||
}
|
||||
if (index == session.currentIndex) return;
|
||||
unawaited(
|
||||
pages.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${episode.number}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showMy(BuildContext context, {int initialSection = 0}) async {
|
||||
if (embedded) {
|
||||
onMy?.call();
|
||||
@@ -576,7 +640,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
await showDiscover(context, focusSearch: true);
|
||||
}
|
||||
|
||||
Future<void> showDiscover(BuildContext context, {bool focusSearch = false}) async {
|
||||
Future<void> showDiscover(
|
||||
BuildContext context, {
|
||||
bool focusSearch = false,
|
||||
}) async {
|
||||
if (embedded) {
|
||||
onDiscover?.call();
|
||||
return;
|
||||
@@ -603,7 +670,8 @@ class PlayerLogic extends ChangeNotifier {
|
||||
await openDetail(context);
|
||||
} else if (action == DiscoverPageAction.my ||
|
||||
action == DiscoverPageAction.watchlist) {
|
||||
await showMy(context,
|
||||
await showMy(
|
||||
context,
|
||||
initialSection: action == DiscoverPageAction.watchlist ? 1 : 0,
|
||||
);
|
||||
} else {
|
||||
@@ -663,5 +731,4 @@ class PlayerLogic extends ChangeNotifier {
|
||||
session.setOverlayOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+296
-572
File diff suppressed because it is too large.
Load diff
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user