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
169 changed files with 4056 additions and 17639 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,已从打包清单移除)。
## 当前说明
收藏、点赞、评论与观看进度仍保存在本机(后端互动接口尚未开发);
金币与会员相关界面仍是占位数据。
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
+3
View File
@@ -32,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
}
}
}
@@ -6,6 +6,4 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
<application android:usesCleartextTraffic="true"/>
</manifest>
+1 -2
View File
@@ -1,9 +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
@@ -6,6 +6,4 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
<application android:usesCleartextTraffic="true"/>
</manifest>
-74
View File
@@ -1,74 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
import 'package:integration_test/integration_test.dart';
/// 设备侧集成测试:**在手机进程里**真实请求开发机上的 app-api。
///
/// 跑法(红线第 10 条要求的"设备侧取证"):
/// ```
/// IP=$(ipconfig getifaddr en0)
/// flutter test integration_test -d <device-id> --dart-define=API_BASE_URL=http://$IP:8081
/// ```
/// 断言失败时会把实际使用的地址一起打出来,便于区分"地址不对"还是"设备连不上"。
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final baseUrl = AppConfig.apiBaseUrl;
testWidgets('should_fetch_home_feed_and_drama_detail_when_device_reaches_backend', (
tester,
) async {
debugPrint('[device] API_BASE_URL=$baseUrl');
final client = ApiClient(baseUrl: baseUrl);
addTearDown(client.close);
final api = CatalogApi(client);
final feed = await api.homeFeed();
expect(
feed.dramas,
isNotEmpty,
reason: '设备侧拿不到首页列表(API_BASE_URL=$baseUrl)',
);
debugPrint('[device] 首页分区=${feed.sections.length} 剧目=${feed.dramas.length}');
final card = feed.dramas.first;
expect(card.id, isNotEmpty, reason: '设备侧首页卡片 id 为空');
final detail = await api.dramaDetail(card.id);
expect(detail.id, card.id, reason: '详情返回的 id 与卡片不一致');
expect(
detail.episodes,
isNotEmpty,
reason: '设备侧拿到的详情没有剧集(dramaId=${card.id})',
);
debugPrint('[device] 详情 id=${detail.id} 剧集=${detail.episodes.length}');
});
testWidgets('should_render_home_drama_list_when_app_boots_on_device', (
tester,
) async {
// 用真 App(真网络、真目录控制器):首页底部标签栏出现即代表"剧目列表加载成功",
// 也就是用户在真机上遇到的那个「剧目列表加载失败」已经消失。
await tester.pumpWidget(const LumaDramaApp());
var loaded = false;
for (var attempt = 0; attempt < 60 && !loaded; attempt++) {
await tester.pump(const Duration(milliseconds: 500));
loaded = find.byKey(const Key('home-tabs-bar')).evaluate().isNotEmpty;
}
expect(
loaded,
isTrue,
reason: '首页 30 秒内没渲染出剧目列表(API_BASE_URL=$baseUrl)',
);
debugPrint('[device] 首页剧目列表已渲染:API_BASE_URL=$baseUrl');
});
}
+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>
+41 -169
View File
@@ -1,191 +1,63 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'core/config/locale_controller.dart';
import 'core/data/remote/api_client.dart';
import 'core/data/remote/auth_api.dart';
import 'core/data/remote/catalog_api.dart';
import 'core/data/remote/category_api.dart';
import 'core/data/remote/comment_api.dart';
import 'core/data/remote/download_api.dart';
import 'core/data/remote/interaction_api.dart';
import 'core/data/remote/library_api.dart';
import 'core/data/remote/playback_api.dart';
import 'core/data/remote/watch_api.dart';
import 'core/data/repositories/auth_repository.dart';
import 'core/data/repositories/catalog_repository.dart';
import 'core/data/repositories/category_repository.dart';
import 'core/data/repositories/comment_repository.dart';
import 'core/data/repositories/download_repository.dart';
import 'core/data/repositories/favorite_repository.dart';
import 'core/data/repositories/interaction_repository.dart';
import 'core/data/repositories/playback_repository.dart';
import 'core/data/repositories/watch_history_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/category_controller.dart';
import 'core/data/services/download_controller.dart';
import 'core/data/services/favorite_controller.dart';
import 'core/data/services/interaction_controller.dart';
import 'core/data/services/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,
this.categoryRepository,
this.downloadRepository,
this.watchHistoryRepository,
this.favoriteRepository,
this.playbackRepository,
this.commentRepository,
this.interactionRepository,
});
class LumaDramaApp extends StatelessWidget {
const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new});
final PlayerFactory playerFactory;
/// 测试注入用;生产走 HTTP 仓库。
final CatalogRepository? catalogRepository;
final CategoryRepository? categoryRepository;
final DownloadRepository? downloadRepository;
final WatchHistoryRepository? watchHistoryRepository;
final FavoriteRepository? favoriteRepository;
final PlaybackRepository? playbackRepository;
final CommentRepository? commentRepository;
final InteractionRepository? interactionRepository;
@override
State<LumaDramaApp> createState() => _LumaDramaAppState();
}
class _LumaDramaAppState extends State<LumaDramaApp> {
late final AuthSessionStore _sessionStore = AuthSessionStore();
/// 全 App 共用一个 ApiClient:这样目录、账号、后续的互动/进度请求
/// 都自动带 `Authorization` 与 `X-Device-Id`,401 的刷新重试也只有一处实现。
late final ApiClient _apiClient = ApiClient(
accessTokenReader: () => _auth.accessToken,
tokenRefresher: () => _auth.refresh(),
onUnauthorized: () => _auth.clear(),
deviceIdReader: _sessionStore.deviceId,
);
late final AuthController _auth = AuthController(
AuthApi(_apiClient),
_sessionStore,
);
late final CatalogRepository _catalogRepository =
widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient));
late final CategoryRepository _categoryRepository =
widget.categoryRepository ??
HttpCategoryRepository(CategoryApi(_apiClient), _catalogRepository);
late final WatchHistoryRepository _watchHistory =
widget.watchHistoryRepository ??
HttpWatchHistoryRepository(
WatchApi(_apiClient),
isSignedIn: () => _auth.isSignedIn,
);
late final FavoriteRepository _favorites =
widget.favoriteRepository ??
HttpFavoriteRepository(
LibraryApi(_apiClient),
isSignedIn: () => _auth.isSignedIn,
);
late final PlaybackRepository _playback =
widget.playbackRepository ??
HttpPlaybackRepository(PlaybackApi(_apiClient));
late final FavoriteController _favoriteController = FavoriteController(
_favorites,
);
late final DownloadRepository _downloadRepository =
widget.downloadRepository ??
HttpDownloadRepository(DownloadApi(_apiClient));
late final DownloadController _downloadController = DownloadController(
_downloadRepository,
);
late final CommentRepository _commentRepository =
widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient));
late final InteractionRepository _interactionRepository =
widget.interactionRepository ??
HttpInteractionRepository(InteractionApi(_apiClient));
late final InteractionController _interactionController =
InteractionController(_interactionRepository);
@override
void initState() {
super.initState();
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
_auth.load();
// 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联
_auth.addListener(_onAuthChanged);
if (_auth.isSignedIn) unawaited(_favoriteController.load());
}
bool _wasSignedIn = false;
void _onAuthChanged() {
if (_auth.isSignedIn == _wasSignedIn) return;
_wasSignedIn = _auth.isSignedIn;
_interactionController.clear();
if (_auth.isSignedIn) {
unawaited(_favoriteController.load());
} else {
_favoriteController.clear();
_downloadController.reset();
}
}
@override
void dispose() {
_auth.removeListener(_onAuthChanged);
_favoriteController.dispose();
_downloadController.dispose();
_interactionController.dispose();
_apiClient.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider(create: (_) => PlaybackCoordinator()),
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
Provider<PlaybackRepository>.value(value: _playback),
Provider<WatchHistoryRepository>.value(value: _watchHistory),
Provider<FavoriteRepository>.value(value: _favorites),
Provider<CommentRepository>.value(value: _commentRepository),
Provider<InteractionRepository>.value(value: _interactionRepository),
ChangeNotifierProvider(
create: (_) => CatalogController(_catalogRepository)..load(),
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: (_) => CategoryController(_categoryRepository)..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.value(value: _favoriteController),
ChangeNotifierProvider.value(value: _downloadController),
ChangeNotifierProvider.value(value: _interactionController),
ChangeNotifierProvider.value(value: _auth),
ChangeNotifierProvider(
create: (_) =>
EngagementController(SharedPreferencesEngagementRepository())
..load(),
),
],
child: Consumer<LocaleController>(
builder: (context, locale, _) => MaterialApp(
@@ -202,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';
}
}
+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(),
};
}
-132
View File
@@ -1,132 +0,0 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 评论游标分页大小,契约 §1.7 使用游标而非页码。
const int commentPageSize = 20;
/// 评论列表页。
class CommentPage {
const CommentPage({
this.comments = const [],
this.nextCursor,
this.hasMore = false,
});
final List<DramaComment> comments;
final String? nextCursor;
final bool hasMore;
/// 解析后端 `CursorResult{list,nextCursor,hasMore}`。
factory CommentPage.fromJson(Map<String, dynamic> json) {
final rawList = json['list'];
return CommentPage(
comments: [
if (rawList is List)
for (final item in rawList)
if (item is Map<String, dynamic>) DramaComment.fromJson(item),
],
nextCursor: json['nextCursor'] == null
? null
: jsonToId(json['nextCursor']),
hasMore: json['hasMore'] == true,
);
}
}
/// 服务端评论视图,字段对应 `CommentRespVO`。
class DramaComment {
const DramaComment({
required this.id,
required this.dramaId,
required this.episodeId,
required this.parentId,
required this.rootId,
required this.userId,
required this.uid,
required this.nickname,
required this.avatarUrl,
required this.content,
required this.status,
required this.auditReason,
required this.likeCount,
required this.replyCount,
required this.reportCount,
required this.createdAt,
required this.mine,
required this.liked,
});
final String id;
final String dramaId;
final String episodeId;
final String parentId;
final String rootId;
final String userId;
final String uid;
final String nickname;
final String avatarUrl;
final String content;
final String status;
final String auditReason;
final int likeCount;
final int replyCount;
final int reportCount;
final DateTime? createdAt;
final bool mine;
final bool liked;
/// 待人工审核的评论仅本人可见。
bool get isPending => status == 'pending';
/// 顶层评论没有父评论。
bool get isTopLevel => parentId.isEmpty || parentId == '0';
/// 解析 `CommentRespVO`,所有 ID 字段只走字符串解析。
factory DramaComment.fromJson(Map<String, dynamic> json) => DramaComment(
id: jsonToId(json['id']),
dramaId: jsonToId(json['dramaId']),
episodeId: jsonToId(json['episodeId'], fallback: '0'),
parentId: jsonToId(json['parentId'], fallback: '0'),
rootId: jsonToId(json['rootId'], fallback: '0'),
userId: jsonToId(json['userId']),
uid: json['uid']?.toString() ?? '',
nickname: json['nickname']?.toString() ?? '',
avatarUrl: json['avatarUrl']?.toString() ?? '',
content: json['content']?.toString() ?? '',
status: json['status']?.toString() ?? '',
auditReason: json['auditReason']?.toString() ?? '',
likeCount: jsonToInt(json['likeCount']),
replyCount: jsonToInt(json['replyCount']),
reportCount: jsonToInt(json['reportCount']),
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
mine: json['mine'] == true,
liked: json['liked'] == true,
);
/// 局部更新点赞或回复数,列表中的其他字段保持不变。
DramaComment copyWith({
int? likeCount,
int? replyCount,
bool? liked,
}) => DramaComment(
id: id,
dramaId: dramaId,
episodeId: episodeId,
parentId: parentId,
rootId: rootId,
userId: userId,
uid: uid,
nickname: nickname,
avatarUrl: avatarUrl,
content: content,
status: status,
auditReason: auditReason,
likeCount: likeCount ?? this.likeCount,
replyCount: replyCount ?? this.replyCount,
reportCount: reportCount,
createdAt: createdAt,
mine: mine,
liked: liked ?? this.liked,
);
}
@@ -1,17 +0,0 @@
// Author: fruit.com.cn
/// 服务端内容分类。id 按契约保持字符串,避免雪花 ID 精度丢失。
class ContentCategory {
const ContentCategory({required this.id, required this.name});
final String id;
final String name;
/// 解析 `GET /app/categories` 的单项。
factory ContentCategory.fromJson(Map<String, dynamic> json) {
return ContentCategory(
id: (json['id'] ?? '').toString(),
name: json['name'] as String? ?? '',
);
}
}
@@ -1,67 +0,0 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 下载授权与任务进度合并视图,对应 `GET /app/downloads` 的列表项。
class DownloadLicense {
const DownloadLicense({
required this.licenseId,
required this.dramaId,
required this.dramaTitle,
required this.episodeId,
required this.episodeNo,
required this.deviceId,
required this.quality,
required this.playUrl,
required this.licenseKey,
required this.status,
required this.grantedAt,
required this.expireAt,
required this.taskStatus,
required this.progressPercent,
required this.downloadedBytes,
required this.totalBytes,
required this.errorMsg,
});
final String licenseId;
final String dramaId;
final String dramaTitle;
final String episodeId;
final int episodeNo;
final String deviceId;
final String quality;
final String playUrl;
final String licenseKey;
final String status;
final DateTime? grantedAt;
final DateTime? expireAt;
final String taskStatus;
final int progressPercent;
final int downloadedBytes;
final int totalBytes;
final String errorMsg;
/// 解析授权响应;所有 ID 与 Long 字段兼容字符串下发。
factory DownloadLicense.fromJson(Map<String, dynamic> json) {
return DownloadLicense(
licenseId: (json['licenseId'] ?? '').toString(),
dramaId: (json['dramaId'] ?? '').toString(),
dramaTitle: json['dramaTitle'] as String? ?? '',
episodeId: (json['episodeId'] ?? '').toString(),
episodeNo: jsonToInt(json['episodeNo']),
deviceId: json['deviceId'] as String? ?? '',
quality: json['quality'] as String? ?? '',
playUrl: json['playUrl'] as String? ?? '',
licenseKey: json['licenseKey'] as String? ?? '',
status: json['status'] as String? ?? '',
grantedAt: DateTime.tryParse(json['grantedAt'] as String? ?? ''),
expireAt: DateTime.tryParse(json['expireAt'] as String? ?? ''),
taskStatus: json['taskStatus'] as String? ?? '',
progressPercent: jsonToInt(json['progressPercent']),
downloadedBytes: jsonToInt(json['downloadedBytes']),
totalBytes: jsonToInt(json['totalBytes']),
errorMsg: json['errorMsg'] as String? ?? '',
);
}
}
-54
View File
@@ -1,54 +0,0 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 下载策略与当前设备配额,对应 `GET /app/downloads/policy`。
class DownloadPolicy {
const DownloadPolicy({
required this.maxPerDevice,
required this.licenseDays,
required this.wifiOnly,
required this.maxQuality,
required this.maxStorageBytes,
required this.used,
required this.remaining,
required this.expirePolicyText,
});
final int maxPerDevice;
final int licenseDays;
final bool wifiOnly;
final String maxQuality;
final int maxStorageBytes;
final int used;
final int remaining;
final String expirePolicyText;
/// 设置更新期间用于本地乐观展示。
DownloadPolicy copyWith({bool? wifiOnly, String? maxQuality}) {
return DownloadPolicy(
maxPerDevice: maxPerDevice,
licenseDays: licenseDays,
wifiOnly: wifiOnly ?? this.wifiOnly,
maxQuality: maxQuality ?? this.maxQuality,
maxStorageBytes: maxStorageBytes,
used: used,
remaining: remaining,
expirePolicyText: expirePolicyText,
);
}
/// 解析策略响应;Long 字段兼容字符串。
factory DownloadPolicy.fromJson(Map<String, dynamic> json) {
return DownloadPolicy(
maxPerDevice: jsonToInt(json['maxPerDevice']),
licenseDays: jsonToInt(json['licenseDays']),
wifiOnly: json['wifiOnly'] == true,
maxQuality: json['maxQuality'] as String? ?? '',
maxStorageBytes: jsonToInt(json['maxStorageBytes']),
used: jsonToInt(json['used']),
remaining: jsonToInt(json['remaining']),
expirePolicyText: json['expirePolicyText'] as String? ?? '',
);
}
}
+8 -101
View File
@@ -1,55 +1,21 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
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 {
@@ -57,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;
}
@@ -0,0 +1,13 @@
// Author: fruit.com.cn
class EngagementSnapshot {
const EngagementSnapshot({
this.savedDramaIds = const {},
this.likedDramaIds = const {},
this.commentsByDrama = const {},
});
final Set<String> savedDramaIds;
final Set<String> likedDramaIds;
final Map<String, List<String>> commentsByDrama;
}
-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? ?? '',
);
}
}
-70
View File
@@ -1,70 +0,0 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 互动目标类型,契约只允许短剧与评论。
enum InteractionTargetType {
drama('DRAMA'),
comment('COMMENT');
const InteractionTargetType(this.apiValue);
final String apiValue;
}
/// 客户端固定举报原因,后端当前未限制枚举值。
enum ReportReason {
spam('SPAM'),
abuse('ABUSE'),
porn('PORN'),
ad('AD'),
other('OTHER');
const ReportReason(this.apiValue);
final String apiValue;
}
/// 一次性互动请求,供仓库和测试断言使用。
class ReportRequest {
const ReportRequest({
required this.targetType,
required this.targetId,
required this.reasonCode,
this.description = '',
});
final InteractionTargetType targetType;
final String targetId;
final String reasonCode;
final String description;
}
/// 点赞切换后的服务端状态。
class LikeToggleResult {
const LikeToggleResult({required this.liked, required this.count});
final bool liked;
final int count;
/// 计数按 Long 字符串兼容解析。
factory LikeToggleResult.fromJson(Map<String, dynamic> json) =>
LikeToggleResult(
liked: json['liked'] == true,
count: jsonToInt(json['count']),
);
}
/// 控制器统一返回结果;失败消息可直接展示后端 `message`。
class OperationResult<T> {
const OperationResult._(this.data, this.errorMessage);
const OperationResult.success([T? data]) : this._(data, null);
const OperationResult.failure(this.errorMessage) : data = null;
final T? data;
final String? errorMessage;
bool get isSuccess => errorMessage == null;
}
-20
View File
@@ -1,20 +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;
}
/// ID 字段统一转字符串;后端按契约下发字符串,同时兼容测试或旧数据的数字形式。
String jsonToId(dynamic value, {String fallback = ''}) {
if (value is String) return value;
if (value is num) return value.toString();
return fallback;
}
-55
View File
@@ -1,55 +0,0 @@
import 'json_numbers.dart';
/// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。
class PlayTicket {
const PlayTicket({
required this.episodeId,
required this.accessible,
required this.accessType,
this.dramaId = '',
this.number = 0,
this.title = '',
this.playUrl = '',
this.durationSec = 0,
this.unlockCoin = 0,
this.previewSeconds = 0,
});
static const String free = 'FREE';
static const String coinRequired = 'COIN_REQUIRED';
static const String vipRequired = 'VIP_REQUIRED';
static const String noMedia = 'NO_MEDIA';
final String episodeId;
final String dramaId;
final int number;
final String title;
final bool accessible;
final String accessType;
/// 签名播放地址:**原样使用**(含 `?e=&s=`),客户端不拼接、不改写。
final String playUrl;
final int durationSec;
final int unlockCoin;
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
final int previewSeconds;
bool get needsUnlock =>
accessType == coinRequired || accessType == vipRequired;
factory PlayTicket.fromJson(Map<String, dynamic> json) {
return PlayTicket(
episodeId: (json['episodeId'] ?? '').toString(),
dramaId: (json['dramaId'] ?? '').toString(),
number: jsonToInt(json['number']),
title: json['title'] as String? ?? '',
accessible: json['accessible'] == true,
accessType: json['accessType'] as String? ?? noMedia,
playUrl: json['playUrl'] as String? ?? '',
durationSec: jsonToInt(json['durationSec']),
unlockCoin: jsonToInt(json['unlockCoin']),
previewSeconds: jsonToInt(json['previewSeconds']),
);
}
}
-65
View File
@@ -1,65 +0,0 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 服务端续看记录:`GET /app/watch/progress?dramaId=` 与 `/app/watch/recent` 的下发结构。
///
/// 服务端按 `(user, drama)` 只留一条,所以它是"这部剧看到哪一集的哪一秒",
/// 不是逐集进度。ID 与毫秒都是字符串(见 api-phase1.md §0),统一走 jsonToInt 解析。
class WatchProgress {
const WatchProgress({
required this.dramaId,
required this.episodeId,
required this.position,
required this.duration,
this.finished = false,
this.playCount = 0,
this.updatedAt,
});
final String dramaId;
final String episodeId;
final Duration position;
final Duration duration;
final bool finished;
final int playCount;
final DateTime? updatedAt;
bool get hasPosition => position > Duration.zero;
/// 进度比例 0~1,时长未知时为 0,避免 UI 出现 NaN。
double get fraction {
if (duration <= Duration.zero) return 0;
return (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0);
}
factory WatchProgress.fromJson(Map<String, dynamic> json) {
return WatchProgress(
dramaId: (json['dramaId'] ?? '').toString(),
episodeId: (json['episodeId'] ?? '').toString(),
position: Duration(milliseconds: jsonToInt(json['positionMs'])),
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
finished: json['finished'] == true,
playCount: jsonToInt(json['playCount']),
updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''),
);
}
Map<String, dynamic> toJson() => {
'dramaId': dramaId,
'episodeId': episodeId,
'positionMs': position.inMilliseconds,
'durationMs': duration.inMilliseconds,
'finished': finished,
'playCount': playCount,
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
};
}
/// 续看点:从哪一集的哪个位置继续看。
class ResumePoint {
const ResumePoint({required this.episodeId, required this.position});
final String episodeId;
final Duration position;
}
-44
View File
@@ -1,44 +0,0 @@
// Author: fruit.com.cn
import 'drama.dart';
import 'watch_progress.dart';
/// 观看记录页的一条数据:进度记录与按 dramaId 补取的详情卡片。
class WatchRecord {
const WatchRecord({
required this.progress,
this.drama,
this.detailLoading = true,
this.detailFailed = false,
});
final WatchProgress progress;
final Drama? drama;
final bool detailLoading;
final bool detailFailed;
String get dramaId => progress.dramaId;
/// 详情中按 episodeId 定位集号;缺失时返回 0。
int get episodeNumber {
final episodes = drama?.episodes ?? const <Episode>[];
for (var index = 0; index < episodes.length; index++) {
if (episodes[index].id == progress.episodeId) return index + 1;
}
return 0;
}
/// 生成卡片加载中、成功或失败后的新状态。
WatchRecord copyWith({
Drama? drama,
bool? detailLoading,
bool? detailFailed,
}) {
return WatchRecord(
progress: progress,
drama: drama ?? this.drama,
detailLoading: detailLoading ?? this.detailLoading,
detailFailed: detailFailed ?? this.detailFailed,
);
}
}
-154
View File
@@ -1,154 +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?> put(
String path, {
Object? body,
Map<String, String>? query,
bool skipAuthRetry = false,
}) {
return _request(
'PUT',
path,
body: body,
query: query,
skipAuthRetry: skipAuthRetry,
);
}
Future<Object?> delete(
String path, {
Object? body,
Map<String, String>? query,
}) {
return _request('DELETE', path, body: body, query: query);
}
Future<Object?> _request(
String method,
String path, {
Object? body,
Map<String, String>? query,
bool retried = false,
bool skipAuthRetry = false,
}) async {
final disableAuthRetry = skipAuthRetry || tokenRefresher == null;
try {
final response = await _dio.request<Object?>(
path,
data: body,
queryParameters: (query?.isEmpty ?? true) ? null : query,
options: Options(
method: method,
contentType: body == null ? null : Headers.jsonContentType,
extra: {
ApiRequestFlags.disableAuthRetry: disableAuthRetry,
ApiRequestFlags.retried: retried,
},
),
);
return response.data;
} on DioException catch (error) {
final cause = error.error;
if (cause is TokenExpiredException) {
final refreshed = await tokenRefresher?.call() ?? false;
if (refreshed) {
return _request(
method,
path,
body: body,
query: query,
retried: true,
skipAuthRetry: skipAuthRetry,
);
}
onUnauthorized?.call();
throw ApiException(401, cause.message ?? '登录已过期');
}
if (cause is ApiException) {
throw cause;
}
rethrow;
}
}
void close() => _dio.close(force: true);
}
-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,
);
}
}
-29
View File
@@ -1,29 +0,0 @@
import '../models/drama.dart';
import '../models/home_feed.dart';
import 'api_client.dart';
/// 客户端目录接口,对应后端 `/app/**`。
class CatalogApi {
const CatalogApi(this._client);
final ApiClient _client;
Future<HomeFeed> homeFeed() async {
return HomeFeed.fromJson(_asMap(await _client.get('/app/home/feed')));
}
Future<HomeFeed> discoverFeed() async {
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
}
Future<Drama> dramaDetail(String dramaId) async {
return Drama.fromDetailJson(
_asMap(await _client.get('/app/dramas/$dramaId')),
);
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '返回数据格式异常');
}
}
-21
View File
@@ -1,21 +0,0 @@
// Author: fruit.com.cn
import '../models/content_category.dart';
import 'api_client.dart';
/// 内容分类接口,对应契约 `GET /app/categories`。
class CategoryApi {
const CategoryApi(this._client);
final ApiClient _client;
/// 拉取已启用分类,id 全程按字符串解析。
Future<List<ContentCategory>> list() async {
final data = await _client.get('/app/categories');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) ContentCategory.fromJson(item),
];
}
}
-59
View File
@@ -1,59 +0,0 @@
// Author: fruit.com.cn
import '../models/comment.dart';
import 'api_client.dart';
/// 评论接口,对应契约 §1.7 的 `/app/comments*`。
class CommentApi {
const CommentApi(this._client);
final ApiClient _client;
/// 拉取游标分页;`rootId` 用于加载某一楼层的回复。
Future<CommentPage> page({
required String dramaId,
String? rootId,
String? cursor,
int size = commentPageSize,
}) async {
final data = await _client.get(
'/app/comments',
query: {
'dramaId': dramaId,
'rootId': ?rootId,
'cursor': ?cursor,
'size': '$size',
},
);
if (data is! Map<String, dynamic>) return const CommentPage();
return CommentPage.fromJson(data);
}
/// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。
Future<DramaComment> publish({
required String dramaId,
String? episodeId,
required String content,
String? parentId,
}) async {
final data = await _client.post(
'/app/comments',
body: {
'dramaId': dramaId,
if (episodeId != null && episodeId.isNotEmpty)
'episodeId': episodeId,
'content': content,
if (parentId != null && parentId.isNotEmpty) 'parentId': parentId,
},
);
if (data is! Map<String, dynamic>) {
throw const ApiException(500, '评论响应格式错误');
}
return DramaComment.fromJson(data);
}
/// 删除自己的评论。
Future<void> delete(String commentId) async {
await _client.delete('/app/comments/$commentId');
}
}
-101
View File
@@ -1,101 +0,0 @@
// Author: fruit.com.cn
import '../models/download_license.dart';
import '../models/download_policy.dart';
import 'api_client.dart';
/// 离线下载授权接口,对应契约 §1.6 的全部 8 个接口。
///
/// 设备号由 [ApiClient] 统一通过 `X-Device-Id` 发送,本类不另造设备参数。
class DownloadApi {
const DownloadApi(this._client);
final ApiClient _client;
/// 拉取配额和偏好策略。
Future<DownloadPolicy> policy() async {
return DownloadPolicy.fromJson(
_asMap(await _client.get('/app/downloads/policy')),
);
}
/// 更新仅 Wi-Fi 与最高清晰度偏好。
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) async {
await _client.put(
'/app/downloads/settings',
body: {'wifiOnly': ?wifiOnly, 'maxQuality': ?maxQuality},
);
}
/// 申请下载授权;同集重复申请由服务端保证幂等。
Future<DownloadLicense> grant(String episodeId, {String? quality}) async {
return DownloadLicense.fromJson(
_asMap(
await _client.post(
'/app/downloads',
body: {'episodeId': episodeId, 'quality': ?quality},
),
),
);
}
/// 拉取当前设备的授权列表。
Future<List<DownloadLicense>> list() async {
final data = await _client.get('/app/downloads');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) DownloadLicense.fromJson(item),
];
}
/// 续传前刷新短时效下载地址。
Future<DownloadLicense> refreshUrl(String episodeId) async {
return DownloadLicense.fromJson(
_asMap(await _client.get('/app/downloads/$episodeId/url')),
);
}
/// 上报下载进度;本批只提供方法,不启动文件落盘。
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
}) async {
return DownloadLicense.fromJson(
_asMap(
await _client.post(
'/app/downloads/$episodeId/progress',
body: {
'deviceId': ?deviceId,
'status': ?status,
'progressPercent': ?progressPercent,
'downloadedBytes': ?downloadedBytes,
'totalBytes': ?totalBytes,
'errorMsg': ?errorMsg,
},
),
),
);
}
/// 吊销单集授权。
Future<void> remove(String episodeId) async {
await _client.delete('/app/downloads/$episodeId');
}
/// 清空当前设备授权并返回清除条数。
Future<int> clear() async {
final data = await _client.delete('/app/downloads');
return data is int ? data : 0;
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '');
}
}
-44
View File
@@ -1,44 +0,0 @@
// Author: fruit.com.cn
import '../models/interaction.dart';
import 'api_client.dart';
/// 点赞与举报接口,对应契约 §1.7。
class InteractionApi {
const InteractionApi(this._client);
final ApiClient _client;
/// 切换点赞,服务端返回的状态与计数为最终真相。
Future<LikeToggleResult> toggleLike({
required InteractionTargetType targetType,
required String targetId,
}) async {
final data = await _client.post(
'/app/likes/toggle',
body: {
'targetType': targetType.apiValue,
'targetId': targetId,
},
);
if (data is! Map<String, dynamic>) {
throw const ApiException(500, '点赞响应格式错误');
}
return LikeToggleResult.fromJson(data);
}
/// 提交举报,返回工单 ID。
Future<String> report(ReportRequest request) async {
final data = await _client.post(
'/app/reports',
body: {
'targetType': request.targetType.apiValue,
'targetId': request.targetId,
'reasonCode': request.reasonCode,
if (request.description.isNotEmpty)
'description': request.description,
},
);
return data?.toString() ?? '';
}
}
-35
View File
@@ -1,35 +0,0 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import 'api_client.dart';
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
///
/// 列表返回的是与首页一致的卡片结构,所以直接用 `Drama.fromCardJson` 解析。
class LibraryApi {
const LibraryApi(this._client);
final ApiClient _client;
/// 切换收藏,返回切换后的状态(true = 已收藏),客户端以它为唯一真相。
Future<bool> toggleFavorite(String dramaId) async {
final data = await _client.post(
'/app/favorites/toggle',
body: {'dramaId': dramaId},
);
return data == true;
}
Future<List<Drama>> favorites() async {
final data = await _client.get('/app/favorites');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) Drama.fromCardJson(item),
];
}
Future<void> removeFavorite(String dramaId) async {
await _client.delete('/app/favorites/$dramaId');
}
}
-20
View File
@@ -1,20 +0,0 @@
// Author: fruit.com.cn
import '../models/play_ticket.dart';
import 'api_client.dart';
/// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。
///
/// **签名地址原样使用**:后端下发的 `playUrl` 带 `?e=过期时间&s=签名`,
/// 客户端只做「相对路径补 host」,不拼接、不改写、不丢 query —— 否则签名校验直接失败。
class PlaybackApi {
const PlaybackApi(this._client);
final ApiClient _client;
Future<PlayTicket> ticket(String episodeId) async {
final data = await _client.get('/app/episodes/$episodeId/play');
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data);
throw const ApiException(-1, '返回数据格式异常');
}
}
-64
View File
@@ -1,64 +0,0 @@
// Author: fruit.com.cn
import '../models/watch_progress.dart';
import 'api_client.dart';
/// 观看进度接口:`/app/watch/*`(契约 §1.5)。全部需要登录态。
class WatchApi {
const WatchApi(this._client);
final ApiClient _client;
/// 上报进度。服务端从 `episodeId` 推出所属剧集,并保证「同集只前进不后退」。
Future<WatchProgress?> saveProgress({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
String source = 'app',
}) async {
final data = await _client.post(
'/app/watch/progress',
body: {
'episodeId': episodeId,
'positionMs': position.inMilliseconds,
if (duration != null) 'durationMs': duration.inMilliseconds,
'finished': finished,
'source': source,
},
);
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 某部剧的续看位置;没有记录时后端返回空 data。
Future<WatchProgress?> progressOfDrama(String dramaId) async {
final data = await _client.get(
'/app/watch/progress',
query: {'dramaId': dramaId},
);
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 最近观看(服务端按下标返回,已按更新时间倒序)。
Future<List<WatchProgress>> recent({int limit = 20}) async {
final data = await _client.get(
'/app/watch/recent',
query: {'limit': '$limit'},
);
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) WatchProgress.fromJson(item),
];
}
/// 删除某部剧的观看记录(记录页清单条用)。
Future<void> removeProgress(String dramaId) async {
await _client.delete('/app/watch/progress/$dramaId');
}
/// 清空当前账号的全部观看记录。
Future<void> clearProgress() async {
await _client.delete('/app/watch/progress');
}
}
@@ -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,33 +1,106 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../remote/catalog_api.dart';
/// 目录数据来源:首页/发现页聚合、详情。
///
/// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。
/// **播放票据不在这里**:它属于 [PlaybackRepository](票据有有效期,需要单独缓存策略)。
abstract interface class CatalogRepository {
Future<HomeFeed> fetchHomeFeed();
Future<HomeFeed> fetchDiscoverFeed();
Future<Drama> fetchDrama(String dramaId);
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);
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,30 +0,0 @@
// Author: fruit.com.cn
import '../models/content_category.dart';
import '../models/home_feed.dart';
import '../remote/category_api.dart';
import 'catalog_repository.dart';
/// 发现页分类数据来源:分类清单与发现 feed。
abstract interface class CategoryRepository {
/// 拉取服务端分类。
Future<List<ContentCategory>> fetchCategories();
/// 拉取发现页分区;分类内容只从这里读取。
Future<HomeFeed> fetchDiscoverFeed();
}
/// HTTP 实现。发现 feed 复用既有目录仓库,不新增接口。
class HttpCategoryRepository implements CategoryRepository {
const HttpCategoryRepository(this._categoryApi, this._catalogRepository);
final CategoryApi _categoryApi;
final CatalogRepository _catalogRepository;
@override
Future<List<ContentCategory>> fetchCategories() => _categoryApi.list();
@override
Future<HomeFeed> fetchDiscoverFeed() =>
_catalogRepository.fetchDiscoverFeed();
}
@@ -1,62 +0,0 @@
// Author: fruit.com.cn
import '../models/comment.dart';
import '../remote/comment_api.dart';
/// 评论数据来源抽象,页面与控制器不直接依赖 HTTP。
abstract interface class CommentRepository {
/// 拉取评论或回复的分页。
Future<CommentPage> page({
required String dramaId,
String? rootId,
String? cursor,
int size = commentPageSize,
});
/// 发表评论或回复。
Future<DramaComment> publish({
required String dramaId,
String? episodeId,
required String content,
String? parentId,
});
/// 删除指定评论。
Future<void> delete(String commentId);
}
/// HTTP 评论仓库。
class HttpCommentRepository implements CommentRepository {
const HttpCommentRepository(this._api);
final CommentApi _api;
@override
Future<CommentPage> page({
required String dramaId,
String? rootId,
String? cursor,
int size = commentPageSize,
}) => _api.page(
dramaId: dramaId,
rootId: rootId,
cursor: cursor,
size: size,
);
@override
Future<DramaComment> publish({
required String dramaId,
String? episodeId,
required String content,
String? parentId,
}) => _api.publish(
dramaId: dramaId,
episodeId: episodeId,
content: content,
parentId: parentId,
);
@override
Future<void> delete(String commentId) => _api.delete(commentId);
}
@@ -1,82 +0,0 @@
// Author: fruit.com.cn
import '../models/download_license.dart';
import '../models/download_policy.dart';
import '../remote/download_api.dart';
/// 下载授权数据来源,页面只依赖该抽象。
abstract interface class DownloadRepository {
Future<DownloadPolicy> fetchPolicy();
Future<List<DownloadLicense>> list();
Future<DownloadLicense> grant(String episodeId, {String? quality});
Future<void> updateSettings({bool? wifiOnly, String? maxQuality});
Future<DownloadLicense> refreshUrl(String episodeId);
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
});
Future<void> remove(String episodeId);
Future<int> clear();
}
/// HTTP 下载授权仓库。
class HttpDownloadRepository implements DownloadRepository {
const HttpDownloadRepository(this._api);
final DownloadApi _api;
@override
Future<DownloadPolicy> fetchPolicy() => _api.policy();
@override
Future<List<DownloadLicense>> list() => _api.list();
@override
Future<DownloadLicense> grant(String episodeId, {String? quality}) =>
_api.grant(episodeId, quality: quality);
@override
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) =>
_api.updateSettings(wifiOnly: wifiOnly, maxQuality: maxQuality);
@override
Future<DownloadLicense> refreshUrl(String episodeId) =>
_api.refreshUrl(episodeId);
@override
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
}) => _api.reportProgress(
episodeId,
deviceId: deviceId,
status: status,
progressPercent: progressPercent,
downloadedBytes: downloadedBytes,
totalBytes: totalBytes,
errorMsg: errorMsg,
);
@override
Future<void> remove(String episodeId) => _api.remove(episodeId);
@override
Future<int> clear() => _api.clear();
}
@@ -0,0 +1,70 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/engagement_snapshot.dart';
abstract interface class EngagementRepository {
Future<EngagementSnapshot> load();
Future<void> save(EngagementSnapshot snapshot);
}
class SharedPreferencesEngagementRepository implements EngagementRepository {
SharedPreferencesEngagementRepository({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
static const _key = 'engagement.v1.demo-local-v1';
final SharedPreferencesAsync _preferences;
@override
Future<EngagementSnapshot> load() async {
final raw = await _preferences.getString(_key);
if (raw == null) return const EngagementSnapshot();
try {
final json = jsonDecode(raw);
if (json is! Map<String, dynamic>) return const EngagementSnapshot();
Set<String> ids(Object? value) =>
value is List ? value.whereType<String>().toSet() : <String>{};
final comments = <String, List<String>>{};
final rawComments = json['comments'];
if (rawComments is Map<String, dynamic>) {
for (final entry in rawComments.entries) {
if (entry.value is List) {
comments[entry.key] = (entry.value as List)
.whereType<String>()
.toList();
}
}
}
return EngagementSnapshot(
savedDramaIds: ids(json['saved']),
likedDramaIds: ids(json['liked']),
commentsByDrama: comments,
);
} catch (_) {
return const EngagementSnapshot();
}
}
@override
Future<void> save(EngagementSnapshot snapshot) => _preferences.setString(
_key,
jsonEncode({
'saved': snapshot.savedDramaIds.toList(),
'liked': snapshot.likedDramaIds.toList(),
'comments': snapshot.commentsByDrama,
}),
);
}
class InMemoryEngagementRepository implements EngagementRepository {
EngagementSnapshot snapshot = const EngagementSnapshot();
@override
Future<EngagementSnapshot> load() async => snapshot;
@override
Future<void> save(EngagementSnapshot value) async => snapshot = value;
}
@@ -1,74 +0,0 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../remote/library_api.dart';
/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。
abstract interface class FavoriteRepository {
/// 切换收藏,返回切换后的状态(true = 已收藏)。
Future<bool> toggle(String dramaId);
/// 收藏列表(卡片结构与首页一致)。
Future<List<Drama>> list();
Future<void> remove(String dramaId);
}
class HttpFavoriteRepository implements FavoriteRepository {
const HttpFavoriteRepository(this._api, {this.isSignedIn});
final LibraryApi _api;
/// 未登录时不请求:收藏属于账号,匿名浏览时列表按空处理(点收藏按钮会先引导登录)。
final bool Function()? isSignedIn;
bool get _signedIn => isSignedIn?.call() ?? true;
@override
Future<bool> toggle(String dramaId) => _api.toggleFavorite(dramaId);
@override
Future<List<Drama>> list() async {
if (!_signedIn) return const [];
return _api.favorites();
}
@override
Future<void> remove(String dramaId) => _api.removeFavorite(dramaId);
}
/// 内存实现:单测与 widget 测试注入用。
class InMemoryFavoriteRepository implements FavoriteRepository {
InMemoryFavoriteRepository({Set<String>? saved, List<Drama>? cards})
: _saved = {...?saved},
_cards = [...?cards];
final Set<String> _saved;
final List<Drama> _cards;
bool failToggle = false;
bool failList = false;
bool failRemove = false;
int toggleCalls = 0;
int listCalls = 0;
@override
Future<bool> toggle(String dramaId) async {
toggleCalls += 1;
if (failToggle) throw StateError('favorite unavailable');
if (!_saved.add(dramaId)) _saved.remove(dramaId);
return _saved.contains(dramaId);
}
@override
Future<List<Drama>> list() async {
listCalls++;
if (failList) throw StateError('favorite unavailable');
return _cards.where((card) => _saved.contains(card.id)).toList();
}
@override
Future<void> remove(String dramaId) async {
if (failRemove) throw StateError('favorite unavailable');
_saved.remove(dramaId);
}
}
@@ -0,0 +1,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>);
}
}
@@ -1,32 +1,61 @@
// Author: fruit.com.cn
import '../models/interaction.dart';
import '../remote/interaction_api.dart';
import '../services/api_client.dart';
/// 点赞与举报数据来源抽象。
abstract interface class InteractionRepository {
/// 切换目标点赞状态。
Future<LikeToggleResult> toggleLike({
required InteractionTargetType targetType,
required String targetId,
});
/// 评论、点赞、举报(A6)。
class InteractionRepository {
InteractionRepository(this._client);
/// 提交举报并返回工单 ID。
Future<String> report(ReportRequest request);
}
final ApiClient _client;
/// HTTP 互动仓库。
class HttpInteractionRepository implements InteractionRepository {
const HttpInteractionRepository(this._api);
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,
});
}
final InteractionApi _api;
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;
}
@override
Future<LikeToggleResult> toggleLike({
required InteractionTargetType targetType,
required String targetId,
}) => _api.toggleLike(targetType: targetType, targetId: targetId);
Future<void> deleteComment(int id) async {
await _client.delete('/app/comments/$id');
}
@override
Future<String> report(ReportRequest request) => _api.report(request);
Future<void> toggleLike({
required String targetType,
required int targetId,
}) async {
await _client.post('/app/likes/toggle', data: {
'targetType': targetType,
'targetId': targetId,
});
}
Future<void> report({
required String targetType,
required int targetId,
required String reason,
}) async {
await _client.post('/app/reports', data: {
'targetType': targetType,
'targetId': targetId,
'reason': reason,
});
}
}
@@ -1,96 +1,16 @@
// Author: fruit.com.cn
import '../models/play_ticket.dart';
import '../remote/playback_api.dart';
import '../services/api_client.dart';
/// 播放票据来源。
///
/// 票据里的签名地址有有效期(契约 §1.4:签名默认 15 分钟),所以**不能一直缓存**:
/// [ticketFor] 命中未过期的缓存就用缓存,过期或 [forceRefresh] 时重新向后端要一张。
abstract interface class PlaybackRepository {
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false});
/// 播放票据(A4)。
class PlaybackRepository {
PlaybackRepository(this._client);
/// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。
bool isFresh(String episodeId);
}
final ApiClient _client;
class HttpPlaybackRepository implements PlaybackRepository {
HttpPlaybackRepository(
this._api, {
this.ttl = const Duration(minutes: 10),
DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now;
final PlaybackApi _api;
/// 缓存时长:比后端 15 分钟签名短 5 分钟,避免"地址刚好在播放中途失效"。
final Duration ttl;
final DateTime Function() _clock;
final Map<String, _CachedTicket> _cache = {};
@override
Future<PlayTicket> ticketFor(
String episodeId, {
bool forceRefresh = false,
}) async {
final cached = _cache[episodeId];
if (!forceRefresh && cached != null && !cached.isExpired(_clock(), ttl)) {
return cached.ticket;
}
final ticket = await _api.ticket(episodeId);
_cache[episodeId] = _CachedTicket(ticket, _clock());
return ticket;
}
@override
bool isFresh(String episodeId) {
final cached = _cache[episodeId];
if (cached == null) return false;
return !cached.isExpired(_clock(), ttl);
}
/// 供测试与"退出播放页"清理用。
void clear() => _cache.clear();
}
class _CachedTicket {
const _CachedTicket(this.ticket, this.fetchedAt);
final PlayTicket ticket;
final DateTime fetchedAt;
bool isExpired(DateTime now, Duration ttl) => now.difference(fetchedAt) >= ttl;
}
/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。
class FakePlaybackRepository implements PlaybackRepository {
FakePlaybackRepository({this.ticket, this.error, this.fresh = true});
PlayTicket? ticket;
Object? error;
final List<String> requests = [];
final List<bool> forced = [];
/// 模拟"票据是否还有效":置 false 可验证回前台会重新取。
bool fresh;
@override
bool isFresh(String episodeId) => fresh;
@override
Future<PlayTicket> ticketFor(
String episodeId, {
bool forceRefresh = false,
}) async {
requests.add(episodeId);
forced.add(forceRefresh);
if (error != null) throw error!;
return ticket ??
PlayTicket(
episodeId: episodeId,
accessible: true,
accessType: PlayTicket.free,
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
);
/// 签发播放票据与签名地址,客户端原样使用 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,156 +1,67 @@
// Author: fruit.com.cn
import '../models/watch_progress.dart';
import '../remote/watch_api.dart';
import 'package:shared_preferences/shared_preferences.dart';
const demoProfileId = 'demo-local-v1';
/// 观看进度来源。
///
/// **服务端按 `(user, drama)` 只留一条续看记录**(契约 §1.5),所以这里不再是
/// "本地按 profile+剧+集存位置",而是"这部剧看到哪一集、哪一秒"。
/// 本地实现已删除:进度以服务端为准,换设备也能续看。
abstract interface class WatchHistoryRepository {
/// 某部剧的续看记录;没有记录返回 null(不是异常)。
Future<WatchProgress?> progressFor(String dramaId);
/// 上报进度(客户端每 10 秒一次,切集/退出/切后台各补一次)。
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
});
/// 最近观看(默认 20 条)。
Future<List<WatchProgress>> recent({int limit = 20});
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
Future<void> remove(String dramaId);
/// 清空当前账号的全部观看记录。
Future<void> clear();
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
);
Future<String?> lastEpisodeId(String profileId, String dramaId);
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
);
}
class HttpWatchHistoryRepository implements WatchHistoryRepository {
const HttpWatchHistoryRepository(this._api, {this.isSignedIn});
class SharedPreferencesWatchHistoryRepository
implements WatchHistoryRepository {
SharedPreferencesWatchHistoryRepository({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
final WatchApi _api;
final SharedPreferencesAsync _preferences;
/// 未登录时这些接口必然 401:直接短路成"没有记录",让浏览/试看保持匿名可用。
final bool Function()? isSignedIn;
bool get _signedIn => isSignedIn?.call() ?? true;
String _positionKey(String profile, String drama, String episode) =>
'watch.v1.$profile.$drama.$episode.positionMs';
String _lastKey(String profile, String drama) =>
'watch.v1.$profile.$drama.lastEpisode';
@override
Future<WatchProgress?> progressFor(String dramaId) async {
if (!_signedIn) return null;
return _api.progressOfDrama(dramaId);
}
@override
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async {
if (!_signedIn) return null;
return _api.saveProgress(
episodeId: episodeId,
position: position,
duration: duration,
finished: finished,
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
) async {
final milliseconds = await _preferences.getInt(
_positionKey(profileId, dramaId, episodeId),
);
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (!_signedIn) return const [];
return _api.recent(limit: limit);
}
Future<String?> lastEpisodeId(String profileId, String dramaId) =>
_preferences.getString(_lastKey(profileId, dramaId));
@override
Future<void> remove(String dramaId) async {
if (!_signedIn) return;
await _api.removeProgress(dramaId);
}
@override
Future<void> clear() async {
if (!_signedIn) return;
await _api.clearProgress();
}
}
/// 内存实现:单测与 widget 测试注入用(不依赖网络与偏好存储)。
class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
InMemoryWatchHistoryRepository({List<WatchProgress>? seed})
: _records = {
for (final item in seed ?? const <WatchProgress>[]) item.dramaId: item,
};
final Map<String, WatchProgress> _records;
int failReads = 0;
bool failWrites = false;
final List<String> writes = [];
@override
Future<WatchProgress?> progressFor(String dramaId) async {
if (failReads > 0) {
failReads -= 1;
throw StateError('progress unavailable');
}
return _records[dramaId];
}
@override
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async {
if (failWrites) throw StateError('progress unavailable');
writes.add('$episodeId|${position.inMilliseconds}|$finished');
final existing = _records[episodeId];
final record = WatchProgress(
dramaId: existing?.dramaId ?? '',
episodeId: episodeId,
position: position,
duration: duration ?? Duration.zero,
finished: finished,
);
return record;
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (failReads > 0) {
failReads -= 1;
throw StateError('progress unavailable');
}
return _records.values.take(limit).toList();
}
@override
Future<void> remove(String dramaId) async => _records.remove(dramaId);
@override
Future<void> clear() async => _records.clear();
}
/// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。
extension WatchProgressResume on WatchHistoryRepository {
Future<ResumePoint?> resumePointFor(String dramaId) async {
final progress = await progressFor(dramaId);
if (progress == null || progress.episodeId.isEmpty) return null;
return ResumePoint(
episodeId: progress.episodeId,
position: progress.position,
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
) async {
await _preferences.setInt(
_positionKey(profileId, dramaId, episodeId),
position.inMilliseconds,
);
await _preferences.setString(_lastKey(profileId, dramaId), episodeId);
}
}
/// 续看位置钳位:片尾附近与异常值一律从 0 开始,避免"一进来就播完"。
Duration clampResumePosition(Duration saved, Duration duration) {
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
if (saved <= Duration.zero) return Duration.zero;
@@ -0,0 +1,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');
}
}
+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);
// 设备号不清:它代表"这台设备",与登录态无关
}
}
+3 -42
View File
@@ -1,67 +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;
}
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,82 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/content_category.dart';
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../repositories/category_repository.dart';
/// 发现页分类状态:服务端分类清单、发现 feed 与当前筛选。
class CategoryController extends ChangeNotifier {
CategoryController(this._repository);
static const String allCode = 'all';
static const String hotCode = 'hot';
static const String newCode = 'new';
final CategoryRepository _repository;
List<ContentCategory> categories = const [];
HomeFeed feed = const HomeFeed();
String selectedCode = allCode;
bool loading = false;
String? error;
bool _disposed = false;
/// 当前筛选对应的分区 code;全部分类为空字符串语义。
bool get selectedSectionMissing =>
selectedCode != allCode && _selectedSection == null;
/// 当前筛选应展示的剧集;全部分类按 feed 顺序去重。
List<Drama> get visibleDramas {
if (selectedCode == allCode) return feed.dramas;
return _selectedSection?.dramas ?? const [];
}
FeedSection? get _selectedSection {
for (final section in feed.sections) {
if (section.code == selectedCode) return section;
}
return null;
}
/// 加载分类与发现 feed;失败保留可重试错误态。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final results = await Future.wait<Object>([
_repository.fetchCategories(),
_repository.fetchDiscoverFeed(),
]);
categories = results[0] as List<ContentCategory>;
feed = results[1] as HomeFeed;
} catch (_) {
error = 'categoriesError';
} finally {
loading = false;
_notify();
}
}
/// 选择服务端分类,内容只匹配 `category-{id}`。
void selectCategory(String id) => selectCode('category-$id');
/// 选择内置或分区 code。
void selectCode(String code) {
selectedCode = code;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -1,378 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/comment.dart';
import '../models/interaction.dart';
import '../remote/api_client.dart';
import '../repositories/comment_repository.dart';
import '../repositories/interaction_repository.dart';
/// 评论列表、发布、回复、删除与评论点赞的状态控制器。
class CommentController extends ChangeNotifier {
CommentController({
required CommentRepository comments,
required InteractionRepository interactions,
required this.dramaId,
this.episodeId,
}) : _commentRepository = comments,
_interactionRepository = interactions;
final CommentRepository _commentRepository;
final InteractionRepository _interactionRepository;
final String dramaId;
final String? episodeId;
final List<DramaComment> _comments = [];
final Map<String, List<DramaComment>> _replies = {};
final Map<String, String?> _replyCursors = {};
final Map<String, bool> _replyHasMore = {};
final Set<String> _expandedRoots = {};
final Set<String> _loadingReplies = {};
final Set<String> _loadingMoreReplies = {};
final Set<String> _likingCommentIds = {};
final Set<String> _deletingCommentIds = {};
bool _disposed = false;
bool loading = false;
bool loadingMore = false;
bool submitting = false;
bool hasMore = false;
String? nextCursor;
String? error;
/// 当前已加载的顶层评论。
List<DramaComment> get comments => List.unmodifiable(_comments);
/// 某一楼层已加载的回复。
List<DramaComment> repliesFor(String rootId) =>
List.unmodifiable(_replies[rootId] ?? const []);
/// 楼层回复是否已展开。
bool repliesExpanded(String rootId) => _expandedRoots.contains(rootId);
/// 楼层回复是否正在首屏加载。
bool repliesLoading(String rootId) => _loadingReplies.contains(rootId);
/// 楼层回复是否还有下一页。
bool repliesHasMore(String rootId) => _replyHasMore[rootId] ?? false;
/// 评论是否正在提交点赞,用于防连点。
bool likingComment(String id) => _likingCommentIds.contains(id);
void _notify() {
if (!_disposed) notifyListeners();
}
/// 加载首屏评论,成功后替换当前顶层列表。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
size: commentPageSize,
);
_comments
..clear()
..addAll(page.comments);
nextCursor = page.nextCursor;
hasMore = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
loading = false;
_notify();
}
}
/// 使用 `nextCursor` 续拉顶层评论。
Future<void> loadMore() async {
final cursor = nextCursor;
if (loadingMore || !hasMore || cursor == null) return;
loadingMore = true;
error = null;
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
cursor: cursor,
size: commentPageSize,
);
_comments.addAll(page.comments);
nextCursor = page.nextCursor;
hasMore = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
loadingMore = false;
_notify();
}
}
/// 展开并用 `rootId` 加载一层楼层回复。
Future<void> loadReplies(String rootId) async {
_expandedRoots.add(rootId);
if (_replies.containsKey(rootId) || _loadingReplies.contains(rootId)) {
_notify();
return;
}
_loadingReplies.add(rootId);
error = null;
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
rootId: rootId,
size: commentPageSize,
);
_replies[rootId] = List.of(page.comments);
_replyCursors[rootId] = page.nextCursor;
_replyHasMore[rootId] = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
_loadingReplies.remove(rootId);
_notify();
}
}
/// 收起楼层回复,不丢弃已加载数据。
void hideReplies(String rootId) {
if (_expandedRoots.remove(rootId)) _notify();
}
/// 续拉楼层回复。
Future<void> loadMoreReplies(String rootId) async {
final cursor = _replyCursors[rootId];
if (_loadingMoreReplies.contains(rootId) || cursor == null) return;
if (!(_replyHasMore[rootId] ?? false)) return;
_loadingMoreReplies.add(rootId);
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
rootId: rootId,
cursor: cursor,
size: commentPageSize,
);
(_replies[rootId] ??= []).addAll(page.comments);
_replyCursors[rootId] = page.nextCursor;
_replyHasMore[rootId] = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
_loadingMoreReplies.remove(rootId);
_notify();
}
}
/// 发布评论或回复,输入框是否清空由调用方根据成功结果决定。
Future<OperationResult<DramaComment>> publish(
String rawContent, {
DramaComment? replyTo,
}) async {
final content = rawContent.trim();
if (content.isEmpty || submitting) {
return const OperationResult.failure(null);
}
submitting = true;
_notify();
try {
final comment = await _commentRepository.publish(
dramaId: dramaId,
episodeId: episodeId,
content: content,
parentId: replyTo?.id,
);
_insertPublished(comment, replyTo);
return OperationResult.success(comment);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
} finally {
submitting = false;
_notify();
}
}
/// 乐观删除自己的评论,失败时恢复到原位置。
Future<OperationResult<void>> delete(DramaComment comment) async {
if (!comment.mine || _deletingCommentIds.contains(comment.id)) {
return const OperationResult.failure(null);
}
_deletingCommentIds.add(comment.id);
final location = _removeComment(comment.id);
_notify();
try {
await _commentRepository.delete(comment.id);
return const OperationResult.success();
} catch (failure) {
_restoreComment(comment, location);
return OperationResult.failure(_errorMessage(failure));
} finally {
_deletingCommentIds.remove(comment.id);
_notify();
}
}
/// 评论点赞先乐观更新,再用服务端 `{liked,count}` 校准。
Future<OperationResult<LikeToggleResult>> toggleLike(
DramaComment comment,
) async {
if (_likingCommentIds.contains(comment.id)) {
return const OperationResult.failure(null);
}
final current = _findComment(comment.id) ?? comment;
_likingCommentIds.add(comment.id);
_replaceComment(
current.copyWith(
liked: !current.liked,
likeCount: current.liked
? (current.likeCount > 0 ? current.likeCount - 1 : 0)
: current.likeCount + 1,
),
);
_notify();
try {
final result = await _interactionRepository.toggleLike(
targetType: InteractionTargetType.comment,
targetId: comment.id,
);
_replaceComment(
current.copyWith(liked: result.liked, likeCount: result.count),
);
return OperationResult.success(result);
} catch (failure) {
_replaceComment(current);
return OperationResult.failure(_errorMessage(failure));
} finally {
_likingCommentIds.remove(comment.id);
_notify();
}
}
/// 举报评论,失败消息原样返回给界面展示。
Future<OperationResult<String>> report(
DramaComment comment, {
required ReportReason reason,
String description = '',
}) async {
try {
final reportId = await _interactionRepository.report(
ReportRequest(
targetType: InteractionTargetType.comment,
targetId: comment.id,
reasonCode: reason.apiValue,
description: description.trim(),
),
);
return OperationResult.success(reportId);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
}
}
void _insertPublished(DramaComment comment, DramaComment? replyTo) {
final rootId = comment.rootId != '0'
? comment.rootId
: replyTo?.isTopLevel == true
? replyTo!.id
: replyTo?.rootId;
if (replyTo == null || rootId == null || rootId == '0') {
if (!_comments.any((item) => item.id == comment.id)) {
_comments.insert(0, comment);
}
return;
}
final replies = _replies.putIfAbsent(rootId, () => []);
if (!replies.any((item) => item.id == comment.id)) {
replies.insert(0, comment);
}
_expandedRoots.add(rootId);
final rootIndex = _comments.indexWhere((item) => item.id == rootId);
if (rootIndex >= 0) {
final root = _comments[rootIndex];
_comments[rootIndex] = root.copyWith(replyCount: root.replyCount + 1);
}
}
_CommentLocation? _removeComment(String id) {
final topIndex = _comments.indexWhere((item) => item.id == id);
if (topIndex >= 0) {
final removed = _comments.removeAt(topIndex);
return _CommentLocation(comment: removed, topIndex: topIndex);
}
for (final entry in _replies.entries) {
final index = entry.value.indexWhere((item) => item.id == id);
if (index < 0) continue;
final removed = entry.value.removeAt(index);
return _CommentLocation(
comment: removed,
rootId: entry.key,
replyIndex: index,
);
}
return null;
}
void _restoreComment(DramaComment comment, _CommentLocation? location) {
if (location == null) return;
if (location.topIndex != null) {
final index = location.topIndex!.clamp(0, _comments.length);
_comments.insert(index, comment);
return;
}
final replies = _replies.putIfAbsent(location.rootId!, () => []);
final index = location.replyIndex!.clamp(0, replies.length);
replies.insert(index, comment);
}
DramaComment? _findComment(String id) {
for (final comment in _comments) {
if (comment.id == id) return comment;
}
for (final replies in _replies.values) {
for (final comment in replies) {
if (comment.id == id) return comment;
}
}
return null;
}
void _replaceComment(DramaComment replacement) {
final topIndex = _comments.indexWhere((item) => item.id == replacement.id);
if (topIndex >= 0) {
_comments[topIndex] = replacement;
return;
}
for (final replies in _replies.values) {
final index = replies.indexWhere((item) => item.id == replacement.id);
if (index >= 0) {
replies[index] = replacement;
return;
}
}
}
String? _errorMessage(Object failure) =>
failure is ApiException ? failure.message : null;
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
class _CommentLocation {
const _CommentLocation({
required this.comment,
this.topIndex,
this.rootId,
this.replyIndex,
});
final DramaComment comment;
final int? topIndex;
final String? rootId;
final int? replyIndex;
}
@@ -1,168 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/download_license.dart';
import '../models/download_policy.dart';
import '../remote/api_exception.dart';
import '../repositories/download_repository.dart';
/// 一次下载授权申请的结果;后端业务拒绝时保留原始 message。
class DownloadActionResult {
const DownloadActionResult({required this.ok, this.license, this.message});
final bool ok;
final DownloadLicense? license;
final String? message;
}
/// 下载授权与策略状态。
class DownloadController extends ChangeNotifier {
DownloadController(this._repository);
final DownloadRepository _repository;
DownloadPolicy? policy;
List<DownloadLicense> licenses = const [];
bool loading = false;
bool clearing = false;
String? error;
String? actionError;
bool _disposed = false;
/// 同时拉取策略与当前设备授权列表。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final results = await Future.wait<Object>([
_repository.fetchPolicy(),
_repository.list(),
]);
policy = results[0] as DownloadPolicy;
licenses = results[1] as List<DownloadLicense>;
} catch (_) {
error = 'downloadsError';
} finally {
loading = false;
_notify();
}
}
/// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。
Future<DownloadActionResult> grant(
String episodeId, {
String? quality,
}) async {
try {
final license = await _repository.grant(episodeId, quality: quality);
licenses = [
license,
for (final item in licenses)
if (item.episodeId != license.episodeId) item,
];
actionError = null;
_notify();
return DownloadActionResult(ok: true, license: license);
} on ApiException catch (exception) {
actionError = exception.message;
_notify();
return DownloadActionResult(ok: false, message: exception.message);
} catch (_) {
actionError = 'downloadGrantFailed';
_notify();
return const DownloadActionResult(ok: false);
}
}
/// 更新设置,先本地展示,失败恢复服务端旧值。
Future<bool> updateSettings({bool? wifiOnly, String? maxQuality}) async {
final previous = policy;
if (previous != null) {
policy = previous.copyWith(wifiOnly: wifiOnly, maxQuality: maxQuality);
_notify();
}
try {
await _repository.updateSettings(
wifiOnly: wifiOnly,
maxQuality: maxQuality,
);
actionError = null;
return true;
} catch (_) {
policy = previous;
actionError = 'downloadSettingsFailed';
return false;
} finally {
_notify();
}
}
/// 删除单条授权,失败按原位置恢复。
Future<bool> remove(String episodeId) async {
final index = licenses.indexWhere((item) => item.episodeId == episodeId);
if (index < 0) return true;
final removed = licenses[index];
licenses = [...licenses]..removeAt(index);
actionError = null;
_notify();
try {
await _repository.remove(episodeId);
return true;
} catch (_) {
final restored = [...licenses];
restored.insert(
index > restored.length ? restored.length : index,
removed,
);
licenses = restored;
actionError = 'downloadDeleteFailed';
return false;
} finally {
_notify();
}
}
/// 清空授权,失败恢复原列表。
Future<bool> clear() async {
if (licenses.isEmpty) return true;
final previous = licenses;
licenses = const [];
clearing = true;
actionError = null;
_notify();
try {
await _repository.clear();
return true;
} catch (_) {
licenses = previous;
actionError = 'downloadClearFailed';
return false;
} finally {
clearing = false;
_notify();
}
}
/// 退出登录时清空账号相关状态,避免跨账号串联。
void reset() {
policy = null;
licenses = const [];
loading = false;
clearing = false;
error = null;
actionError = null;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,80 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/engagement_snapshot.dart';
import '../repositories/engagement_repository.dart';
/// Local demo interactions, indexed by drama ID. No public/social backend.
class EngagementController extends ChangeNotifier {
EngagementController(this._repository);
final EngagementRepository _repository;
final Set<String> _saved = {};
final Set<String> _liked = {};
final Map<String, List<String>> _comments = {};
Future<void> _loadFuture = Future<void>.value();
Future<void> _pendingSave = Future<void>.value();
bool isSaved(String dramaId) => _saved.contains(dramaId);
bool isLiked(String dramaId) => _liked.contains(dramaId);
List<String> commentsFor(String dramaId) =>
List.unmodifiable(_comments[dramaId] ?? const []);
Future<void> load() => _loadFuture = _loadFromRepository();
Future<void> _loadFromRepository() async {
try {
final snapshot = await _repository.load();
_saved.addAll(snapshot.savedDramaIds);
_liked.addAll(snapshot.likedDramaIds);
for (final entry in snapshot.commentsByDrama.entries) {
_comments[entry.key] = List.of(entry.value);
}
notifyListeners();
} catch (_) {
// Local preferences are best effort for this demo.
}
}
Future<void> toggleSaved(String dramaId) async {
await _loadFuture;
if (!_saved.add(dramaId)) _saved.remove(dramaId);
notifyListeners();
await _persist();
}
Future<void> toggleLiked(String dramaId) async {
await _loadFuture;
if (!_liked.add(dramaId)) _liked.remove(dramaId);
notifyListeners();
await _persist();
}
Future<void> addComment(String dramaId, String text) async {
await _loadFuture;
final clean = text.trim();
if (clean.isEmpty) return;
(_comments[dramaId] ??= []).add(
clean.length > 300 ? clean.substring(0, 300) : clean,
);
notifyListeners();
await _persist();
}
Future<void> _persist() {
final snapshot = EngagementSnapshot(
savedDramaIds: Set.of(_saved),
likedDramaIds: Set.of(_liked),
commentsByDrama: {
for (final entry in _comments.entries) entry.key: List.of(entry.value),
},
);
_pendingSave = _pendingSave
.then((_) => _repository.save(snapshot))
.catchError((Object _) {
// Keep the current in-memory state if local persistence fails.
});
return _pendingSave;
}
}
+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,127 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../repositories/favorite_repository.dart';
/// 收藏状态:**以服务端为准**。
///
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
/// "点了没反应"或"看起来收藏了其实没有"。
class FavoriteController extends ChangeNotifier {
FavoriteController(this._repository);
final FavoriteRepository _repository;
final Set<String> _saved = {};
List<Drama> cards = const [];
bool loading = false;
String? error;
bool _disposed = false;
Set<String> get savedIds => Set.unmodifiable(_saved);
bool isSaved(String dramaId) => _saved.contains(dramaId);
void notify() {
if (!_disposed) notifyListeners();
}
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
Future<void> load() async {
loading = true;
error = null;
notify();
try {
final list = await _repository.list();
cards = list;
_saved
..clear()
..addAll(list.map((drama) => drama.id));
} catch (_) {
error = 'favoritesError';
} finally {
loading = false;
notify();
}
}
/// 未登录时不要调用(服务端要求登录态)。
///
/// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
Future<bool> toggle(String dramaId, {Drama? card}) async {
final wasSaved = _saved.contains(dramaId);
// 乐观更新
if (wasSaved) {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
} else {
_saved.add(dramaId);
}
notify();
try {
final saved = await _repository.toggle(dramaId);
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
if (saved) {
_saved.add(dramaId);
if (card != null && !cards.any((drama) => drama.id == dramaId)) {
cards = [card, ...cards];
}
} else {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
}
notify();
return true;
} catch (_) {
// 回滚
if (wasSaved) {
_saved.add(dramaId);
} else {
_saved.remove(dramaId);
}
notify();
return false;
}
}
/// 取消收藏("我的"页列表里删除某条)。
Future<bool> remove(String dramaId) async {
final index = cards.indexWhere((drama) => drama.id == dramaId);
if (index < 0) return true;
final removed = cards[index];
_saved.remove(dramaId);
final next = [...cards]..removeAt(index);
cards = next;
notify();
try {
await _repository.remove(dramaId);
return true;
} catch (_) {
final restored = [...cards];
restored.insert(
index > restored.length ? restored.length : index,
removed,
);
cards = restored;
_saved.add(dramaId);
notify();
return false;
}
}
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
void clear() {
_saved.clear();
cards = const [];
error = null;
notify();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -1,107 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/interaction.dart';
import '../remote/api_client.dart';
import '../repositories/interaction_repository.dart';
/// 剧集点赞与举报的会话状态控制器。
///
/// 剧集契约暂未下发初始点赞状态与计数,本批只维护当前会话内的 liked 状态,
/// 不展示数字,也不伪造初始值。
class InteractionController extends ChangeNotifier {
InteractionController(this._repository);
final InteractionRepository _repository;
final Set<String> _likedDramaIds = {};
final Set<String> _busyDramaIds = {};
bool _disposed = false;
/// 当前会话内该剧是否已点赞。
bool isLiked(String dramaId) => _likedDramaIds.contains(dramaId);
/// 点赞请求是否进行中,用于防连点。
bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId);
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
Future<OperationResult<LikeToggleResult>> toggleDramaLike(
String dramaId,
) async {
if (_busyDramaIds.contains(dramaId)) {
return const OperationResult.failure(null);
}
final wasLiked = _likedDramaIds.contains(dramaId);
_busyDramaIds.add(dramaId);
if (wasLiked) {
_likedDramaIds.remove(dramaId);
} else {
_likedDramaIds.add(dramaId);
}
_notify();
try {
final result = await _repository.toggleLike(
targetType: InteractionTargetType.drama,
targetId: dramaId,
);
if (result.liked) {
_likedDramaIds.add(dramaId);
} else {
_likedDramaIds.remove(dramaId);
}
return OperationResult.success(result);
} catch (failure) {
if (wasLiked) {
_likedDramaIds.add(dramaId);
} else {
_likedDramaIds.remove(dramaId);
}
return OperationResult.failure(_errorMessage(failure));
} finally {
_busyDramaIds.remove(dramaId);
_notify();
}
}
/// 举报剧集,失败消息原样返回给界面。
Future<OperationResult<String>> reportDrama(
String dramaId, {
required ReportReason reason,
String description = '',
}) async {
try {
final reportId = await _repository.report(
ReportRequest(
targetType: InteractionTargetType.drama,
targetId: dramaId,
reasonCode: reason.apiValue,
description: description.trim(),
),
);
return OperationResult.success(reportId);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
}
}
/// 登录账号变化时清理会话内状态,避免跨账号串联。
void clear() {
if (_likedDramaIds.isEmpty && _busyDramaIds.isEmpty) return;
_likedDramaIds.clear();
_busyDramaIds.clear();
_notify();
}
String? _errorMessage(Object failure) =>
failure is ApiException ? failure.message : null;
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+1 -13
View File
@@ -130,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;
@@ -154,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);
}
}
+5 -20
View File
@@ -2,7 +2,6 @@
import 'package:video_player/video_player.dart';
import '../../config/app_config.dart';
import '../models/drama.dart';
import '../models/player_port.dart';
@@ -10,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,205 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../models/watch_progress.dart';
import '../models/watch_record.dart';
import '../repositories/catalog_repository.dart';
import '../repositories/watch_history_repository.dart';
/// 独立观看记录页状态。
///
/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。
class WatchRecordsController extends ChangeNotifier {
factory WatchRecordsController({
required WatchHistoryRepository history,
required CatalogRepository catalog,
int limit = defaultLimit,
}) => WatchRecordsController._(history, catalog, limit);
WatchRecordsController._(this._history, this._catalog, this.limit);
static const int defaultLimit = 20;
static const int detailConcurrency = 4;
final WatchHistoryRepository _history;
final CatalogRepository _catalog;
final int limit;
final Map<String, Drama> _detailCache = {};
List<WatchRecord> records = const [];
bool loading = false;
bool clearing = false;
String? error;
String? actionError;
bool _disposed = false;
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
Future<void> load() async {
loading = true;
error = null;
actionError = null;
_notify();
try {
final recent = await _history.recent(limit: limit);
if (_disposed) return;
records = _uniqueRecords(recent);
_notify();
await _hydrate(records.map((record) => record.dramaId).toList());
} catch (_) {
if (!_disposed) error = 'progressError';
} finally {
if (!_disposed) {
loading = false;
_notify();
}
}
}
/// 只重试一条详情失败记录。
Future<void> retryDetail(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return;
_replaceAt(
index,
records[index].copyWith(detailLoading: true, detailFailed: false),
);
_notify();
await _loadDetail(dramaId);
_notify();
}
/// 删除单条记录,先更新 UI,失败按原位置回滚。
Future<bool> remove(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return true;
final removed = records[index];
records = [...records]..removeAt(index);
actionError = null;
_notify();
try {
await _history.remove(dramaId);
return true;
} catch (_) {
final next = [...records];
next.insert(index > next.length ? next.length : index, removed);
records = next;
actionError = 'recordDeleteFailed';
return false;
} finally {
_notify();
}
}
/// 清空全部记录,先更新 UI,失败恢复原列表。
Future<bool> clear() async {
if (records.isEmpty) return true;
final previous = records;
records = const [];
clearing = true;
actionError = null;
_notify();
try {
await _history.clear();
return true;
} catch (_) {
records = previous;
actionError = 'recordClearFailed';
return false;
} finally {
clearing = false;
_notify();
}
}
List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) {
final seen = <String>{};
return [
for (final progress in recent)
if (seen.add(progress.dramaId)) WatchRecord(progress: progress),
];
}
Future<void> _hydrate(List<String> dramaIds) async {
final queue = [
for (final id in dramaIds)
if (!_detailCache.containsKey(id)) id,
];
final workerCount = queue.length < detailConcurrency
? queue.length
: detailConcurrency;
Future<void> worker() async {
while (queue.isNotEmpty && !_disposed) {
await _loadDetail(queue.removeAt(0));
}
}
await Future.wait([
for (var index = 0; index < workerCount; index++) worker(),
]);
}
Future<void> _loadDetail(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return;
final cached = _detailCache[dramaId];
if (cached != null) {
_replaceAt(
index,
records[index].copyWith(
drama: cached,
detailLoading: false,
detailFailed: false,
),
);
return;
}
try {
final detail = await _catalog.fetchDrama(dramaId);
if (_disposed) return;
_detailCache[dramaId] = detail;
final current = _recordIndex(dramaId);
if (current < 0) return;
_replaceAt(
current,
records[current].copyWith(
drama: detail,
detailLoading: false,
detailFailed: false,
),
);
} catch (_) {
if (_disposed) return;
final current = _recordIndex(dramaId);
if (current < 0) return;
_replaceAt(
current,
records[current].copyWith(detailLoading: false, detailFailed: true),
);
}
}
int _recordIndex(String dramaId) {
for (var index = 0; index < records.length; index++) {
if (records[index].dramaId == dramaId) return index;
}
return -1;
}
void _replaceAt(int index, WatchRecord record) {
final next = [...records];
next[index] = record;
records = next;
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
-17
View File
@@ -1,17 +0,0 @@
// Author: fruit.com.cn
import '../../l10n/app_localizations.dart';
/// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与
/// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。
String errorText(AppLocalizations l10n, String? code, String fallback) {
if (code == null || code.isEmpty) return fallback;
return switch (code) {
'networkError' => l10n.networkError,
'progressError' => l10n.progressError,
'favoritesError' => l10n.favoritesError,
'ticketError' => l10n.playbackError,
'commentsLoadFailed' => l10n.commentsLoadFailed,
final value => value,
};
}
-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);
}
-122
View File
@@ -1,122 +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;
}
/// 页面级登录引导,用于记录、收藏和下载等必须登录的独立页面。
class AuthGatePanel extends StatelessWidget {
const AuthGatePanel({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(
key: const Key('auth-gate-panel'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.authGateTitle,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
l10n.authGateMessage,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 18),
FilledButton(
onPressed: () => openAuthPage(context),
child: Text(l10n.authGateAction),
),
],
),
),
);
}
}
-29
View File
@@ -1,29 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../data/services/download_controller.dart';
import 'auth_gate.dart';
/// 详情页与播放页共用的下载授权入口。
///
/// 未登录先走登录闸;业务拒绝时直接展示后端 message。
Future<void> requestEpisodeDownload(
BuildContext context,
String episodeId,
) async {
if (!await ensureSignedIn(context)) return;
if (!context.mounted) return;
final result = await context.read<DownloadController>().grant(episodeId);
if (!context.mounted) return;
final l10n = AppLocalizations.of(context)!;
final hasBackendMessage = result.message?.trim().isNotEmpty ?? false;
final message = result.ok
? l10n.downloadGranted
: hasBackendMessage
? result.message!
: l10n.downloadGrantFailed;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
-73
View File
@@ -1,73 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../data/models/drama.dart';
import '../theme/app_colors.dart';
import 'remote_image.dart';
/// 收藏等账号列表复用的剧集卡片,视觉与"我的"页列表保持一致。
class LibraryDramaTile extends StatelessWidget {
const LibraryDramaTile({
super.key,
required this.drama,
required this.onTap,
this.trailing,
});
final Drama drama;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF1B1C21),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(9),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: RemoteImage(drama.posterUrl, width: 74, height: 103),
),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drama.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 7),
Text(
drama.genre.isEmpty ? drama.category : drama.genre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
],
),
),
?trailing,
],
),
),
),
);
}
}
-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),
);
}
}
+11 -237
View File
@@ -1,6 +1,6 @@
{
"@@locale": "en",
"appName": "FruitShorts",
"appName": "Nontonki",
"demo": "DEMO",
"homeHeadline": "Watch now",
"homeSubtitle": "Free short drama preview.",
@@ -34,13 +34,7 @@
"localDemoProfile": "Local demo profile",
"continueWatching": "Continue Watching",
"watchedPercent": "Watched {percent}%",
"@watchedPercent": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
"resume": "Resume",
"history": "History",
"watchlist": "Watchlist",
@@ -65,50 +59,10 @@
"save": "Save",
"comments": "Comments",
"share": "Share",
"localOnlyComments": "Only visible on this device",
"noComments": "No comments yet.",
"writeComment": "Write a comment",
"postComment": "Post",
"loadMore": "Load more",
"commentsLoadFailed": "Comments could not be loaded.",
"commentPostFailed": "Could not post your comment.",
"delete": "Delete",
"deleteComment": "Delete comment",
"deleteCommentTitle": "Delete this comment?",
"deleteCommentMessage": "This removes your comment from the conversation.",
"commentDeleteFailed": "Could not delete this comment.",
"underReview": "Under review",
"reply": "Reply",
"replyingTo": "Replying to {name}",
"@replyingTo": {
"placeholders": {
"name": {}
}
},
"cancelReply": "Cancel reply",
"viewReplies": "View replies ({count})",
"@viewReplies": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"hideReplies": "Hide replies",
"anonymousUser": "Viewer",
"more": "More",
"likeFailed": "Could not update like. Please retry.",
"reportComment": "Report comment",
"reportDrama": "Report drama",
"reportReason": "Reason",
"reportReasonSpam": "Spam",
"reportReasonAbuse": "Harassment",
"reportReasonPorn": "Sexual content",
"reportReasonAd": "Advertising",
"reportReasonOther": "Other",
"reportDescriptionHint": "Describe the issue (optional)",
"reportSubmit": "Submit report",
"reportSubmitted": "Report submitted. Thank you.",
"reportFailed": "Could not submit report.",
"shareFailed": "Could not open the share sheet.",
"nowPlaying": "Playing now",
"watchFullscreen": "Watch in landscape",
@@ -118,40 +72,16 @@
"originalTag": "Original",
"dramaTag": "Short drama",
"episodeLabel": "Episode {number}",
"@episodeLabel": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
"exploreSeries": "Explore series",
"episodeCount": "{count} EPISODES",
"@episodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@episodeCount": {"placeholders": {"count": {"type": "int"}}},
"technicalDemoCount": "FREE PREVIEW · {count} EPISODES",
"@technicalDemoCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}},
"episodes": "Episodes",
"watchEpisodeOne": "Watch episode 1",
"continueEpisode": "Continue episode {number}",
"@continueEpisode": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"@continueEpisode": {"placeholders": {"number": {"type": "int"}}},
"freeTestMedia": "Same demo clip · free",
"lockedStoreUnavailable": "Locked · store unavailable",
"freeTestClip": "Free test clip",
@@ -159,31 +89,13 @@
"freeSamplesNotice": "Two free samples · store setup pending",
"demoTestMedia": "FREE · SAME DEMO CLIP",
"episodeProgress": "EPISODE {number} / {total}",
"@episodeProgress": {
"placeholders": {
"number": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}},
"testVideoNotice": "Self-generated technical test video",
"lockedTitle": "Episode {number} is locked",
"@lockedTitle": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"@lockedTitle": {"placeholders": {"number": {"type": "int"}}},
"lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.",
"playbackError": "This clip could not be played.",
"catalogError": "The catalog could not be loaded.",
"categoriesError": "Categories could not be loaded.",
"detailError": "The episode list could not be loaded.",
"noMediaNotice": "This episode has no playable media yet.",
"noStories": "No stories available yet.",
"retry": "Retry",
"backToSeries": "Back to series",
@@ -217,144 +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": "Watch history, favorites, comments and downloads need an account.",
"authGateAction": "Sign In",
"cancel": "Not now",
"noEpisodesYet": "No playable episodes yet.",
"networkError": "Network is unreachable. Please check your connection and retry.",
"progressError": "Your watch position could not be loaded.",
"favoritesError": "Your favorites could not be loaded.",
"favoriteToggleFailed": "Could not update favorites. Please retry.",
"myFavorites": "My favorites",
"noFavorites": "No favorites yet.",
"downloads": "Downloads",
"downloadsError": "Download settings could not be loaded.",
"noDownloads": "No download authorizations yet.",
"downloadedItems": "Download authorizations",
"downloadQuota": "{remaining} of {max} authorizations available",
"@downloadQuota": {
"placeholders": {
"remaining": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"downloadLicenseDays": "Authorization valid for {days} days",
"@downloadLicenseDays": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"downloadWifiOnly": "Wi-Fi only",
"downloadQuality": "Maximum quality",
"downloadStatus": "Authorization: {status}",
"@downloadStatus": {
"placeholders": {
"status": {}
}
},
"downloadTaskStatus": "Task: {status}",
"@downloadTaskStatus": {
"placeholders": {
"status": {}
}
},
"downloadProgress": "{progress}%",
"@downloadProgress": {
"placeholders": {
"progress": {
"type": "int"
}
}
},
"downloadExpiresAt": "Expires {time}",
"@downloadExpiresAt": {
"placeholders": {
"time": {}
}
},
"clearDownloadsTitle": "Clear download authorizations?",
"clearDownloadsMessage": "This revokes every download authorization for this device.",
"downloadSettingsFailed": "Could not update download settings.",
"downloadDeleteFailed": "Could not remove this download authorization.",
"downloadClearFailed": "Could not clear download authorizations.",
"downloadGrantFailed": "Could not request this download.",
"downloadGranted": "Download authorization is ready.",
"noWatchRecords": "No watch history yet.",
"recordDeleteFailed": "Could not delete this record. Please retry.",
"recordClearFailed": "Could not clear watch history. Please retry.",
"clearAll": "Clear all",
"clearWatchHistoryTitle": "Clear watch history?",
"clearWatchHistoryMessage": "This removes the watch history from your account.",
"confirmClear": "Clear",
"detailLoadFailed": "Details failed to load",
"retryDetail": "Retry",
"episodeUnavailable": "Episode unavailable",
"updatedAtLabel": "Updated {time}",
"@updatedAtLabel": {
"placeholders": {
"time": {}
}
},
"remove": "Remove",
"unlockComingSoon": "Unlocking with coins is coming soon.",
"resumeProgress": "Resume at {position} / {total}",
"@resumeProgress": {
"placeholders": {
"position": {},
"total": {}
}
}
"brandMark": "NONTONKI · DEMO",
"shareFooter": "Nontonki demo"
}
-360
View File
@@ -1,360 +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",
"noComments": "Belum ada komentar.",
"writeComment": "Tulis komentar",
"postComment": "Kirim",
"loadMore": "Muat lebih banyak",
"commentsLoadFailed": "Komentar tidak dapat dimuat.",
"commentPostFailed": "Komentar tidak dapat dikirim.",
"delete": "Hapus",
"deleteComment": "Hapus komentar",
"deleteCommentTitle": "Hapus komentar ini?",
"deleteCommentMessage": "Komentar ini akan dihapus dari percakapan.",
"commentDeleteFailed": "Komentar tidak dapat dihapus.",
"underReview": "Sedang ditinjau",
"reply": "Balas",
"replyingTo": "Membalas {name}",
"@replyingTo": {
"placeholders": {
"name": {}
}
},
"cancelReply": "Batal membalas",
"viewReplies": "Lihat balasan ({count})",
"@viewReplies": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"hideReplies": "Sembunyikan balasan",
"anonymousUser": "Penonton",
"more": "Lainnya",
"likeFailed": "Status suka tidak dapat diperbarui.",
"reportComment": "Laporkan komentar",
"reportDrama": "Laporkan drama",
"reportReason": "Alasan",
"reportReasonSpam": "Spam",
"reportReasonAbuse": "Pelecehan",
"reportReasonPorn": "Konten seksual",
"reportReasonAd": "Iklan",
"reportReasonOther": "Lainnya",
"reportDescriptionHint": "Jelaskan masalah (opsional)",
"reportSubmit": "Kirim laporan",
"reportSubmitted": "Laporan terkirim. Terima kasih.",
"reportFailed": "Laporan tidak dapat dikirim.",
"shareFailed": "Tidak bisa membuka menu berbagi.",
"nowPlaying": "Sedang diputar",
"watchFullscreen": "Tonton mode lanskap",
"exitFullscreen": "Keluar dari layar penuh",
"pictureInPicture": "Gambar dalam gambar",
"pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini",
"originalTag": "Original",
"dramaTag": "Drama pendek",
"episodeLabel": "Episode {number}",
"@episodeLabel": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"exploreSeries": "Lihat serial",
"episodeCount": "{count} EPISODE",
"@episodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"technicalDemoCount": "PRATINJAU GRATIS · {count} EPISODE",
"@technicalDemoCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"episodes": "Episode",
"watchEpisodeOne": "Tonton episode 1",
"continueEpisode": "Lanjut episode {number}",
"@continueEpisode": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"freeTestMedia": "Klip demo sama · gratis",
"lockedStoreUnavailable": "Terkunci · toko belum tersedia",
"freeTestClip": "Klip uji gratis",
"locked": "Terkunci",
"freeSamplesNotice": "Dua sampel gratis · pengaturan toko belum selesai",
"demoTestMedia": "GRATIS · KLIP DEMO SAMA",
"episodeProgress": "EPISODE {number} / {total}",
"@episodeProgress": {
"placeholders": {
"number": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"testVideoNotice": "Video uji teknis buatan sendiri",
"lockedTitle": "Episode {number} terkunci",
"@lockedTitle": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"lockedBody": "Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.",
"playbackError": "Klip ini tidak bisa diputar.",
"catalogError": "Katalog gagal dimuat.",
"categoriesError": "Kategori gagal dimuat.",
"detailError": "Daftar episode gagal dimuat.",
"noMediaNotice": "Episode ini belum punya media yang bisa diputar.",
"noStories": "Belum ada cerita.",
"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": "Riwayat tontonan, favorit, komentar, dan unduhan perlu akun.",
"authGateAction": "Masuk",
"cancel": "Nanti saja",
"noEpisodesYet": "Belum ada episode yang bisa diputar.",
"networkError": "Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.",
"progressError": "Posisi tonton gagal dimuat.",
"favoritesError": "Daftar favorit gagal dimuat.",
"favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.",
"myFavorites": "Favorit saya",
"noFavorites": "Belum ada favorit.",
"downloads": "Unduhan",
"downloadsError": "Pengaturan unduhan gagal dimuat.",
"noDownloads": "Belum ada izin unduhan.",
"downloadedItems": "Izin unduhan",
"downloadQuota": "{remaining} dari {max} izin tersedia",
"@downloadQuota": {
"placeholders": {
"remaining": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"downloadLicenseDays": "Izin berlaku {days} hari",
"@downloadLicenseDays": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"downloadWifiOnly": "Hanya Wi-Fi",
"downloadQuality": "Kualitas maksimum",
"downloadStatus": "Izin: {status}",
"@downloadStatus": {
"placeholders": {
"status": {}
}
},
"downloadTaskStatus": "Tugas: {status}",
"@downloadTaskStatus": {
"placeholders": {
"status": {}
}
},
"downloadProgress": "{progress}%",
"@downloadProgress": {
"placeholders": {
"progress": {
"type": "int"
}
}
},
"downloadExpiresAt": "Berakhir {time}",
"@downloadExpiresAt": {
"placeholders": {
"time": {}
}
},
"clearDownloadsTitle": "Hapus izin unduhan?",
"clearDownloadsMessage": "Semua izin unduhan untuk perangkat ini akan dicabut.",
"downloadSettingsFailed": "Gagal memperbarui pengaturan unduhan.",
"downloadDeleteFailed": "Gagal menghapus izin unduhan ini.",
"downloadClearFailed": "Gagal menghapus semua izin unduhan.",
"downloadGrantFailed": "Gagal meminta unduhan ini.",
"downloadGranted": "Izin unduhan sudah siap.",
"noWatchRecords": "Belum ada riwayat tontonan.",
"recordDeleteFailed": "Gagal menghapus riwayat ini. Coba lagi.",
"recordClearFailed": "Gagal menghapus semua riwayat. Coba lagi.",
"clearAll": "Hapus semua",
"clearWatchHistoryTitle": "Hapus riwayat tontonan?",
"clearWatchHistoryMessage": "Semua riwayat akan dihapus dari akun kamu.",
"confirmClear": "Hapus",
"detailLoadFailed": "Detail drama gagal dimuat",
"retryDetail": "Coba lagi",
"episodeUnavailable": "Episode tidak tersedia",
"updatedAtLabel": "Diperbarui {time}",
"@updatedAtLabel": {
"placeholders": {
"time": {}
}
},
"remove": "Hapus",
"unlockComingSoon": "Pembukaan dengan koin segera hadir.",
"resumeProgress": "Lanjut di {position} / {total}",
"@resumeProgress": {
"placeholders": {
"position": {},
"total": {}
}
}
}
+12 -728
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.
@@ -448,6 +448,12 @@ abstract class AppLocalizations {
/// **'Share'**
String get share;
/// No description provided for @localOnlyComments.
///
/// In en, this message translates to:
/// **'Only visible on this device'**
String get localOnlyComments;
/// No description provided for @noComments.
///
/// In en, this message translates to:
@@ -466,180 +472,6 @@ abstract class AppLocalizations {
/// **'Post'**
String get postComment;
/// No description provided for @loadMore.
///
/// In en, this message translates to:
/// **'Load more'**
String get loadMore;
/// No description provided for @commentsLoadFailed.
///
/// In en, this message translates to:
/// **'Comments could not be loaded.'**
String get commentsLoadFailed;
/// No description provided for @commentPostFailed.
///
/// In en, this message translates to:
/// **'Could not post your comment.'**
String get commentPostFailed;
/// No description provided for @delete.
///
/// In en, this message translates to:
/// **'Delete'**
String get delete;
/// No description provided for @deleteComment.
///
/// In en, this message translates to:
/// **'Delete comment'**
String get deleteComment;
/// No description provided for @deleteCommentTitle.
///
/// In en, this message translates to:
/// **'Delete this comment?'**
String get deleteCommentTitle;
/// No description provided for @deleteCommentMessage.
///
/// In en, this message translates to:
/// **'This removes your comment from the conversation.'**
String get deleteCommentMessage;
/// No description provided for @commentDeleteFailed.
///
/// In en, this message translates to:
/// **'Could not delete this comment.'**
String get commentDeleteFailed;
/// No description provided for @underReview.
///
/// In en, this message translates to:
/// **'Under review'**
String get underReview;
/// No description provided for @reply.
///
/// In en, this message translates to:
/// **'Reply'**
String get reply;
/// No description provided for @replyingTo.
///
/// In en, this message translates to:
/// **'Replying to {name}'**
String replyingTo(Object name);
/// No description provided for @cancelReply.
///
/// In en, this message translates to:
/// **'Cancel reply'**
String get cancelReply;
/// No description provided for @viewReplies.
///
/// In en, this message translates to:
/// **'View replies ({count})'**
String viewReplies(int count);
/// No description provided for @hideReplies.
///
/// In en, this message translates to:
/// **'Hide replies'**
String get hideReplies;
/// No description provided for @anonymousUser.
///
/// In en, this message translates to:
/// **'Viewer'**
String get anonymousUser;
/// No description provided for @more.
///
/// In en, this message translates to:
/// **'More'**
String get more;
/// No description provided for @likeFailed.
///
/// In en, this message translates to:
/// **'Could not update like. Please retry.'**
String get likeFailed;
/// No description provided for @reportComment.
///
/// In en, this message translates to:
/// **'Report comment'**
String get reportComment;
/// No description provided for @reportDrama.
///
/// In en, this message translates to:
/// **'Report drama'**
String get reportDrama;
/// No description provided for @reportReason.
///
/// In en, this message translates to:
/// **'Reason'**
String get reportReason;
/// No description provided for @reportReasonSpam.
///
/// In en, this message translates to:
/// **'Spam'**
String get reportReasonSpam;
/// No description provided for @reportReasonAbuse.
///
/// In en, this message translates to:
/// **'Harassment'**
String get reportReasonAbuse;
/// No description provided for @reportReasonPorn.
///
/// In en, this message translates to:
/// **'Sexual content'**
String get reportReasonPorn;
/// No description provided for @reportReasonAd.
///
/// In en, this message translates to:
/// **'Advertising'**
String get reportReasonAd;
/// No description provided for @reportReasonOther.
///
/// In en, this message translates to:
/// **'Other'**
String get reportReasonOther;
/// No description provided for @reportDescriptionHint.
///
/// In en, this message translates to:
/// **'Describe the issue (optional)'**
String get reportDescriptionHint;
/// No description provided for @reportSubmit.
///
/// In en, this message translates to:
/// **'Submit report'**
String get reportSubmit;
/// No description provided for @reportSubmitted.
///
/// In en, this message translates to:
/// **'Report submitted. Thank you.'**
String get reportSubmitted;
/// No description provided for @reportFailed.
///
/// In en, this message translates to:
/// **'Could not submit report.'**
String get reportFailed;
/// No description provided for @shareFailed.
///
/// In en, this message translates to:
@@ -802,24 +634,6 @@ abstract class AppLocalizations {
/// **'The catalog could not be loaded.'**
String get catalogError;
/// No description provided for @categoriesError.
///
/// In en, this message translates to:
/// **'Categories could not be loaded.'**
String get categoriesError;
/// No description provided for @detailError.
///
/// In en, this message translates to:
/// **'The episode list could not be loaded.'**
String get detailError;
/// No description provided for @noMediaNotice.
///
/// In en, this message translates to:
/// **'This episode has no playable media yet.'**
String get noMediaNotice;
/// No description provided for @noStories.
///
/// In en, this message translates to:
@@ -1021,542 +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:
/// **'Watch history, favorites, comments and downloads need an account.'**
String get authGateMessage;
/// No description provided for @authGateAction.
///
/// In en, this message translates to:
/// **'Sign In'**
String get authGateAction;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Not now'**
String get cancel;
/// No description provided for @noEpisodesYet.
///
/// In en, this message translates to:
/// **'No playable episodes yet.'**
String get noEpisodesYet;
/// No description provided for @networkError.
///
/// In en, this message translates to:
/// **'Network is unreachable. Please check your connection and retry.'**
String get networkError;
/// No description provided for @progressError.
///
/// In en, this message translates to:
/// **'Your watch position could not be loaded.'**
String get progressError;
/// No description provided for @favoritesError.
///
/// In en, this message translates to:
/// **'Your favorites could not be loaded.'**
String get favoritesError;
/// No description provided for @favoriteToggleFailed.
///
/// In en, this message translates to:
/// **'Could not update favorites. Please retry.'**
String get favoriteToggleFailed;
/// No description provided for @myFavorites.
///
/// In en, this message translates to:
/// **'My favorites'**
String get myFavorites;
/// No description provided for @noFavorites.
///
/// In en, this message translates to:
/// **'No favorites yet.'**
String get noFavorites;
/// No description provided for @downloads.
///
/// In en, this message translates to:
/// **'Downloads'**
String get downloads;
/// No description provided for @downloadsError.
///
/// In en, this message translates to:
/// **'Download settings could not be loaded.'**
String get downloadsError;
/// No description provided for @noDownloads.
///
/// In en, this message translates to:
/// **'No download authorizations yet.'**
String get noDownloads;
/// No description provided for @downloadedItems.
///
/// In en, this message translates to:
/// **'Download authorizations'**
String get downloadedItems;
/// No description provided for @downloadQuota.
///
/// In en, this message translates to:
/// **'{remaining} of {max} authorizations available'**
String downloadQuota(int remaining, int max);
/// No description provided for @downloadLicenseDays.
///
/// In en, this message translates to:
/// **'Authorization valid for {days} days'**
String downloadLicenseDays(int days);
/// No description provided for @downloadWifiOnly.
///
/// In en, this message translates to:
/// **'Wi-Fi only'**
String get downloadWifiOnly;
/// No description provided for @downloadQuality.
///
/// In en, this message translates to:
/// **'Maximum quality'**
String get downloadQuality;
/// No description provided for @downloadStatus.
///
/// In en, this message translates to:
/// **'Authorization: {status}'**
String downloadStatus(Object status);
/// No description provided for @downloadTaskStatus.
///
/// In en, this message translates to:
/// **'Task: {status}'**
String downloadTaskStatus(Object status);
/// No description provided for @downloadProgress.
///
/// In en, this message translates to:
/// **'{progress}%'**
String downloadProgress(int progress);
/// No description provided for @downloadExpiresAt.
///
/// In en, this message translates to:
/// **'Expires {time}'**
String downloadExpiresAt(Object time);
/// No description provided for @clearDownloadsTitle.
///
/// In en, this message translates to:
/// **'Clear download authorizations?'**
String get clearDownloadsTitle;
/// No description provided for @clearDownloadsMessage.
///
/// In en, this message translates to:
/// **'This revokes every download authorization for this device.'**
String get clearDownloadsMessage;
/// No description provided for @downloadSettingsFailed.
///
/// In en, this message translates to:
/// **'Could not update download settings.'**
String get downloadSettingsFailed;
/// No description provided for @downloadDeleteFailed.
///
/// In en, this message translates to:
/// **'Could not remove this download authorization.'**
String get downloadDeleteFailed;
/// No description provided for @downloadClearFailed.
///
/// In en, this message translates to:
/// **'Could not clear download authorizations.'**
String get downloadClearFailed;
/// No description provided for @downloadGrantFailed.
///
/// In en, this message translates to:
/// **'Could not request this download.'**
String get downloadGrantFailed;
/// No description provided for @downloadGranted.
///
/// In en, this message translates to:
/// **'Download authorization is ready.'**
String get downloadGranted;
/// No description provided for @noWatchRecords.
///
/// In en, this message translates to:
/// **'No watch history yet.'**
String get noWatchRecords;
/// No description provided for @recordDeleteFailed.
///
/// In en, this message translates to:
/// **'Could not delete this record. Please retry.'**
String get recordDeleteFailed;
/// No description provided for @recordClearFailed.
///
/// In en, this message translates to:
/// **'Could not clear watch history. Please retry.'**
String get recordClearFailed;
/// No description provided for @clearAll.
///
/// In en, this message translates to:
/// **'Clear all'**
String get clearAll;
/// No description provided for @clearWatchHistoryTitle.
///
/// In en, this message translates to:
/// **'Clear watch history?'**
String get clearWatchHistoryTitle;
/// No description provided for @clearWatchHistoryMessage.
///
/// In en, this message translates to:
/// **'This removes the watch history from your account.'**
String get clearWatchHistoryMessage;
/// No description provided for @confirmClear.
///
/// In en, this message translates to:
/// **'Clear'**
String get confirmClear;
/// No description provided for @detailLoadFailed.
///
/// In en, this message translates to:
/// **'Details failed to load'**
String get detailLoadFailed;
/// No description provided for @retryDetail.
///
/// In en, this message translates to:
/// **'Retry'**
String get retryDetail;
/// No description provided for @episodeUnavailable.
///
/// In en, this message translates to:
/// **'Episode unavailable'**
String get episodeUnavailable;
/// No description provided for @updatedAtLabel.
///
/// In en, this message translates to:
/// **'Updated {time}'**
String updatedAtLabel(Object time);
/// No description provided for @remove.
///
/// In en, this message translates to:
/// **'Remove'**
String get remove;
/// No description provided for @unlockComingSoon.
///
/// In en, this message translates to:
/// **'Unlocking with coins is coming soon.'**
String get unlockComingSoon;
/// No description provided for @resumeProgress.
///
/// In en, this message translates to:
/// **'Resume at {position} / {total}'**
String resumeProgress(Object position, Object total);
}
class _AppLocalizationsDelegate
@@ -1570,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;
@@ -1581,8 +867,6 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'id':
return AppLocalizationsId();
case 'zh':
return AppLocalizationsZh();
}
+8 -392
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';
@@ -187,6 +189,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get share => 'Share';
@override
String get localOnlyComments => 'Only visible on this device';
@override
String get noComments => 'No comments yet.';
@@ -196,98 +201,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get postComment => 'Post';
@override
String get loadMore => 'Load more';
@override
String get commentsLoadFailed => 'Comments could not be loaded.';
@override
String get commentPostFailed => 'Could not post your comment.';
@override
String get delete => 'Delete';
@override
String get deleteComment => 'Delete comment';
@override
String get deleteCommentTitle => 'Delete this comment?';
@override
String get deleteCommentMessage =>
'This removes your comment from the conversation.';
@override
String get commentDeleteFailed => 'Could not delete this comment.';
@override
String get underReview => 'Under review';
@override
String get reply => 'Reply';
@override
String replyingTo(Object name) {
return 'Replying to $name';
}
@override
String get cancelReply => 'Cancel reply';
@override
String viewReplies(int count) {
return 'View replies ($count)';
}
@override
String get hideReplies => 'Hide replies';
@override
String get anonymousUser => 'Viewer';
@override
String get more => 'More';
@override
String get likeFailed => 'Could not update like. Please retry.';
@override
String get reportComment => 'Report comment';
@override
String get reportDrama => 'Report drama';
@override
String get reportReason => 'Reason';
@override
String get reportReasonSpam => 'Spam';
@override
String get reportReasonAbuse => 'Harassment';
@override
String get reportReasonPorn => 'Sexual content';
@override
String get reportReasonAd => 'Advertising';
@override
String get reportReasonOther => 'Other';
@override
String get reportDescriptionHint => 'Describe the issue (optional)';
@override
String get reportSubmit => 'Submit report';
@override
String get reportSubmitted => 'Report submitted. Thank you.';
@override
String get reportFailed => 'Could not submit report.';
@override
String get shareFailed => 'Could not open the share sheet.';
@@ -383,15 +296,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get catalogError => 'The catalog could not be loaded.';
@override
String get categoriesError => 'Categories could not be loaded.';
@override
String get detailError => 'The episode list could not be loaded.';
@override
String get noMediaNotice => 'This episode has no playable media yet.';
@override
String get noStories => 'No stories available yet.';
@@ -495,296 +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 =>
'Watch history, favorites, comments and downloads need an account.';
@override
String get authGateAction => 'Sign In';
@override
String get cancel => 'Not now';
@override
String get noEpisodesYet => 'No playable episodes yet.';
@override
String get networkError =>
'Network is unreachable. Please check your connection and retry.';
@override
String get progressError => 'Your watch position could not be loaded.';
@override
String get favoritesError => 'Your favorites could not be loaded.';
@override
String get favoriteToggleFailed =>
'Could not update favorites. Please retry.';
@override
String get myFavorites => 'My favorites';
@override
String get noFavorites => 'No favorites yet.';
@override
String get downloads => 'Downloads';
@override
String get downloadsError => 'Download settings could not be loaded.';
@override
String get noDownloads => 'No download authorizations yet.';
@override
String get downloadedItems => 'Download authorizations';
@override
String downloadQuota(int remaining, int max) {
return '$remaining of $max authorizations available';
}
@override
String downloadLicenseDays(int days) {
return 'Authorization valid for $days days';
}
@override
String get downloadWifiOnly => 'Wi-Fi only';
@override
String get downloadQuality => 'Maximum quality';
@override
String downloadStatus(Object status) {
return 'Authorization: $status';
}
@override
String downloadTaskStatus(Object status) {
return 'Task: $status';
}
@override
String downloadProgress(int progress) {
return '$progress%';
}
@override
String downloadExpiresAt(Object time) {
return 'Expires $time';
}
@override
String get clearDownloadsTitle => 'Clear download authorizations?';
@override
String get clearDownloadsMessage =>
'This revokes every download authorization for this device.';
@override
String get downloadSettingsFailed => 'Could not update download settings.';
@override
String get downloadDeleteFailed =>
'Could not remove this download authorization.';
@override
String get downloadClearFailed => 'Could not clear download authorizations.';
@override
String get downloadGrantFailed => 'Could not request this download.';
@override
String get downloadGranted => 'Download authorization is ready.';
@override
String get noWatchRecords => 'No watch history yet.';
@override
String get recordDeleteFailed =>
'Could not delete this record. Please retry.';
@override
String get recordClearFailed =>
'Could not clear watch history. Please retry.';
@override
String get clearAll => 'Clear all';
@override
String get clearWatchHistoryTitle => 'Clear watch history?';
@override
String get clearWatchHistoryMessage =>
'This removes the watch history from your account.';
@override
String get confirmClear => 'Clear';
@override
String get detailLoadFailed => 'Details failed to load';
@override
String get retryDetail => 'Retry';
@override
String get episodeUnavailable => 'Episode unavailable';
@override
String updatedAtLabel(Object time) {
return 'Updated $time';
}
@override
String get remove => 'Remove';
@override
String get unlockComingSoon => 'Unlocking with coins is coming soon.';
@override
String resumeProgress(Object position, Object total) {
return 'Resume at $position / $total';
}
String get shareFooter => 'Nontonki demo';
}
-788
View File
@@ -1,788 +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 noComments => 'Belum ada komentar.';
@override
String get writeComment => 'Tulis komentar';
@override
String get postComment => 'Kirim';
@override
String get loadMore => 'Muat lebih banyak';
@override
String get commentsLoadFailed => 'Komentar tidak dapat dimuat.';
@override
String get commentPostFailed => 'Komentar tidak dapat dikirim.';
@override
String get delete => 'Hapus';
@override
String get deleteComment => 'Hapus komentar';
@override
String get deleteCommentTitle => 'Hapus komentar ini?';
@override
String get deleteCommentMessage =>
'Komentar ini akan dihapus dari percakapan.';
@override
String get commentDeleteFailed => 'Komentar tidak dapat dihapus.';
@override
String get underReview => 'Sedang ditinjau';
@override
String get reply => 'Balas';
@override
String replyingTo(Object name) {
return 'Membalas $name';
}
@override
String get cancelReply => 'Batal membalas';
@override
String viewReplies(int count) {
return 'Lihat balasan ($count)';
}
@override
String get hideReplies => 'Sembunyikan balasan';
@override
String get anonymousUser => 'Penonton';
@override
String get more => 'Lainnya';
@override
String get likeFailed => 'Status suka tidak dapat diperbarui.';
@override
String get reportComment => 'Laporkan komentar';
@override
String get reportDrama => 'Laporkan drama';
@override
String get reportReason => 'Alasan';
@override
String get reportReasonSpam => 'Spam';
@override
String get reportReasonAbuse => 'Pelecehan';
@override
String get reportReasonPorn => 'Konten seksual';
@override
String get reportReasonAd => 'Iklan';
@override
String get reportReasonOther => 'Lainnya';
@override
String get reportDescriptionHint => 'Jelaskan masalah (opsional)';
@override
String get reportSubmit => 'Kirim laporan';
@override
String get reportSubmitted => 'Laporan terkirim. Terima kasih.';
@override
String get reportFailed => 'Laporan tidak dapat dikirim.';
@override
String get shareFailed => 'Tidak bisa membuka menu berbagi.';
@override
String get nowPlaying => 'Sedang diputar';
@override
String get watchFullscreen => 'Tonton mode lanskap';
@override
String get exitFullscreen => 'Keluar dari layar penuh';
@override
String get pictureInPicture => 'Gambar dalam gambar';
@override
String get pipUnavailable =>
'Gambar dalam gambar tidak tersedia di perangkat ini';
@override
String get originalTag => 'Original';
@override
String get dramaTag => 'Drama pendek';
@override
String episodeLabel(int number) {
return 'Episode $number';
}
@override
String get exploreSeries => 'Lihat serial';
@override
String episodeCount(int count) {
return '$count EPISODE';
}
@override
String technicalDemoCount(int count) {
return 'PRATINJAU GRATIS · $count EPISODE';
}
@override
String get episodes => 'Episode';
@override
String get watchEpisodeOne => 'Tonton episode 1';
@override
String continueEpisode(int number) {
return 'Lanjut episode $number';
}
@override
String get freeTestMedia => 'Klip demo sama · gratis';
@override
String get lockedStoreUnavailable => 'Terkunci · toko belum tersedia';
@override
String get freeTestClip => 'Klip uji gratis';
@override
String get locked => 'Terkunci';
@override
String get freeSamplesNotice =>
'Dua sampel gratis · pengaturan toko belum selesai';
@override
String get demoTestMedia => 'GRATIS · KLIP DEMO SAMA';
@override
String episodeProgress(int number, int total) {
return 'EPISODE $number / $total';
}
@override
String get testVideoNotice => 'Video uji teknis buatan sendiri';
@override
String lockedTitle(int number) {
return 'Episode $number terkunci';
}
@override
String get lockedBody =>
'Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.';
@override
String get playbackError => 'Klip ini tidak bisa diputar.';
@override
String get catalogError => 'Katalog gagal dimuat.';
@override
String get categoriesError => 'Kategori gagal dimuat.';
@override
String get detailError => 'Daftar episode gagal dimuat.';
@override
String get noMediaNotice =>
'Episode ini belum punya media yang bisa diputar.';
@override
String get noStories => 'Belum ada cerita.';
@override
String get 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 =>
'Riwayat tontonan, favorit, komentar, dan unduhan perlu akun.';
@override
String get authGateAction => 'Masuk';
@override
String get cancel => 'Nanti saja';
@override
String get noEpisodesYet => 'Belum ada episode yang bisa diputar.';
@override
String get networkError =>
'Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.';
@override
String get progressError => 'Posisi tonton gagal dimuat.';
@override
String get favoritesError => 'Daftar favorit gagal dimuat.';
@override
String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.';
@override
String get myFavorites => 'Favorit saya';
@override
String get noFavorites => 'Belum ada favorit.';
@override
String get downloads => 'Unduhan';
@override
String get downloadsError => 'Pengaturan unduhan gagal dimuat.';
@override
String get noDownloads => 'Belum ada izin unduhan.';
@override
String get downloadedItems => 'Izin unduhan';
@override
String downloadQuota(int remaining, int max) {
return '$remaining dari $max izin tersedia';
}
@override
String downloadLicenseDays(int days) {
return 'Izin berlaku $days hari';
}
@override
String get downloadWifiOnly => 'Hanya Wi-Fi';
@override
String get downloadQuality => 'Kualitas maksimum';
@override
String downloadStatus(Object status) {
return 'Izin: $status';
}
@override
String downloadTaskStatus(Object status) {
return 'Tugas: $status';
}
@override
String downloadProgress(int progress) {
return '$progress%';
}
@override
String downloadExpiresAt(Object time) {
return 'Berakhir $time';
}
@override
String get clearDownloadsTitle => 'Hapus izin unduhan?';
@override
String get clearDownloadsMessage =>
'Semua izin unduhan untuk perangkat ini akan dicabut.';
@override
String get downloadSettingsFailed => 'Gagal memperbarui pengaturan unduhan.';
@override
String get downloadDeleteFailed => 'Gagal menghapus izin unduhan ini.';
@override
String get downloadClearFailed => 'Gagal menghapus semua izin unduhan.';
@override
String get downloadGrantFailed => 'Gagal meminta unduhan ini.';
@override
String get downloadGranted => 'Izin unduhan sudah siap.';
@override
String get noWatchRecords => 'Belum ada riwayat tontonan.';
@override
String get recordDeleteFailed => 'Gagal menghapus riwayat ini. Coba lagi.';
@override
String get recordClearFailed => 'Gagal menghapus semua riwayat. Coba lagi.';
@override
String get clearAll => 'Hapus semua';
@override
String get clearWatchHistoryTitle => 'Hapus riwayat tontonan?';
@override
String get clearWatchHistoryMessage =>
'Semua riwayat akan dihapus dari akun kamu.';
@override
String get confirmClear => 'Hapus';
@override
String get detailLoadFailed => 'Detail drama gagal dimuat';
@override
String get retryDetail => 'Coba lagi';
@override
String get episodeUnavailable => 'Episode tidak tersedia';
@override
String updatedAtLabel(Object time) {
return 'Diperbarui $time';
}
@override
String get remove => 'Hapus';
@override
String get unlockComingSoon => 'Pembukaan dengan koin segera hadir.';
@override
String resumeProgress(Object position, Object total) {
return 'Lanjut di $position / $total';
}
}
+8 -383
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 => '演示';
@@ -184,6 +186,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get share => '分享';
@override
String get localOnlyComments => '仅在本机可见';
@override
String get noComments => '还没有评论。';
@@ -193,97 +198,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get postComment => '发布';
@override
String get loadMore => '加载更多';
@override
String get commentsLoadFailed => '评论加载失败,请重试。';
@override
String get commentPostFailed => '评论发布失败,请重试。';
@override
String get delete => '删除';
@override
String get deleteComment => '删除评论';
@override
String get deleteCommentTitle => '删除这条评论?';
@override
String get deleteCommentMessage => '删除后,这条评论将从评论列表中移除。';
@override
String get commentDeleteFailed => '评论删除失败,请重试。';
@override
String get underReview => '审核中';
@override
String get reply => '回复';
@override
String replyingTo(Object name) {
return '回复 $name';
}
@override
String get cancelReply => '取消回复';
@override
String viewReplies(int count) {
return '查看回复($count)';
}
@override
String get hideReplies => '收起回复';
@override
String get anonymousUser => '观众';
@override
String get more => '更多';
@override
String get likeFailed => '点赞状态更新失败,请重试。';
@override
String get reportComment => '举报评论';
@override
String get reportDrama => '举报短剧';
@override
String get reportReason => '举报原因';
@override
String get reportReasonSpam => '垃圾广告';
@override
String get reportReasonAbuse => '辱骂骚扰';
@override
String get reportReasonPorn => '色情低俗';
@override
String get reportReasonAd => '引流广告';
@override
String get reportReasonOther => '其他';
@override
String get reportDescriptionHint => '补充说明(选填)';
@override
String get reportSubmit => '提交举报';
@override
String get reportSubmitted => '举报已提交,感谢反馈。';
@override
String get reportFailed => '举报提交失败,请重试。';
@override
String get shareFailed => '无法打开系统分享面板。';
@@ -377,15 +291,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get catalogError => '剧目列表加载失败。';
@override
String get categoriesError => '分类加载失败。';
@override
String get detailError => '剧集信息加载失败。';
@override
String get noMediaNotice => '该剧集还没有可播放的片源。';
@override
String get noStories => '暂无剧目。';
@@ -486,288 +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 => '暂不';
@override
String get noEpisodesYet => '还没有可播放的剧集。';
@override
String get networkError => '网络异常,请检查网络后重试。';
@override
String get progressError => '续看进度加载失败。';
@override
String get favoritesError => '收藏列表加载失败。';
@override
String get favoriteToggleFailed => '收藏操作失败,请重试。';
@override
String get myFavorites => '我的收藏';
@override
String get noFavorites => '还没有收藏。';
@override
String get downloads => '下载';
@override
String get downloadsError => '下载设置加载失败。';
@override
String get noDownloads => '还没有下载授权。';
@override
String get downloadedItems => '下载授权';
@override
String downloadQuota(int remaining, int max) {
return '剩余 $remaining / $max 个授权';
}
@override
String downloadLicenseDays(int days) {
return '授权有效期 $days 天';
}
@override
String get downloadWifiOnly => '仅 Wi-Fi 下载';
@override
String get downloadQuality => '最高清晰度';
@override
String downloadStatus(Object status) {
return '授权:$status';
}
@override
String downloadTaskStatus(Object status) {
return '任务:$status';
}
@override
String downloadProgress(int progress) {
return '$progress%';
}
@override
String downloadExpiresAt(Object time) {
return '到期时间 $time';
}
@override
String get clearDownloadsTitle => '清空下载授权?';
@override
String get clearDownloadsMessage => '这会吊销当前设备的全部下载授权。';
@override
String get downloadSettingsFailed => '下载设置更新失败。';
@override
String get downloadDeleteFailed => '删除下载授权失败。';
@override
String get downloadClearFailed => '清空下载授权失败。';
@override
String get downloadGrantFailed => '申请下载失败。';
@override
String get downloadGranted => '下载授权已就绪。';
@override
String get noWatchRecords => '还没有观看记录。';
@override
String get recordDeleteFailed => '删除记录失败,请重试。';
@override
String get recordClearFailed => '清空观看记录失败,请重试。';
@override
String get clearAll => '清空';
@override
String get clearWatchHistoryTitle => '清空观看记录?';
@override
String get clearWatchHistoryMessage => '这会从当前账号移除全部观看记录。';
@override
String get confirmClear => '清空';
@override
String get detailLoadFailed => '剧集信息加载失败';
@override
String get retryDetail => '重试';
@override
String get episodeUnavailable => '集数未知';
@override
String updatedAtLabel(Object time) {
return '更新于 $time';
}
@override
String get remove => '移除';
@override
String get unlockComingSoon => '金币解锁功能即将开放。';
@override
String resumeProgress(Object position, Object total) {
return '续看至 $position / $total';
}
String get shareFooter => 'Nontonki 演示';
}
+6 -199
View File
@@ -1,6 +1,6 @@
{
"@@locale": "zh",
"appName": "FruitShorts",
"appName": "Nontonki",
"demo": "演示",
"homeHeadline": "即刻观看",
"homeSubtitle": "免费短剧预览。",
@@ -34,13 +34,7 @@
"localDemoProfile": "本地演示档案",
"continueWatching": "继续观看",
"watchedPercent": "已观看 {percent}%",
"@watchedPercent": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
"resume": "继续播放",
"history": "历史",
"watchlist": "追剧",
@@ -65,50 +59,10 @@
"save": "收藏",
"comments": "评论",
"share": "分享",
"localOnlyComments": "仅在本机可见",
"noComments": "还没有评论。",
"writeComment": "写评论",
"postComment": "发布",
"loadMore": "加载更多",
"commentsLoadFailed": "评论加载失败,请重试。",
"commentPostFailed": "评论发布失败,请重试。",
"delete": "删除",
"deleteComment": "删除评论",
"deleteCommentTitle": "删除这条评论?",
"deleteCommentMessage": "删除后,这条评论将从评论列表中移除。",
"commentDeleteFailed": "评论删除失败,请重试。",
"underReview": "审核中",
"reply": "回复",
"replyingTo": "回复 {name}",
"@replyingTo": {
"placeholders": {
"name": {}
}
},
"cancelReply": "取消回复",
"viewReplies": "查看回复({count})",
"@viewReplies": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"hideReplies": "收起回复",
"anonymousUser": "观众",
"more": "更多",
"likeFailed": "点赞状态更新失败,请重试。",
"reportComment": "举报评论",
"reportDrama": "举报短剧",
"reportReason": "举报原因",
"reportReasonSpam": "垃圾广告",
"reportReasonAbuse": "辱骂骚扰",
"reportReasonPorn": "色情低俗",
"reportReasonAd": "引流广告",
"reportReasonOther": "其他",
"reportDescriptionHint": "补充说明(选填)",
"reportSubmit": "提交举报",
"reportSubmitted": "举报已提交,感谢反馈。",
"reportFailed": "举报提交失败,请重试。",
"shareFailed": "无法打开系统分享面板。",
"nowPlaying": "正在播放",
"watchFullscreen": "横屏全屏观看",
@@ -118,13 +72,7 @@
"originalTag": "原创",
"dramaTag": "短剧",
"episodeLabel": "第 {number} 集",
"@episodeLabel": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
"exploreSeries": "查看剧集",
"episodeCount": "{count} 集",
"technicalDemoCount": "免费试看 · {count} 集",
@@ -143,9 +91,6 @@
"lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。",
"playbackError": "此视频暂时无法播放。",
"catalogError": "剧目列表加载失败。",
"categoriesError": "分类加载失败。",
"detailError": "剧集信息加载失败。",
"noMediaNotice": "该剧集还没有可播放的片源。",
"noStories": "暂无剧目。",
"retry": "重试",
"backToSeries": "返回剧集详情",
@@ -179,144 +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": "暂不",
"noEpisodesYet": "还没有可播放的剧集。",
"networkError": "网络异常,请检查网络后重试。",
"progressError": "续看进度加载失败。",
"favoritesError": "收藏列表加载失败。",
"favoriteToggleFailed": "收藏操作失败,请重试。",
"myFavorites": "我的收藏",
"noFavorites": "还没有收藏。",
"downloads": "下载",
"downloadsError": "下载设置加载失败。",
"noDownloads": "还没有下载授权。",
"downloadedItems": "下载授权",
"downloadQuota": "剩余 {remaining} / {max} 个授权",
"@downloadQuota": {
"placeholders": {
"remaining": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"downloadLicenseDays": "授权有效期 {days} 天",
"@downloadLicenseDays": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"downloadWifiOnly": "仅 Wi-Fi 下载",
"downloadQuality": "最高清晰度",
"downloadStatus": "授权:{status}",
"@downloadStatus": {
"placeholders": {
"status": {}
}
},
"downloadTaskStatus": "任务:{status}",
"@downloadTaskStatus": {
"placeholders": {
"status": {}
}
},
"downloadProgress": "{progress}%",
"@downloadProgress": {
"placeholders": {
"progress": {
"type": "int"
}
}
},
"downloadExpiresAt": "到期时间 {time}",
"@downloadExpiresAt": {
"placeholders": {
"time": {}
}
},
"clearDownloadsTitle": "清空下载授权?",
"clearDownloadsMessage": "这会吊销当前设备的全部下载授权。",
"downloadSettingsFailed": "下载设置更新失败。",
"downloadDeleteFailed": "删除下载授权失败。",
"downloadClearFailed": "清空下载授权失败。",
"downloadGrantFailed": "申请下载失败。",
"downloadGranted": "下载授权已就绪。",
"noWatchRecords": "还没有观看记录。",
"recordDeleteFailed": "删除记录失败,请重试。",
"recordClearFailed": "清空观看记录失败,请重试。",
"clearAll": "清空",
"clearWatchHistoryTitle": "清空观看记录?",
"clearWatchHistoryMessage": "这会从当前账号移除全部观看记录。",
"confirmClear": "清空",
"detailLoadFailed": "剧集信息加载失败",
"retryDetail": "重试",
"episodeUnavailable": "集数未知",
"updatedAtLabel": "更新于 {time}",
"@updatedAtLabel": {
"placeholders": {
"time": {}
}
},
"remove": "移除",
"unlockComingSoon": "金币解锁功能即将开放。",
"resumeProgress": "续看至 {position} / {total}",
"@resumeProgress": {
"placeholders": {
"position": {},
"total": {}
}
}
"brandMark": "NONTONKI · 演示",
"shareFooter": "Nontonki 演示"
}
-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 -2
View File
@@ -7,9 +7,9 @@ import 'discover_logic.dart';
class DiscoverBinding {
DiscoverLogic create({
required Drama drama,
required WatchHistoryRepository history,
WatchHistoryRepository? history,
}) => DiscoverLogic(
drama: drama,
history: history,
history: history ?? SharedPreferencesWatchHistoryRepository(),
);
}
+47 -22
View File
@@ -19,7 +19,7 @@ class DiscoverLogic extends ChangeNotifier {
final TextEditingController search = TextEditingController();
final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController();
bool searchOpen = false;
int category = 0;
bool _disposed = false;
void notify() {
@@ -27,23 +27,20 @@ class DiscoverLogic extends ChangeNotifier {
}
void refreshPosition() {
// 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看"
position = history
.resumePointFor(drama.id)
.then((point) => point?.position)
.catchError((Object _) => null);
position = history.positionFor(
demoProfileId,
drama.id,
drama.episodes.first.id,
);
}
void selectCategory(int index) {
category = index;
notify();
}
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();
});
@@ -99,12 +94,7 @@ class DiscoverLogic extends ChangeNotifier {
} else if (embedded && onDramaSelected != null) {
onDramaSelected(selected);
} else {
act(
context,
DiscoverPageAction.detail,
embedded: embedded,
onAction: onAction,
);
act(context, DiscoverPageAction.detail, embedded: embedded, onAction: onAction);
}
}
@@ -122,6 +112,41 @@ class DiscoverLogic extends ChangeNotifier {
).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice)));
}
void showFilters(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final labels = [
l10n.featured,
l10n.suspense,
l10n.romance,
l10n.actionGenre,
];
showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF202126),
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text(l10n.filter)),
for (var index = 0; index < labels.length; index++)
ListTile(
title: Text(labels[index]),
trailing: index == category
? const Icon(Icons.check_rounded, color: Color(0xFFFF5B3D))
: null,
onTap: () {
selectCategory(index);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
),
),
);
}
@override
void dispose() {
_disposed = true;
+446 -391
View File
@@ -2,18 +2,13 @@
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/category_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({
@@ -25,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,
@@ -41,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,9 +48,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
super.initState();
logic = DiscoverBinding().create(
drama: widget.drama,
history: widget.history ?? context.read<WatchHistoryRepository>(),
history: widget.history,
);
if (widget.focusSearch) logic.searchOpen = true;
}
@override
@@ -96,39 +82,50 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>();
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
final cards = _cardsFor(categories.visibleDramas, '');
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, categories.feed.banners, allDramas),
_searchBar(l10n),
const SizedBox(height: 12),
_categories(l10n),
const SizedBox(height: 15),
_continueCard(l10n),
const SizedBox(height: 15),
_quickActions(l10n),
const SizedBox(height: 20),
_sectionTitle(l10n),
const SizedBox(height: 13),
if (cards.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
l10n.noSearchResults,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: cards.length,
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 14,
childAspectRatio: 0.59,
),
itemBuilder: (context, index) =>
_posterCard(l10n, cards[index]),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_categories(l10n, categories),
const SizedBox(height: 18),
_continueHeader(l10n),
const SizedBox(height: 12),
_dramaContent(l10n, categories, cards),
],
),
),
],
),
),
@@ -147,292 +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 _dramaContent(
AppLocalizations l10n,
CategoryController categories,
List<_ShowcaseCard> cards,
) {
if (categories.loading && categories.feed.sections.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (categories.error != null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Row(
children: [
Expanded(
child: Text(
l10n.categoriesError,
style: const TextStyle(color: Color(0xFFE6E1E3)),
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,
),
TextButton(onPressed: categories.load, child: Text(l10n.retry)),
],
),
),
);
}
if (cards.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
categories.selectedSectionMissing
? l10n.noCategoryDramas
: l10n.noSearchResults,
style: const TextStyle(color: Color(0xFFE6E1E3)),
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: 196,
child: ListView(
height: 30,
child: ListView.separated(
scrollDirection: Axis.horizontal,
children: [
for (final card in cards) ...[
_continueTile(l10n, card, categories.visibleDramas),
const SizedBox(width: 12),
],
],
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 _continueTile(
AppLocalizations l10n,
_ShowcaseCard card,
List<Drama> source,
) {
final drama = logic.dramaFor(card.dramaId, source);
return SizedBox(
width: 132,
child: InkWell(
onTap: () => logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
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: 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],
),
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, CategoryController categories) {
final choices = <({String code, String label})>[
(code: CategoryController.allCode, label: l10n.forYou),
(code: CategoryController.hotCode, label: l10n.hot),
(code: CategoryController.newCode, label: l10n.newest),
for (final category in categories.categories)
(code: 'category-${category.id}', label: category.name),
];
return SizedBox(
height: 32,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: choices.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final choice = choices[index];
final selected = categories.selectedCode == choice.code;
return GestureDetector(
onTap: () => categories.selectCode(choice.code),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: selected ? AppColors.blue : const Color(0xFF1C1E24),
borderRadius: BorderRadius.circular(16),
),
child: Text(
choice.label,
maxLines: 1,
style: TextStyle(
color: selected ? Colors.white : AppColors.muted,
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
height: 1.1,
),
Widget _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),
),
],
),
);
}
@@ -522,122 +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, String query) {
final normalized = query.trim().toLowerCase();
return [
for (final drama in dramas)
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,
),
List<_ShowcaseCard> _cardsFor(
AppLocalizations l10n,
int category,
String query,
) {
final cards = [
_ShowcaseCard(
title: l10n.dramaSignalTitle,
genre: l10n.genreSuspenseMystery,
image: 'assets/images/stitch_signal_poster_clean.png',
category: 1,
dramaId: 'the-signal-demo',
),
_ShowcaseCard(
title: l10n.dramaCityTitle,
genre: l10n.genreUrbanRomance,
image: 'assets/images/stitch_city_poster_clean.png',
category: 2,
dramaId: 'city-lights-demo',
),
_ShowcaseCard(
title: l10n.dramaVengeanceTitle,
genre: l10n.genreActionThriller,
image: 'assets/images/stitch_vengeance_poster_clean.png',
category: 3,
dramaId: 'vengeance-demo',
),
_ShowcaseCard(
title: l10n.dramaEchoesTitle,
genre: l10n.genreDramaMystery,
image: 'assets/images/stitch_rain_scene.jpg',
category: 1,
),
];
final normalized = query.trim().toLowerCase();
return cards.where((card) {
final categoryMatches = category == 0 || card.category == category;
final queryMatches =
normalized.isEmpty ||
card.title.toLowerCase().contains(normalized) ||
card.genre.toLowerCase().contains(normalized);
return categoryMatches && queryMatches;
}).toList();
}
const _orange = 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,9 +0,0 @@
// Author: fruit.com.cn
import '../../core/data/services/download_controller.dart';
/// 下载管理页依赖装配。
class DownloadsBinding {
/// 从服务端刷新策略与授权列表。
Future<void> load(DownloadController downloads) => downloads.load();
}
-409
View File
@@ -1,409 +0,0 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/download_license.dart';
import '../../core/data/models/download_policy.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/auth_gate.dart';
import '../../l10n/app_localizations.dart';
import 'downloads_binding.dart';
/// 下载授权与状态管理页;本批不落盘,只管理服务端授权。
class DownloadsPage extends StatefulWidget {
const DownloadsPage({super.key});
@override
State<DownloadsPage> createState() => _DownloadsPageState();
}
class _DownloadsPageState extends State<DownloadsPage> {
bool _requested = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final signedIn = context.watch<AuthController>().isSignedIn;
if (!signedIn) {
_requested = false;
return;
}
if (_requested) return;
_requested = true;
final downloads = context.read<DownloadController>();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(DownloadsBinding().load(downloads));
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Scaffold(
key: const Key('downloads-page'),
backgroundColor: const Color(0xFF0B0C10),
appBar: AppBar(
backgroundColor: const Color(0xFF0B0C10),
foregroundColor: Colors.white,
title: Text(l10n.downloads),
),
body: signedIn
? _DownloadsBody(downloads: context.watch<DownloadController>())
: const AuthGatePanel(),
);
}
}
class _DownloadsBody extends StatelessWidget {
const _DownloadsBody({required this.downloads});
final DownloadController downloads;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (downloads.loading && downloads.policy == null) {
return const Center(child: CircularProgressIndicator());
}
if (downloads.error != null && downloads.policy == null) {
return Center(
key: const Key('downloads-error'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorText(l10n, downloads.error, l10n.downloadsError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 14),
FilledButton(
key: const Key('downloads-retry'),
onPressed: () => unawaited(downloads.load()),
child: Text(l10n.retry),
),
],
),
),
);
}
final policy = downloads.policy;
if (policy == null) {
return Center(
key: const Key('downloads-error'),
child: FilledButton(
key: const Key('downloads-retry'),
onPressed: () => unawaited(downloads.load()),
child: Text(l10n.retry),
),
);
}
return RefreshIndicator(
onRefresh: downloads.load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 28),
children: [
_PolicyPanel(downloads: downloads, policy: policy),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: Text(
l10n.downloadedItems,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
if (downloads.licenses.isNotEmpty)
TextButton.icon(
key: const Key('download-clear'),
onPressed: downloads.clearing
? null
: () => _confirmClear(context),
icon: const Icon(Icons.delete_sweep_outlined),
label: Text(l10n.clearAll),
),
],
),
if (downloads.licenses.isEmpty)
Padding(
key: const Key('downloads-empty'),
padding: const EdgeInsets.symmetric(vertical: 34),
child: Text(
l10n.noDownloads,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
)
else
for (final license in downloads.licenses) ...[
_LicenseTile(
license: license,
onRemove: () => _remove(context, license.episodeId),
),
const SizedBox(height: 10),
],
],
),
);
}
Future<void> _remove(BuildContext context, String episodeId) async {
final ok = await downloads.remove(episodeId);
if (ok || !context.mounted) return;
_showMessage(context, AppLocalizations.of(context)!.downloadDeleteFailed);
}
Future<void> _confirmClear(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.clearDownloadsTitle),
content: Text(l10n.clearDownloadsMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.confirmClear),
),
],
),
);
if (confirmed != true) return;
final ok = await downloads.clear();
if (!ok && context.mounted) {
_showMessage(context, l10n.downloadClearFailed);
}
}
void _showMessage(BuildContext context, String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
class _PolicyPanel extends StatelessWidget {
const _PolicyPanel({required this.downloads, required this.policy});
final DownloadController downloads;
final DownloadPolicy policy;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: const Key('download-policy'),
padding: const EdgeInsets.fromLTRB(14, 14, 14, 10),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2A2C33)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.downloadQuota(policy.remaining, policy.maxPerDevice),
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 5),
Text(
l10n.downloadLicenseDays(policy.licenseDays),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
const SizedBox(height: 6),
Text(
policy.expirePolicyText,
style: const TextStyle(color: Color(0xFFB7B3B8), fontSize: 12),
),
const Divider(height: 22, color: Color(0xFF2A2C33)),
Row(
children: [
Expanded(
child: Text(
l10n.downloadWifiOnly,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
Switch(
key: const Key('download-wifi-switch'),
value: policy.wifiOnly,
onChanged: (value) => _update(context, wifiOnly: value),
),
],
),
Row(
children: [
Expanded(
child: Text(
l10n.downloadQuality,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
DropdownButton<String>(
key: const Key('download-quality'),
value:
_qualityChoices(
policy.maxQuality,
).contains(policy.maxQuality)
? policy.maxQuality
: 'AUTO',
underline: const SizedBox.shrink(),
items: [
for (final quality in _qualityChoices(policy.maxQuality))
DropdownMenuItem(value: quality, child: Text(quality)),
],
onChanged: (value) {
if (value == null || value == policy.maxQuality) return;
_update(context, maxQuality: value);
},
),
],
),
],
),
);
}
Future<void> _update(
BuildContext context, {
bool? wifiOnly,
String? maxQuality,
}) async {
final ok = await downloads.updateSettings(
wifiOnly: wifiOnly,
maxQuality: maxQuality,
);
if (ok || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.downloadSettingsFailed),
),
);
}
}
class _LicenseTile extends StatelessWidget {
const _LicenseTile({required this.license, required this.onRemove});
final DownloadLicense license;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: ValueKey('download-license-${license.episodeId}'),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
license.dramaTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
Text(l10n.episodeLabel(license.episodeNo)),
IconButton(
key: ValueKey('download-remove-${license.episodeId}'),
tooltip: l10n.remove,
onPressed: onRemove,
icon: const Icon(Icons.close_rounded),
),
],
),
Wrap(
spacing: 12,
runSpacing: 5,
children: [
Text(
l10n.downloadStatus(license.status),
style: const TextStyle(color: AppColors.blueSoft, fontSize: 12),
),
Text(
l10n.downloadTaskStatus(license.taskStatus),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
Text(
l10n.downloadProgress(license.progressPercent),
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
if (license.quality.isNotEmpty)
Text(
license.quality,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
minHeight: 4,
value: (license.progressPercent / 100).clamp(0.0, 1.0),
),
),
if (license.expireAt != null) ...[
const SizedBox(height: 7),
Text(
l10n.downloadExpiresAt(_formatTime(license.expireAt!)),
style: const TextStyle(color: Color(0xFF8D8990), fontSize: 11),
),
],
],
),
);
}
}
List<String> _qualityChoices(String current) {
final choices = <String>['AUTO'];
if (current.isNotEmpty && current != 'AUTO') choices.add(current);
for (final value in const ['720P', '1080P']) {
if (!choices.contains(value)) choices.add(value);
}
return choices;
}
String _formatTime(DateTime value) {
final local = value.toLocal();
final month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
return '${local.year}-$month-$day $hour:$minute';
}
@@ -1,21 +1,15 @@
// Author: fruit.com.cn
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/favorite_controller.dart';
import 'drama_detail_logic.dart';
class DramaDetailBinding {
DramaDetailLogic create({
required Drama drama,
required WatchHistoryRepository history,
required PlaybackRepository playback,
required FavoriteController favorites,
WatchHistoryRepository? history,
}) => DramaDetailLogic(
drama: drama,
history: history,
playback: playback,
favorites: favorites,
history: history ?? SharedPreferencesWatchHistoryRepository(),
);
}
+11 -83
View File
@@ -1,106 +1,37 @@
// Author: fruit.com.cn
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/remote/api_client.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/services/favorite_controller.dart';
import '../player/player_view.dart';
class DramaDetailLogic extends ChangeNotifier {
DramaDetailLogic({
required this.drama,
required this.history,
required this.playback,
required this.favorites,
});
DramaDetailLogic({required this.drama, required this.history});
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
Drama drama;
final Drama drama;
final WatchHistoryRepository history;
final PlaybackRepository playback;
final FavoriteController favorites;
bool loading = false;
bool failed = false;
/// 接口给出的失败原因;为空时界面回退到本地文案。
String? failureMessage;
int? continueIndex;
WatchProgress? progress;
String? progressError;
bool _disposed = false;
void notify() {
if (!_disposed) notifyListeners();
}
/// 列表卡片只带一集可播预览,进详情时补一次完整剧集列表。
Future<void> load(Future<Drama> Function() fetch) async {
if (drama.detailLoaded) {
await loadContinue();
return;
}
loading = true;
failed = false;
failureMessage = null;
notify();
try {
drama = await fetch();
await loadContinue();
} on ApiException catch (exception) {
failed = true;
failureMessage = exception.message;
} on DioException {
// 断网/超时:交给 l10n 文案,不把 HTTP 细节丢给用户
failed = true;
failureMessage = 'networkError';
} catch (_) {
failed = true;
} finally {
loading = false;
notify();
}
}
Future<void> loadContinue() async {
try {
final record = await history.progressFor(drama.id);
if (_disposed) return;
progress = record;
progressError = null;
continueIndex = record == null
? null
: _indexOf(record.episodeId) ?? continueIndex;
notify();
} catch (exception) {
if (_disposed) return;
// 拿不到续看位置不影响看第一集,但要让用户知道(可重试)
progressError = exception is ApiException
? exception.message
: 'progressError';
notify();
final id = await history.lastEpisodeId(demoProfileId, drama.id);
if (id == null || _disposed) return;
final index = drama.episodes.indexWhere((episode) => episode.id == id);
if (index >= 0 && drama.episodes[index].isFree) {
continueIndex = index;
notify();
}
} catch (_) {
// Keep the first episode available if preferences cannot be read.
}
}
int? _indexOf(String episodeId) {
final index = drama.episodes.indexWhere(
(episode) => episode.id == episodeId,
);
return index >= 0 ? index : null;
}
/// 该集的续看进度(0~1),用于剧集行上的进度条;没有记录返回 null。
double? progressFractionFor(String episodeId) {
final record = progress;
if (record == null || record.episodeId != episodeId) return null;
if (!record.hasPosition) return null;
return record.fraction;
}
Future<void> openPlayer(BuildContext context, int index) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
@@ -110,9 +41,6 @@ class DramaDetailLogic extends ChangeNotifier {
await loadContinue();
}
/// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。
Future<bool> toggleFavorite() => favorites.toggle(drama.id, card: drama);
@override
void dispose() {
_disposed = true;
+15 -275
View File
@@ -1,27 +1,12 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import '../interaction/widgets/report_dialog.dart';
import 'drama_detail_binding.dart';
import 'drama_detail_logic.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
const String _reportMenuValue = 'report';
class DramaDetailPage extends StatefulWidget {
const DramaDetailPage({super.key, required this.drama});
@@ -37,21 +22,8 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
@override
void initState() {
super.initState();
logic = DramaDetailBinding().create(
drama: widget.drama,
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
favorites: context.read<FavoriteController>(),
);
_load();
}
void _load() {
unawaited(
logic.load(
() => context.read<CatalogController>().requireDetail(widget.drama),
),
);
logic = DramaDetailBinding().create(drama: widget.drama);
logic.loadContinue();
}
@override
@@ -70,69 +42,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (logic.loading) {
return const Scaffold(
backgroundColor: Color(0xFF111111),
body: Center(child: CircularProgressIndicator()),
);
}
if (logic.failed) {
return Scaffold(
backgroundColor: const Color(0xFF111111),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorText(l10n, logic.failureMessage, l10n.detailError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: Text(l10n.retry)),
],
),
),
),
);
}
final drama = logic.drama;
if (drama.episodes.isEmpty) {
// 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏
return Scaffold(
backgroundColor: const Color(0xFF111111),
appBar: AppBar(
backgroundColor: const Color(0xFF111111),
foregroundColor: Colors.white,
title: Text(localizedDramaTitle(l10n, drama)),
),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.movie_filter_outlined,
size: 48,
color: Colors.white38,
),
const SizedBox(height: 12),
Text(
l10n.noEpisodesYet,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: Text(l10n.retry)),
],
),
),
),
);
}
return Scaffold(
body: CustomScrollView(
slivers: [
@@ -141,13 +51,8 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
pinned: true,
backgroundColor: const Color(0xFF111111),
foregroundColor: Colors.white,
actions: [
_reportAction(context, l10n),
_downloadAction(context, l10n),
_favoriteAction(context, l10n),
],
flexibleSpace: FlexibleSpaceBar(
background: RemoteImage(drama.posterUrl),
background: Image.asset(drama.posterAsset, fit: BoxFit.cover),
),
),
SliverPadding(
@@ -158,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,
),
),
@@ -177,54 +82,15 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
),
),
const SizedBox(height: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: () =>
logic.openPlayer(context, logic.continueIndex ?? 0),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
logic.continueIndex == null
? l10n.watchEpisodeOne
: l10n.continueEpisode(logic.continueIndex! + 1),
),
),
if (_continueFraction != null) ...[
const SizedBox(height: 8),
_ResumeBar(
fraction: _continueFraction!,
label: l10n.resumeProgress(
_clock(logic.progress!.position),
_clock(logic.progress!.duration),
),
),
],
if (logic.progressError != null) ...[
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
errorText(
l10n,
logic.progressError,
l10n.progressError,
),
style: const TextStyle(
color: Colors.white60,
fontSize: 12,
),
),
),
TextButton(
onPressed: () => unawaited(logic.loadContinue()),
child: Text(l10n.retry),
),
],
),
],
],
FilledButton.icon(
onPressed: () =>
logic.openPlayer(context, logic.continueIndex ?? 0),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
logic.continueIndex == null
? l10n.watchEpisodeOne
: l10n.continueEpisode(logic.continueIndex! + 1),
),
),
const SizedBox(height: 30),
Text(
@@ -242,24 +108,18 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
itemCount: drama.episodes.length,
itemBuilder: (context, index) {
final episode = drama.episodes[index];
final fraction = logic.progressFractionFor(episode.id);
return Card(
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
minTileHeight: 76,
leading: CircleAvatar(
backgroundColor: const Color(
0xFF2F7BFF,
0xFFFF6B45,
).withValues(alpha: 0.18),
child: Text('${episode.number}'),
),
title: Text(l10n.episodeLabel(episode.number)),
subtitle: fraction == null
? null
: Padding(
padding: const EdgeInsets.only(top: 6),
child: _ResumeBar(fraction: fraction),
),
subtitle: Text(l10n.freeTestMedia),
trailing: const Icon(Icons.play_circle_outline_rounded),
onTap: () => logic.openPlayer(context, index),
),
@@ -271,124 +131,4 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
),
);
}
double? get _continueFraction {
final record = logic.progress;
if (record == null || !record.hasPosition) return null;
if (logic.continueIndex == null) return null;
return record.fraction;
}
/// 下载当前续看集或第一集;业务判定完全交给服务端。
Widget _downloadAction(BuildContext context, AppLocalizations l10n) {
final episodes = logic.drama.episodes;
final index = logic.continueIndex ?? 0;
final episode = episodes.isEmpty
? null
: episodes[index.clamp(0, episodes.length - 1)];
return IconButton(
key: const Key('detail-download'),
tooltip: l10n.downloads,
icon: const Icon(Icons.download_outlined),
onPressed: episode == null
? null
: () => requestEpisodeDownload(context, episode.id),
);
}
/// 详情页更多菜单:本批只提供举报短剧。
Widget _reportAction(BuildContext context, AppLocalizations l10n) {
return PopupMenuButton<String>(
key: const Key('detail-more'),
tooltip: l10n.more,
onSelected: (_) => unawaited(_reportDrama(l10n)),
itemBuilder: (_) => [
PopupMenuItem(
value: _reportMenuValue,
child: Text(l10n.reportDrama),
),
],
);
}
Future<void> _reportDrama(AppLocalizations l10n) async {
if (!await ensureSignedIn(context) || !mounted) return;
final draft = await showReportDialog(context, title: l10n.reportDrama);
if (draft == null || !mounted) return;
final result = await context.read<InteractionController>().reportDrama(
logic.drama.id,
reason: draft.reason,
description: draft.description,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.isSuccess
? l10n.reportSubmitted
: result.errorMessage ?? l10n.reportFailed,
),
),
);
}
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
final favorites = context.watch<FavoriteController>();
final saved = favorites.isSaved(logic.drama.id);
return IconButton(
tooltip: l10n.favorite,
icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border),
onPressed: () async {
if (!await ensureSignedIn(context)) return;
final ok = await logic.toggleFavorite();
if (ok || !context.mounted) return;
// 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚)
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
},
);
}
String _clock(Duration duration) {
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
return duration.inHours > 0
? '${duration.inHours}:$minutes:$seconds'
: '$minutes:$seconds';
}
}
/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。
class _ResumeBar extends StatelessWidget {
const _ResumeBar({required this.fraction, this.label});
final double fraction;
final String? label;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: fraction,
minHeight: 3,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation(Color(0xFF2F7BFF)),
),
),
if (label != null) ...[
const SizedBox(height: 4),
Text(
label!,
style: const TextStyle(color: Colors.white54, fontSize: 11),
),
],
],
);
}
}
@@ -1,9 +0,0 @@
// Author: fruit.com.cn
import '../../core/data/services/favorite_controller.dart';
/// 收藏页依赖装配。
class FavoritesBinding {
/// 从服务端刷新收藏列表。
Future<void> load(FavoriteController favorites) => favorites.load();
}
-148
View File
@@ -1,148 +0,0 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/library_drama_tile.dart';
import '../../l10n/app_localizations.dart';
import '../drama_detail/drama_detail_view.dart';
import 'favorites_binding.dart';
/// 独立我的收藏页。
class FavoritesPage extends StatefulWidget {
const FavoritesPage({super.key});
@override
State<FavoritesPage> createState() => _FavoritesPageState();
}
class _FavoritesPageState extends State<FavoritesPage> {
bool _requested = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final signedIn = context.watch<AuthController>().isSignedIn;
if (!signedIn) {
_requested = false;
return;
}
if (_requested) return;
_requested = true;
final favorites = context.read<FavoriteController>();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(FavoritesBinding().load(favorites));
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Scaffold(
backgroundColor: const Color(0xFF0B0C10),
appBar: AppBar(
backgroundColor: const Color(0xFF0B0C10),
foregroundColor: Colors.white,
title: Text(l10n.myFavorites),
),
body: signedIn
? _FavoritesBody(favorites: context.watch<FavoriteController>())
: const AuthGatePanel(),
);
}
}
class _FavoritesBody extends StatelessWidget {
const _FavoritesBody({required this.favorites});
final FavoriteController favorites;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (favorites.loading && favorites.cards.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (favorites.error != null && favorites.cards.isEmpty) {
return Center(
key: const Key('favorites-error'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorText(l10n, favorites.error, l10n.favoritesError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 14),
FilledButton(
key: const Key('favorites-retry'),
onPressed: () => unawaited(favorites.load()),
child: Text(l10n.retry),
),
],
),
),
);
}
if (favorites.cards.isEmpty) {
return Center(
key: const Key('favorites-empty'),
child: Text(
l10n.noFavorites,
style: const TextStyle(color: Colors.white70),
),
);
}
return RefreshIndicator(
onRefresh: favorites.load,
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
itemCount: favorites.cards.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final drama = favorites.cards[index];
return LibraryDramaTile(
key: ValueKey('favorite-card-${drama.id}'),
drama: drama,
onTap: () => _open(context, drama),
trailing: IconButton(
key: ValueKey('favorite-remove-${drama.id}'),
tooltip: l10n.remove,
onPressed: () => _remove(context, drama.id),
icon: const Icon(
Icons.favorite_rounded,
color: Color(0xFFD74A7D),
),
),
);
},
),
);
}
Future<void> _open(BuildContext context, Drama drama) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
);
}
Future<void> _remove(BuildContext context, String dramaId) async {
final ok = await favorites.remove(dramaId);
if (ok || !context.mounted) return;
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
}
}
+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]];
}
+9 -38
View File
@@ -1,21 +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;
@@ -24,7 +21,6 @@ class HomeLogic extends ChangeNotifier {
bool drawerOpen = false;
bool detailOpen = false;
bool videoImmersive = false;
bool profile = false;
bool _disposed = false;
void notify() {
@@ -68,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) {
@@ -84,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:
@@ -119,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:
@@ -144,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();
}
}
+27 -70
View File
@@ -1,15 +1,10 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_view.dart';
import '../mine/mine_view.dart';
@@ -36,6 +31,7 @@ class HomePage extends StatelessWidget {
CatalogController catalog,
AppLocalizations l10n,
) {
if (catalog.loading) {
return const Scaffold(
backgroundColor: Colors.black,
@@ -52,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),
),
],
],
),
),
@@ -107,8 +96,6 @@ 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, _) {
@@ -122,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,
@@ -142,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,
@@ -150,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(
@@ -167,14 +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,
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,
);
}
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more