Author SHA1 Message Date
Zhou Weidong 45e867561d chore(ios): 补充 CocoaPods 配置(Podfile 与 Pods xcconfig 引用) 2026-10-19 13:23:03 +08:00
Zhou Weidong 64fa3b1efe build: release 开启 R8 混淆与资源压缩(反编译保护) 2026-10-17 03:03:52 +08:00
Zhou Weidong faaf2f7283 feat: C3 客户端埋点 SDK(EventTracker 批量上报 + 核心事件) 2026-10-17 02:55:47 +08:00
Zhou Weidong 070ec78668 feat: A3 数据层 AppCatalogController(feed/详情/分类加载,供页面切换) 2026-10-17 02:43:41 +08:00
Zhou Weidong 88a401512d feat: A3-A6 数据层(内容/播放/记录收藏/互动的 HTTP 对接)
- AppCatalog 模型(feed/section/card/detail/episode/banner/category)
- HttpCatalogRepository 对接首页/发现/分类/详情
- PlaybackRepository 播放票据、WatchRepository 记录收藏
- InteractionRepository 评论点赞举报、WalletRepository 钱包解锁
2026-10-17 02:31:47 +08:00
Zhou Weidong a7ac3b7fc7 feat: A1 网络层与鉴权 + A2 账号登录对接
- 引入 dio,封装统一 ApiClient(BaseUrl/Token 注入/401 自动刷新/{code,message,data} 解包)
- TokenStorage 令牌持久化,AuthController 登录状态管理
- AuthRepository 封装 otp/login/logout 接口
- 登录页对接手机号/邮箱验证码登录(Google 登录待 OAuth client 配置)
2026-10-17 02:30:16 +08:00
Zhou Weidong 015677d1a8 feat: 新增登录页 + 主题改橙红对齐设计稿
- 新增登录页(Google / 手机号 / 邮箱 + 游客浏览 + 首登送金币)
- seedColor 改橙红 #FF5B3D,themeMode 改 dark,背景对齐设计稿 #0B0C10
- app 启动入口改为 LoginPage
2026-10-17 02:24:30 +08:00
Zhou Weidong eaf8fb4371 style: 全量补充作者标识 // Author: fruit.com.cn 2026-10-16 00:01:58 +08:00
137 changed files with 3731 additions and 7928 deletions

No files matched your search

+2 -35
View File
@@ -5,46 +5,13 @@
- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。
- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。
- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。
- **互动与多语言**:本地演示评论、系统分享,以及英文、简体中文和印尼语界面。
- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。
- **画中画**: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,已从打包清单移除)。
## 当前说明
收藏、点赞、评论与观看进度仍保存在本机(后端互动接口尚未开发);
金币与会员相关界面仍是占位数据。
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
+2
View File
@@ -1,3 +1,5 @@
# Author: fruit.com.cn
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
+5
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
@@ -30,6 +32,9 @@ 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
}
}
}
+2 -2
View File
@@ -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>
+3 -2
View File
@@ -1,7 +1,8 @@
<!-- Author: fruit.com.cn -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="FruitShorts"
android:label="Nontonki"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
package com.stormg.lumadrama
import android.content.Intent
@@ -1,4 +1,6 @@
<?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,4 +1,6 @@
<?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,4 +1,6 @@
<?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,4 +1,6 @@
<?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,4 +1,6 @@
<?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,4 +1,6 @@
<?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,4 +1,6 @@
<?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,4 +1,6 @@
<?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">
+2 -2
View File
@@ -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>
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
allprojects {
repositories {
google()
+2
View File
@@ -1,3 +1,5 @@
# 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
+2
View File
@@ -1,3 +1,5 @@
# Author: fruit.com.cn
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
pluginManagement {
val flutterSdkPath =
run {
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
View File
@@ -0,0 +1,43 @@
# 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
+3 -3
View File
@@ -360,7 +360,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.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 = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.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 = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
+2 -9
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>FruitShorts</string>
<string>Nontonki</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>FruitShorts</string>
<string>Nontonki</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -26,13 +26,6 @@
<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>
+2
View File
@@ -1,3 +1,5 @@
# Author: fruit.com.cn
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
+37 -50
View File
@@ -1,68 +1,56 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'core/config/locale_controller.dart';
import 'core/data/remote/api_client.dart';
import 'core/data/remote/auth_api.dart';
import 'core/data/remote/catalog_api.dart';
import 'core/data/repositories/auth_repository.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/home/home_view.dart';
import 'pages/login/login_view.dart';
class LumaDramaApp extends StatefulWidget {
const LumaDramaApp({
super.key,
this.playerFactory = VideoPlayerPort.new,
this.catalogRepository,
});
class LumaDramaApp extends StatelessWidget {
const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new});
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: (_) => widget.playerFactory),
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>(),
),
),
ChangeNotifierProvider(
create: (_) => CatalogController(
widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)),
)..load(),
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(),
),
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
ChangeNotifierProvider(
@@ -70,7 +58,6 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
EngagementController(SharedPreferencesEngagementRepository())
..load(),
),
ChangeNotifierProvider.value(value: _auth),
],
child: Consumer<LocaleController>(
builder: (context, locale, _) => MaterialApp(
@@ -87,23 +74,23 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
return supported.first;
},
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.system,
themeMode: ThemeMode.dark,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF2F7BFF),
seedColor: const Color(0xFFFF5B3D),
),
scaffoldBackgroundColor: const Color(0xFFF8F7FC),
scaffoldBackgroundColor: const Color(0xFF0B0C10),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF2F7BFF),
seedColor: const Color(0xFFFF5B3D),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF101019),
scaffoldBackgroundColor: const Color(0xFF0B0C10),
),
home: const HomePage(),
home: const LoginPage(),
),
),
);
+15
View File
@@ -0,0 +1,15 @@
// 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;
}
-50
View File
@@ -1,50 +0,0 @@
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';
}
}
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
+188
View File
@@ -0,0 +1,188 @@
// 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(),
);
}
-54
View File
@@ -1,54 +0,0 @@
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(),
};
}
+10 -101
View File
@@ -1,53 +1,21 @@
import 'json_numbers.dart';
// Author: fruit.com.cn
enum DramaFormat { animated, liveAction }
/// 短剧与剧集,字段与后端 `/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 {
@@ -55,74 +23,15 @@ class Drama {
required this.id,
required this.title,
required this.synopsis,
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,
required this.posterAsset,
required this.episodes,
this.format = DramaFormat.liveAction,
});
final String id;
final String title;
final String synopsis;
final String posterUrl;
final String posterAsset;
final List<Episode> episodes;
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(),
);
}
final DramaFormat format;
}
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
class EngagementSnapshot {
const EngagementSnapshot({
this.savedDramaIds = const {},
-86
View File
@@ -1,86 +0,0 @@
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? ?? '',
);
}
}
-13
View File
@@ -1,13 +0,0 @@
// 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;
}
-37
View File
@@ -1,37 +0,0 @@
/// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。
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,
);
}
}
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
abstract interface class PlayerPort {
Future<void> initialize();
Future<void> play();
-131
View File
@@ -1,131 +0,0 @@
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);
}
-15
View File
@@ -1,15 +0,0 @@
// 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)';
}
-133
View File
@@ -1,133 +0,0 @@
// 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 {};
}
}
}
-73
View File
@@ -1,73 +0,0 @@
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,
);
}
}
-36
View File
@@ -1,36 +0,0 @@
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, '返回数据格式异常');
}
}
@@ -0,0 +1,91 @@
// 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,36 +1,106 @@
// 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<HomeFeed> fetchHomeFeed();
Future<HomeFeed> fetchDiscoverFeed();
Future<Drama> fetchDrama(String dramaId);
Future<PlayTicket> resolvePlay(String episodeId);
Future<List<Drama>> fetchDramas();
}
class HttpCatalogRepository implements CatalogRepository {
HttpCatalogRepository(this._api);
final CatalogApi _api;
class DemoCatalogRepository implements CatalogRepository {
@override
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);
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,
),
],
),
];
}
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
@@ -0,0 +1,33 @@
// 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>);
}
}
@@ -0,0 +1,61 @@
// 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,
});
}
}
@@ -0,0 +1,16 @@
// 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>;
}
}
@@ -0,0 +1,25 @@
// 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,6 +1,6 @@
import 'package:shared_preferences/shared_preferences.dart';
// Author: fruit.com.cn
import '../models/drama.dart';
import 'package:shared_preferences/shared_preferences.dart';
const demoProfileId = 'demo-local-v1';
@@ -62,17 +62,6 @@ 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;
@@ -0,0 +1,59 @@
// 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,3 +1,5 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
+136
View File
@@ -0,0 +1,136 @@
// 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',
);
}
}
}
@@ -0,0 +1,39 @@
// 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();
}
}
+46 -84
View File
@@ -1,103 +1,65 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/auth_session.dart';
import '../remote/api_client.dart';
import '../remote/auth_api.dart';
import 'auth_session_store.dart';
import '../repositories/auth_repository.dart';
/// 登录态的唯一来源。
///
/// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新;
/// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 ——
/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。
enum AuthStatus { unknown, authenticated, unauthenticated }
/// 登录状态与账号操作。
class AuthController extends ChangeNotifier {
AuthController(this._api, this._store);
AuthController(this._repository);
final AuthApi _api;
final AuthSessionStore _store;
final AuthRepository _repository;
AuthSession? _session;
String _deviceId = '';
bool _loaded = false;
AuthStatus _status = AuthStatus.unknown;
AuthStatus get status => _status;
bool get isLoaded => _loaded;
bool get isSignedIn => _session != null;
AuthUser? get user => _session?.user;
String? get accessToken => _session?.accessToken;
String get deviceId => _deviceId;
/// App 启动时调用:恢复本地令牌与设备号。
Future<void> load() async {
_deviceId = await _store.deviceId();
_session = await _store.read();
_loaded = true;
notifyListeners();
}
Future<void> register({required String email, required String password}) async {
final session = await _api.register(
email: email,
password: password,
deviceId: await _store.deviceId(),
platform: _platform,
Future<void> sendOtp({
required String scene,
required String targetType,
required String target,
}) {
return _repository.sendOtp(
scene: scene,
targetType: targetType,
target: target,
);
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,
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 _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();
await _repository.saveSession(session);
_status = AuthStatus.authenticated;
notifyListeners();
}
Future<void> _apply(AuthSession session) async {
_session = session;
await _store.save(session);
Future<void> loginWithPassword({
required String targetType,
required String target,
required String password,
}) async {
final session = await _repository.loginWithPassword(
targetType: targetType,
target: target,
password: password,
);
await _repository.saveSession(session);
_status = AuthStatus.authenticated;
notifyListeners();
}
String get _platform {
if (kIsWeb) return 'WEB';
return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID';
Future<void> logout() async {
await _repository.logout();
_status = AuthStatus.unauthenticated;
notifyListeners();
}
}
@@ -1,74 +0,0 @@
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);
// 设备号不清:它代表"这台设备",与登录态无关
}
}
+5 -49
View File
@@ -1,72 +1,28 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.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;
HomeFeed feed = const HomeFeed();
List<Drama> dramas = const [];
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 {
feed = await repository.fetchHomeFeed();
} on ApiException catch (exception) {
error = exception.message;
dramas = await repository.fetchDramas();
} catch (_) {
error = 'catalogError';
error = 'The catalog could not be loaded.';
} 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,3 +1,5 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/engagement_snapshot.dart';
+64
View File
@@ -0,0 +1,64 @@
// 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,3 +1,5 @@
// Author: fruit.com.cn
import 'package:flutter/services.dart';
class PlatformShareService {
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import '../models/drama.dart';
/// A player session has one navigation meaning: browse dramas or browse episodes.
+3 -13
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
@@ -128,9 +130,6 @@ 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;
@@ -152,23 +151,14 @@ 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,
+34
View File
@@ -0,0 +1,34 @@
// 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);
}
}
+7 -20
View File
@@ -1,6 +1,7 @@
// 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';
@@ -8,25 +9,11 @@ class VideoPlayerPort implements PlayerPort {
VideoPlayerPort(
Episode episode, {
Future<ClosedCaptionFile>? closedCaptionFile,
}) : 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,
);
}
}) : controller = VideoPlayerController.asset(
episode.assetPath,
closedCaptionFile: closedCaptionFile,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false),
);
final VideoPlayerController controller;
Future<void>? _initialization;
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import '../../l10n/app_localizations.dart';
import '../data/models/drama.dart';
-11
View File
@@ -1,11 +0,0 @@
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);
}
-83
View File
@@ -1,83 +0,0 @@
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;
}
-44
View File
@@ -1,44 +0,0 @@
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),
);
}
}
+3 -53
View File
@@ -1,6 +1,6 @@
{
"@@locale": "en",
"appName": "FruitShorts",
"appName": "Nontonki",
"demo": "DEMO",
"homeHeadline": "Watch now",
"homeSubtitle": "Free short drama preview.",
@@ -96,8 +96,6 @@
"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",
@@ -131,54 +129,6 @@
"genreDramaMystery": "Drama · Mystery",
"continueWatchingHeading": "CONTINUE WATCHING",
"preferencesHeading": "PREFERENCES",
"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"
"brandMark": "NONTONKI · DEMO",
"shareFooter": "Nontonki demo"
}
-184
View File
@@ -1,184 +0,0 @@
{
"@@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"
}
+6 -308
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
@@ -6,7 +8,6 @@ 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
@@ -96,14 +97,13 @@ 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:
/// **'FruitShorts'**
/// **'Nontonki'**
String get appName;
/// No description provided for @demo.
@@ -634,18 +634,6 @@ 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:
@@ -847,302 +835,14 @@ abstract class AppLocalizations {
/// No description provided for @brandMark.
///
/// In en, this message translates to:
/// **'FRUITSHORTS'**
/// **'NONTONKI · DEMO'**
String get brandMark;
/// No description provided for @shareFooter.
///
/// In en, this message translates to:
/// **'FruitShorts'**
/// **'Nontonki demo'**
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
@@ -1156,7 +856,7 @@ class _AppLocalizationsDelegate
@override
bool isSupported(Locale locale) =>
<String>['en', 'id', 'zh'].contains(locale.languageCode);
<String>['en', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
@@ -1167,8 +867,6 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'id':
return AppLocalizationsId();
case 'zh':
return AppLocalizationsZh();
}
+5 -153
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
@@ -9,7 +11,7 @@ class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appName => 'FruitShorts';
String get appName => 'Nontonki';
@override
String get demo => 'DEMO';
@@ -294,12 +296,6 @@ 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.';
@@ -403,152 +399,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get preferencesHeading => 'PREFERENCES';
@override
String get brandMark => 'FRUITSHORTS';
String get brandMark => 'NONTONKI · DEMO';
@override
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';
String get shareFooter => 'Nontonki demo';
}
-556
View File
@@ -1,556 +0,0 @@
// 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';
}
+5 -153
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
@@ -9,7 +11,7 @@ class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appName => 'FruitShorts';
String get appName => 'Nontonki';
@override
String get demo => '演示';
@@ -289,12 +291,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get catalogError => '剧目列表加载失败。';
@override
String get detailError => '剧集信息加载失败。';
@override
String get noMediaNotice => '该剧集还没有可播放的片源。';
@override
String get noStories => '暂无剧目。';
@@ -395,152 +391,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get preferencesHeading => '偏好设置';
@override
String get brandMark => 'FRUITSHORTS';
String get brandMark => 'NONTONKI · 演示';
@override
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 => '暂不';
String get shareFooter => 'Nontonki 演示';
}
+3 -53
View File
@@ -1,6 +1,6 @@
{
"@@locale": "zh",
"appName": "FruitShorts",
"appName": "Nontonki",
"demo": "演示",
"homeHeadline": "即刻观看",
"homeSubtitle": "免费短剧预览。",
@@ -91,8 +91,6 @@
"lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。",
"playbackError": "此视频暂时无法播放。",
"catalogError": "剧目列表加载失败。",
"detailError": "剧集信息加载失败。",
"noMediaNotice": "该剧集还没有可播放的片源。",
"noStories": "暂无剧目。",
"retry": "重试",
"backToSeries": "返回剧集详情",
@@ -126,54 +124,6 @@
"genreDramaMystery": "剧情 · 悬疑",
"continueWatchingHeading": "继续观看",
"preferencesHeading": "偏好设置",
"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": "暂不"
"brandMark": "NONTONKI · 演示",
"shareFooter": "Nontonki 演示"
}
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app.dart';
-6
View File
@@ -1,6 +0,0 @@
import 'auth_logic.dart';
/// 与其它页保持一致的三层写法:binding 负责装配,logic 放状态与逻辑,view 只渲染。
class AuthBinding {
AuthLogic create({bool signUpMode = false}) => AuthLogic(signUpMode: signUpMode);
}
-50
View File
@@ -1,50 +0,0 @@
/// 登录 / 注册页的表单状态与提交逻辑。
///
/// 校验规则与后端一致(邮箱格式、密码 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,
}
-227
View File
@@ -1,227 +0,0 @@
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,
),
),
),
],
);
}
}
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'discover_logic.dart';
+10 -16
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
@@ -18,7 +20,6 @@ class DiscoverLogic extends ChangeNotifier {
final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController();
int category = 0;
bool searchOpen = false;
bool _disposed = false;
void notify() {
@@ -26,7 +27,11 @@ class DiscoverLogic extends ChangeNotifier {
}
void refreshPosition() {
position = history.resumePositionFor(demoProfileId, drama);
position = history.positionFor(
demoProfileId,
drama.id,
drama.episodes.first.id,
);
}
void selectCategory(int index) {
@@ -36,14 +41,6 @@ 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,
@@ -59,8 +56,6 @@ class DiscoverLogic extends ChangeNotifier {
notify();
}
if (active && searchRequest != previousSearchRequest) {
searchOpen = true;
notify();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isActive()) searchFocus.requestFocus();
});
@@ -120,9 +115,8 @@ class DiscoverLogic extends ChangeNotifier {
void showFilters(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final labels = [
l10n.forYou,
l10n.hot,
l10n.newest,
l10n.featured,
l10n.suspense,
l10n.romance,
l10n.actionGenre,
];
@@ -139,7 +133,7 @@ class DiscoverLogic extends ChangeNotifier {
ListTile(
title: Text(labels[index]),
trailing: index == category
? const Icon(Icons.check_rounded, color: Color(0xFF2F7BFF))
? const Icon(Icons.check_rounded, color: Color(0xFFFF5B3D))
: null,
onTap: () {
selectCategory(index);
+452 -370
View File
@@ -1,17 +1,14 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/formatting/localized_content.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({
@@ -23,10 +20,6 @@ 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,
@@ -39,10 +32,6 @@ 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;
@@ -61,7 +50,6 @@ class _DiscoverPageState extends State<DiscoverPage> {
drama: widget.drama,
history: widget.history,
);
if (widget.focusSearch) logic.searchOpen = true;
}
@override
@@ -94,64 +82,50 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final catalog = context.watch<CatalogController>();
final allDramas = widget.dramas ?? [logic.drama];
final cards = _cardsFor(
allDramas,
catalog.sections,
logic.category,
'',
);
final cards = _cardsFor(l10n, logic.category, logic.search.text);
final content = SafeArea(
bottom: !widget.embedded,
child: Column(
children: [
_header(l10n),
_topBar(l10n),
Expanded(
child: ListView(
controller: logic.scroll,
padding: const EdgeInsets.only(bottom: 24),
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
children: [
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),
],
],
),
_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]),
),
),
],
),
),
@@ -170,233 +144,412 @@ class _DiscoverPageState extends State<DiscoverPage> {
);
}
Widget _header(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
Widget _topBar(AppLocalizations l10n) => SizedBox(
height: 51,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const _BrandMark(),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.appName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
height: 1.1,
),
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),
),
),
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),
),
],
);
}
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: [
Expanded(
child: Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.2,
),
),
),
const SizedBox(width: 8),
Text(
l10n.seeAll,
style: const TextStyle(
color: AppColors.blue,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
);
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,
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,
),
),
),
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],
),
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)),
),
],
),
),
if (drama == null)
const ColoredBox(
color: Color(0x99000000),
child: Center(
child: Icon(Icons.image_outlined, color: Colors.white),
),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.continueWatchingHeading,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFFFA490),
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
localizedDramaTitle(l10n, widget.drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
l10n.episodeProgress(1, widget.drama.episodes.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 11,
),
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: progress,
minHeight: 3,
backgroundColor: const Color(0xFF474248),
color: _orange,
),
),
],
),
),
const SizedBox(width: 9),
FilledButton.icon(
onPressed: () => logic.act(
context,
DiscoverPageAction.resume,
embedded: widget.embedded,
onAction: widget.onAction,
),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFBD5C),
foregroundColor: const Color(0xFF2A160B),
padding: const EdgeInsets.symmetric(horizontal: 10),
minimumSize: const Size(0, 32),
textStyle: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 14),
label: Text(l10n.resume),
),
],
),
);
},
);
Widget _quickActions(AppLocalizations l10n) {
final actions = [
(Icons.tune_rounded, l10n.filter, () => logic.showFilters(context)),
(Icons.bar_chart_rounded, l10n.topCharts, () => logic.catalogInfo(context)),
(
Icons.auto_awesome_rounded,
l10n.newReleases,
() => logic.catalogInfo(context),
),
(
Icons.bookmark_outline_rounded,
l10n.watchlist,
() {
logic.act(
context,
DiscoverPageAction.watchlist,
embedded: widget.embedded,
onAction: widget.onAction,
);
},
),
];
return Row(
children: [
for (var index = 0; index < actions.length; index++) ...[
Expanded(
child: InkWell(
onTap: actions[index].$3,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 77,
padding: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: const Color(0xFF202126),
borderRadius: BorderRadius.circular(12),
),
Positioned(
left: 10,
right: 10,
bottom: 10,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
card.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.2,
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: 3),
const SizedBox(height: 7),
Text(
drama == null ? l10n.videoPending : card.genre,
actions[index].$2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xE6FFFFFF),
fontSize: 11,
fontWeight: FontWeight.w400,
height: 1.2,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
),
],
),
),
),
),
if (index < actions.length - 1) const SizedBox(width: 7),
],
],
);
}
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,
),
Widget _sectionTitle(AppLocalizations l10n) => Row(
children: [
Container(width: 3, height: 20, color: _orange),
const SizedBox(width: 7),
Expanded(
child: Text(
l10n.trendingMicroDramas,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
),
),
TextButton(
onPressed: () => logic.selectCategory(0),
child: Text(
l10n.seeAll,
style: const TextStyle(color: Color(0xFFFFB3A1)),
),
),
],
);
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)),
),
Positioned(
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: playable ? _orange : const Color(0xDD37343B),
borderRadius: BorderRadius.circular(7),
),
child: Text(
playable ? l10n.free : l10n.artworkOnly,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
),
),
Positioned(
bottom: 10,
left: 8,
right: 8,
child: Text(
playable ? l10n.freeTestMedia : l10n.videoPending,
style: const TextStyle(
color: Color(0xFFFFC789),
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
},
),
const SizedBox(height: 6),
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),
),
],
),
);
}
@@ -486,131 +639,60 @@ 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(
List<Drama> dramas,
List<FeedSection> sections,
AppLocalizations l10n,
int category,
String query,
) {
final source = switch (category) {
0 => dramas,
_ => category < sections.length ? sections[category].dramas : const <Drama>[],
};
final normalized = query.trim().toLowerCase();
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,
),
final cards = [
_ShowcaseCard(
title: l10n.dramaSignalTitle,
genre: l10n.genreSuspenseMystery,
image: 'assets/images/stitch_signal_poster_clean.png',
category: 1,
dramaId: 'the-signal-demo',
),
_ShowcaseCard(
title: l10n.dramaCityTitle,
genre: l10n.genreUrbanRomance,
image: 'assets/images/stitch_city_poster_clean.png',
category: 2,
dramaId: 'city-lights-demo',
),
_ShowcaseCard(
title: l10n.dramaVengeanceTitle,
genre: l10n.genreActionThriller,
image: 'assets/images/stitch_vengeance_poster_clean.png',
category: 3,
dramaId: 'vengeance-demo',
),
_ShowcaseCard(
title: l10n.dramaEchoesTitle,
genre: l10n.genreDramaMystery,
image: 'assets/images/stitch_rain_scene.jpg',
category: 1,
),
];
final normalized = query.trim().toLowerCase();
return cards.where((card) {
final categoryMatches = category == 0 || card.category == category;
final queryMatches =
normalized.isEmpty ||
card.title.toLowerCase().contains(normalized) ||
card.genre.toLowerCase().contains(normalized);
return categoryMatches && queryMatches;
}).toList();
}
const _orange = 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),
);
}
}
const _orange = Color(0xFFFF5B3D);
@@ -1,206 +0,0 @@
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,3 +1,5 @@
// Author: fruit.com.cn
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'drama_detail_logic.dart';
+3 -32
View File
@@ -1,21 +1,16 @@
// 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});
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
Drama drama;
final Drama drama;
final WatchHistoryRepository history;
bool loading = false;
bool failed = false;
/// 接口给出的失败原因;为空时界面回退到本地文案。
String? failureMessage;
int? continueIndex;
bool _disposed = false;
@@ -23,30 +18,6 @@ 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);
+6 -44
View File
@@ -1,12 +1,9 @@
import 'dart:async';
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'drama_detail_binding.dart';
import 'drama_detail_logic.dart';
@@ -26,15 +23,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
void initState() {
super.initState();
logic = DramaDetailBinding().create(drama: widget.drama);
_load();
}
void _load() {
unawaited(
logic.load(
() => context.read<CatalogController>().requireDetail(widget.drama),
),
);
logic.loadContinue();
}
@override
@@ -53,34 +42,6 @@ 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(
@@ -91,7 +52,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
backgroundColor: const Color(0xFF111111),
foregroundColor: Colors.white,
flexibleSpace: FlexibleSpaceBar(
background: RemoteImage(drama.posterUrl),
background: Image.asset(drama.posterAsset, fit: BoxFit.cover),
),
),
SliverPadding(
@@ -102,7 +63,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
l10n.technicalDemoCount(drama.episodes.length),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
letterSpacing: 1.4,
color: const Color(0xFF2F7BFF),
color: const Color(0xFF9278EA),
fontWeight: FontWeight.bold,
),
),
@@ -153,11 +114,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
minTileHeight: 76,
leading: CircleAvatar(
backgroundColor: const Color(
0xFF2F7BFF,
0xFFFF6B45,
).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),
),
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import '../../core/data/models/drama.dart';
import 'home_logic.dart';
+32
View File
@@ -0,0 +1,32 @@
// 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]];
}
+11 -38
View File
@@ -1,19 +1,18 @@
// 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 = 1;
int tab = 0;
int searchRequest = 0;
int sectionRequest = 0;
int resumeRequest = 0;
@@ -22,7 +21,6 @@ class HomeLogic extends ChangeNotifier {
bool drawerOpen = false;
bool detailOpen = false;
bool videoImmersive = false;
bool profile = false;
bool _disposed = false;
void notify() {
@@ -66,7 +64,9 @@ 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,34 +82,6 @@ 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:
@@ -117,7 +89,7 @@ class HomeLogic extends ChangeNotifier {
case DiscoverPageAction.my:
select(2);
case DiscoverPageAction.watchlist:
openLibrary();
select(2, watchlist: true);
case DiscoverPageAction.resume:
select(0, resume: true);
case DiscoverPageAction.detail:
@@ -142,10 +114,11 @@ class HomeLogic extends ChangeNotifier {
} else if (videoImmersive) {
backRequest++;
notify();
} else if (tab != 1) {
select(1);
} else if (tab != 0) {
select(0);
} else {
openMenu();
backRequest++;
notify();
}
}
+29 -72
View File
@@ -1,13 +1,10 @@
import 'package:flutter/foundation.dart';
// Author: fruit.com.cn
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';
@@ -34,6 +31,7 @@ class HomePage extends StatelessWidget {
CatalogController catalog,
AppLocalizations l10n,
) {
if (catalog.loading) {
return const Scaffold(
backgroundColor: Colors.black,
@@ -50,13 +48,6 @@ 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),
),
],
],
),
),
@@ -103,11 +94,8 @@ 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, _) {
@@ -121,8 +109,8 @@ class _HomeTabsState extends State<_HomeTabs> {
drama: logic.currentDrama,
onOpenSeries: () => logic.openCurrentSeries(context),
onDiscover: () => logic.selectFromMenu(1),
onHistory: () => logic.openLibrary(section: 0),
onWatchlist: logic.openLibrary,
onHistory: () => logic.selectFromMenu(2, mySection: 0),
onWatchlist: () => logic.selectFromMenu(2, mySection: 1),
onLiked: () => logic.selectFromMenu(2, mySection: 2),
),
drawerEnableOpenDragGesture: false,
@@ -141,7 +129,7 @@ class _HomeTabsState extends State<_HomeTabs> {
resumeRequest: logic.resumeRequest,
backRequest: logic.backRequest,
onDiscover: () => logic.select(1),
onMy: logic.openProfile,
onMy: () => logic.select(2),
onSearch: () => logic.select(1, search: true),
onMenu: logic.openMenu,
onImmersiveChanged: logic.onImmersiveChanged,
@@ -149,15 +137,11 @@ class _HomeTabsState extends State<_HomeTabs> {
DiscoverPage(
drama: logic.currentDrama,
dramas: widget.dramas,
onDramaSelected: (drama) => logic.playDrama(context, drama),
onDramaSelected: (drama) => logic.openDrama(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(
@@ -166,15 +150,10 @@ 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(),
),
],
),
@@ -190,38 +169,28 @@ class _HomeTabsState extends State<_HomeTabs> {
border: Border(top: BorderSide(color: Color(0xFF242329))),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_TabButton(
key: const Key('tab-discover'),
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,
icon: Icons.play_circle_outline_rounded,
label: l10n.homeTab,
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,
key: const Key('tab-discover'),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
selected: logic.tab == 1,
onTap: () => logic.select(1),
),
_TabButton(
key: const Key('tab-my'),
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
icon: Icons.video_library_outlined,
label: l10n.myTab,
selected: logic.tab == 2 && logic.profile,
onTap: logic.openProfile,
selected: logic.tab == 2,
onTap: () => logic.select(2),
),
],
),
@@ -236,47 +205,35 @@ 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) => Expanded(
child: InkWell(
onTap: onTap,
Widget build(BuildContext context) => InkWell(
onTap: onTap,
child: SizedBox(
width: 80,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
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,
),
Icon(
icon,
size: 21,
color: selected ? const Color(0xFFFF5C37) : const Color(0xFFE6E1DE),
),
const SizedBox(height: 2),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? AppColors.blue : AppColors.muted,
color: selected
? const Color(0xFFFF5C37)
: const Color(0xFFE6E1DE),
),
),
],
@@ -0,0 +1,37 @@
// 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,
);
}
@@ -0,0 +1,172 @@
// 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,
),
),
],
),
),
],
);
}
+16 -5
View File
@@ -1,10 +1,11 @@
// 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.
@@ -28,7 +29,7 @@ class HomeQuickDrawer extends StatelessWidget {
static const _panel = Color(0xFF222225);
static const _card = Color(0xFF303033);
static const _orange = Color(0xFF2F7BFF);
static const _orange = Color(0xFFFF5C37);
@override
Widget build(BuildContext context) {
@@ -89,10 +90,11 @@ class HomeQuickDrawer extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: RemoteImage(
drama.posterUrl,
child: Image.asset(
drama.posterAsset,
width: 74,
height: 100,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
@@ -111,9 +113,18 @@ class HomeQuickDrawer extends StatelessWidget {
),
const SizedBox(height: 7),
Text(
l10n.episodeCount(drama.episodeCount),
l10n.episodeCount(drama.episodes.length),
style: const TextStyle(color: Color(0xFFE7E2E0)),
),
const SizedBox(height: 5),
Text(
l10n.freeTestMedia,
maxLines: 2,
style: const TextStyle(
color: Color(0xFFFFB99E),
fontSize: 11,
),
),
],
),
),
+468
View File
@@ -0,0 +1,468 @@
// 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),
),
],
),
);
}
}
+2
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'mine_logic.dart';
+7 -1
View File
@@ -1,3 +1,5 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -29,7 +31,11 @@ class MineLogic extends ChangeNotifier {
}
void refreshPosition() {
position = history.resumePositionFor(demoProfileId, drama);
position = history.positionFor(
demoProfileId,
drama.id,
drama.episodes.first.id,
);
}
void selectSection(int index) {
+218 -274
View File
@@ -1,13 +1,12 @@
// 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';
@@ -18,16 +17,11 @@ 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,
});
@@ -35,16 +29,11 @@ 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;
@@ -99,7 +88,7 @@ class _MyPageState extends State<MyPage> {
bottom: !widget.embedded,
child: Column(
children: [
if (!widget.profile) _topBar(l10n),
_topBar(l10n),
Expanded(
child: FutureBuilder<Duration?>(
future: logic.position,
@@ -114,13 +103,15 @@ class _MyPageState extends State<MyPage> {
1.0,
);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
children: [
if (widget.profile)
..._accountPage(l10n)
else ...[
_library(l10n, position, total, fraction),
],
_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),
const SizedBox(height: 24),
Center(
child: Text(
@@ -158,8 +149,8 @@ class _MyPageState extends State<MyPage> {
const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27),
const SizedBox(width: 7),
Text(
l10n.myListTab,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
l10n.myTab,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
const Spacer(),
IconButton(
@@ -183,247 +174,158 @@ class _MyPageState extends State<MyPage> {
),
);
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,
Widget _profile(AppLocalizations l10n) => Row(
children: [
const CircleAvatar(
radius: 30,
backgroundColor: Color(0xFF32343A),
child: Icon(Icons.person_rounded, color: Colors.white, size: 34),
),
),
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),
),
];
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 _guestRow(AppLocalizations l10n) {
return Row(
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,
children: [
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,
Row(
children: [
const Icon(Icons.history_rounded, color: _orange, size: 17),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
uidText,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
],
),
),
Text(
l10n.episodeProgress(1, widget.drama.episodes.length),
style: const TextStyle(color: Color(0xFFFFA38F), fontSize: 10),
),
],
),
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(),
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: 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),
),
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,
@@ -453,25 +355,14 @@ class _MyPageState extends State<MyPage> {
for (var index = 0; index < tabs.length; index++) ...[
InkWell(
onTap: () => logic.selectSection(index),
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
child: Text(
tabs[index],
style: TextStyle(
color: logic.section == index
? AppColors.blue
: const Color(0xFF24262E),
borderRadius: BorderRadius.circular(20),
),
child: Text(
tabs[index],
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w700,
),
? _orange
: const Color(0xFFE4DBDD),
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
@@ -557,7 +448,7 @@ class _MyPageState extends State<MyPage> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.blueSoft,
color: Color(0xFFFFA18D),
fontSize: 10,
),
),
@@ -618,7 +509,12 @@ class _MyPageState extends State<MyPage> {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: RemoteImage(drama.posterUrl, width: 74, height: 103),
child: Image.asset(
drama.posterAsset,
width: 74,
height: 103,
fit: BoxFit.cover,
),
),
const SizedBox(width: 11),
Expanded(
@@ -636,7 +532,7 @@ class _MyPageState extends State<MyPage> {
),
const SizedBox(height: 7),
Text(
l10n.episodeCount(drama.episodeCount),
l10n.episodeCount(drama.episodes.length),
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
@@ -651,6 +547,49 @@ 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(
@@ -692,7 +631,12 @@ class _MyPageState extends State<MyPage> {
Widget _poster({required double width, required double height}) => ClipRRect(
borderRadius: BorderRadius.circular(7),
child: RemoteImage(widget.drama.posterUrl, width: width, height: height),
child: Image.asset(
widget.drama.posterAsset,
width: width,
height: height,
fit: BoxFit.cover,
),
);
}
@@ -741,4 +685,4 @@ String _clock(Duration duration) {
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}';
}
const _orange = AppColors.blue;
const _orange = Color(0xFFFF5B3D);
+2
View File
@@ -1,3 +1,5 @@
// 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';
+97 -164
View File
@@ -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,6 +18,7 @@ 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';
@@ -31,7 +32,10 @@ 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;
@@ -45,8 +49,6 @@ 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;
@@ -67,24 +69,6 @@ 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();
}
@@ -115,13 +99,17 @@ class PlayerLogic extends ChangeNotifier {
queue = feedDramas == null
? PlaybackQueue.series(pageDrama)
: PlaybackQueue.home(feedDramas!);
visibleIndices = List<int>.generate(queue.length, (index) => index);
homeSections = HomeFeedSections([
for (final entry in queue.entries) entry.drama,
]);
visibleIndices = homeMode
? homeSections.indicesFor(category)
: 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.videoUrl.isNotEmpty,
canPlay: (episode) => episode.isFree,
prepare: preparePlayer,
activate: activatePlayer,
coordinator: coordinator,
@@ -149,10 +137,8 @@ class PlayerLogic extends ChangeNotifier {
if (isMounted() && backRequest == request) {
if (fullscreen) {
unawaited(setFullscreen(false));
} else if (screenCleared) {
setScreenCleared(false);
} else {
onMenu?.call();
shiftCategory(-1);
}
}
});
@@ -186,7 +172,7 @@ class PlayerLogic extends ChangeNotifier {
super.dispose();
}
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
void notifyImmersive() => onImmersiveChanged?.call(immersive);
Future<void> setFullscreen(bool value) async {
if (fullscreen == value || !isMounted()) return;
@@ -208,7 +194,7 @@ class PlayerLogic extends ChangeNotifier {
} catch (_) {
if (value && isMounted()) {
fullscreen = false;
notify();
notify();
notifyImmersive();
}
}
@@ -242,7 +228,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)),
@@ -343,7 +329,7 @@ class PlayerLogic extends ChangeNotifier {
curve: Curves.easeOutCubic,
);
} catch (_) {
// The page can leave the tree before the animation finishes.
// A category change can detach the old page controller mid-animation.
}
}
@@ -387,6 +373,49 @@ 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;
@@ -399,11 +428,7 @@ 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;
@@ -441,31 +466,12 @@ 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) {
@@ -475,57 +481,49 @@ class PlayerLogic extends ChangeNotifier {
try {
await showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF16171C),
backgroundColor: const Color(0xFF202024),
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: SizedBox(
height: MediaQuery.sizeOf(sheetContext).height * 0.46,
height: MediaQuery.sizeOf(context).height * 0.6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Text(
l10n.episodes,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Text(
localizedDramaTitle(l10n, drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFB7B3B8),
fontSize: 14,
fontWeight: FontWeight.w400,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.45,
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}'),
),
itemCount: episodes.length,
itemBuilder: (_, index) => _episodeCell(
context,
sheetContext,
drama,
episodes[index],
index,
homeFeed,
currentNumber,
),
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,
),
);
},
);
},
),
),
],
@@ -538,68 +536,6 @@ 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();
@@ -640,10 +576,7 @@ 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;
@@ -670,8 +603,7 @@ 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 {
@@ -731,4 +663,5 @@ class PlayerLogic extends ChangeNotifier {
session.setOverlayOpen(false);
}
}
}
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