Author SHA1 Message Date
jianghaifeng 00280ca0da merge: 同步 main 的作者标识提交到 develop
main 的 eaf8fb4 机械性地给全仓加了 // Author 头,develop 侧包名已改 fruitshorts、
并删除了 demo_media/home_feed_category/home_feed_sections 三个文件,故按 develop 口径解决冲突:
保留 fruitshorts 包名与删除结果,补回 Author 头。
2026-10-17 13:48:31 +08:00
Vicky 84a1f58a0a test(client): 新增设备侧集成测试(真机跑通后端可达性)
红线第 10 条(设备验证必须设备侧取证)配套:integration_test/app_backend_test.dart
在手机进程里真实请求 /app/home/feed 与 /app/dramas/{id},并用真 App 断言首页剧目列表能渲染。
把 API_BASE_URL 打进设备日志,便于区分地址不对与设备连不上。

注意:真机执行由用户负责(用户 20:45 指令),本文件按原样交付、不再改动。
本项目 iOS 用 SPM,无 Podfile,不需要 pod install。
2026-10-16 18:05:58 +08:00
Vicky d7eff4ca59 feat(client): 对接详情/播放/进度续看/收藏(K1 看剧闭环)+ 三态 UI
后端接口从 6 个扩到 13 个,全部走真数据:

- 新增 api:playback_api(/app/episodes/{id}/play)、watch_api(/app/watch/*)、
  library_api(/app/favorites*);票据从 CatalogApi 搬到 PlaybackApi
- 新增 repository:PlaybackRepository(含票据 TTL 缓存,默认 10 分钟,时钟可注入)、
  FavoriteRepository、WatchHistoryRepository 换成服务端模型(每剧一条续看记录,删掉 demoProfileId)
- PlayerLogic:起播前取票(拿不到给三态 UI,失败可重试);accessType 驱动锁定文案,
  不在前端猜;签名地址原样交给播放器(只补 host,不动 path/query);
  进度上报节流 5s → 10s,切集/退出/切后台各补一次,播完带 finished
- 续看定位:服务端记录里的那一集才 seek(其它集从头播),续看位置进详情/播放页时读一次
- FavoriteController:收藏以服务端为准,乐观更新 + 失败回滚;退出登录清空
- UI 三态:详情页(加载/失败可重试/空剧集 + 续看进度条 + 收藏按钮)、
  播放页(票据加载中/失败可重试/后端判定不可播)、我的页(续看失败可重试、收藏三态、最近观看)
- l10n 新增 8 个键(en/zh/id 三份):网络/进度/收藏失败、锁定提示、续看文案等

测试:flutter test 188 全过(存量 27 个测试文件未跑红);新增票据过期重取、进度节流、
续看定位、收藏回滚、签名 query 原样保留、真实后端端到端联调(live_watch_flow)
2026-10-16 17:39:05 +08:00
Vicky 94ff9f1177 fix(client): 兼容后端 Long 字段字符串化(id 与计数)
后端 J1 起 Long/long 一律以字符串下发(含雪花 id 与 viewCount/expiresIn)。
客户端 id 方向本来就是 (json['id'] ?? '').toString(),不受影响;会被 as num? 打挂的
是 Long 计数,这里加统一兼容解析并替换 3 处:

- lib/core/data/models/json_numbers.dart(新增,接受数字或数字字符串)
- lib/core/data/models/auth_session.dart(expiresIn,后端 long)
- lib/core/data/models/drama.dart(viewCount,后端 Long ×2)
- test/core/json_id_parsing_test.dart(新增 8 条回归用例)

其余 as num?(durationSec/unlockCoin/number/totalEpisodes/freeEpisodes/code)对应后端
Integer/int,仍是数字,未改动;未碰任何业务逻辑与 UI。
2026-10-16 16:44:02 +08:00
Vicky 61454e36b1 feat(client): 改名为 FruitShorts 并新增 id-ID 印尼语文案(1-3 + 1-4 合并)
为什么:a) 应用对外名称要统一成 FruitShorts(产品名,nontonki 是原型代号);
b) 首发市场是印尼,界面必须有印尼语;c) 归档要求"新增 id-ID 与改名合并生成一次 l10n,别分两次"。

改名:
- pubspec name: nontonki → fruitshorts(Dart 包名),description 同步;28 个文件的 package: 导入前缀替换
- Android android:label、iOS CFBundleDisplayName/CFBundleName → FruitShorts
- 图标资源 assets/icon/nontonki.png → fruitshorts.png(含 pubspec 两处引用)
- README 同步(补印尼语说明,顺带修掉写错的模块名 reelshort-app-api → fruitshorts-app-api)

文案:
- 三份 arb 的 appName → FruitShorts、brandMark → FRUITSHORTS、shareFooter → FruitShorts(不留 demo 标记)
- 新增 lib/l10n/app_id.arb:174 个值键与 en/zh 完全一致,按印尼语习惯翻译(Coin→Koin、Episode→Episode)
- 一次性 flutter gen-l10n 生成 app_localizations_id.dart;id 自动进入 supportedLocales,默认 locale 未改
- 新增 test/config/l10n_id_test.dart:钉住 id 文案、supportedLocales 含 id、品牌文案无 demo 标记、占位符渲染

不动:applicationId / bundle id(lumadrama 遗留,等裁定)、目录名、业务逻辑与页面结构、语言选择页(仍只有 en/zh)

门禁:dart analyze lib test 零告警;flutter test 159 全过(基线 155 + 新增 4);残留扫描 0 命中;
三份 arb 键集一致(174/174/174);真机(iPhone iOS 26.6.1)构建安装启动成功,devicectl 列出显示名 FruitShorts
2026-10-16 10:16:59 +08:00
Vicky 0e5d550b50 test(client): 用例名统一为 should_<期望>_when_<条件>(155/155)
为什么:红线第 8 条门禁立即生效,要求 Flutter 测试命名合规;此前 155 条用例名是英文自然语言
或中文描述,合规数 0/155,属存量欠账,按"先补齐再上新功能"在本批处理。

- 25 个测试文件、155 条用例改名,按「文件 + 旧名」精确定位,每条断言命中恰好 1 次才替换
- 只改名字字面量:改动行 155 删 + 155 增,无一行不含字符串字面量(断言与用例体零改动)
- 门禁:dart analyze lib test = No issues found;flutter test = 155 全过
2026-10-16 09:41:14 +08:00
Vicky 1853e1845c refactor(client): 网络层由官方 http 换成 dio(对外行为不变)
为什么:权威文档(AGENTS.md / CODING_STANDARDS.md)强制网络层用 dio,差异清单 A 组第 1 题
用户裁定选 A;原来用官方 http 是在无文档状态下按口头要求选的。

- ApiClient 改用 dio + BaseOptions(validateStatus 恒真、8s 超时、ResponseType.bytes 自行 UTF-8 解码)
- 新增 AuthHeaderInterceptor(Authorization / X-Device-Id)与 EnvelopeInterceptor
  (解包 {code,message,data} + 错误码映射 + 401 判定)
- 401 → 刷新 → 重发一次在 ApiClient 里编排,刻意不放在拦截器内重发:dio 的排队拦截器
  在回调里再发请求会自锁
- 接口层(auth_api/catalog_api)签名、全部 controller/logic/page 未改动
- 测试:新增假 dio adapter 替代 MockClient,断言口径一条未改;用例名改为 should_<期望>_when_<条件>;
  新增 api_client_test(解包/错误映射/请求头/查询参数/跳过重试)与 live_auth_test(真实后端 POST 链路)
- pubspec:加 dio、移除已无引用的 http
- 实测:dart analyze lib test 零告警;flutter test 155 全过;真实联调(8081)GET/POST 链路均通
2026-10-16 09:30:46 +08:00
Vicky ae49b10407 chore(client): 归档发现页 widgets(原型阶段新增目录) 2026-10-15 17:53:54 +08:00
Vicky feecc349a0 chore(client): 归档原型阶段新增文件(配置、主题、模型与测试)
为什么:上一批归档只覆盖了「已跟踪文件的修改」,原型阶段新增的文件(app_config、theme、
home_feed/play_ticket 模型、catalog_api、测试目录等)还是未入库状态 —— 而已入库的代码会 import 它们,
留在工作区会导致 HEAD 处的代码编译不过。一并归档,让 HEAD 自洽。

纯归档,无行为改动。
2026-10-15 17:53:45 +08:00
Vicky e1b98df509 feat(client): C 端 1-2 账号链路(邮箱登录/注册 + 令牌与 401 + 匿名/登录分叉)
为什么:客户端此前完全没有账号概念(mine 页的"登录"只是本地 demo 开关),
而受限动作(收藏/评论)要对到具体账号。本批把账号链路接上,为后续互动、进度同步、下载打底。

- AuthApi + AuthController + AuthSessionStore:注册/登录/登出/刷新,令牌与设备号存 shared_preferences
  (用 SharedPreferencesAsync,与 engagement 仓库同一套 API)
- ApiClient:统一带 Authorization 与 X-Device-Id;401 自动刷新一次并重试,刷新失败清登录态走 onUnauthorized
- 登录/注册页(pages/auth,沿用 binding/logic/view 与现有视觉):邮箱格式、密码 8-64 位含字母数字本地先校验,
  错误提示走 l10n;登录/注册一键切换
- 匿名/登录分叉:首页、发现、详情保持匿名可看;收藏/点赞等受限动作走 ensureSignedIn() 弹底部提示引导登录
- mine 页展示真实账号(昵称 + uid),拿不到才回落到演示文案
- 测试:新增 auth 控制器(注册落盘/401 刷新重试/刷新失败清会话/登出)与登录页(校验/切换)用例;
  widget_test 改为预置本地会话(账号不再是本地开关);修掉 playback 用例对 host 大小写的环境依赖断言

门禁:flutter analyze 0 问题;flutter test 148 全过。
2026-10-15 17:53:35 +08:00
Vicky 77d5748dbb chore(client): 归档原型阶段未提交改动(binding/logic/view 分层与各页迭代)
为什么:客户端原型在 14d7bd3 之后又做了一轮(页面分层、发现页、分类滑动、播放器控件等),
一直留在工作区没入库。下一批账号链路要改其中的 app.dart / home_view / player_view / mine_view,
先归档这批既存改动,本批的实际 diff 才说得清边界。

只归档,不改行为:当前工作区 flutter analyze 0 问题、flutter test 148 全过。
2026-10-15 17:53:27 +08:00
122 changed files with 10207 additions and 3729 deletions

No files matched your search

+35 -2
View File
@@ -5,13 +5,46 @@
- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。
- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。
- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。
- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。
- **互动与多语言**:本地演示评论、系统分享,以及英文、简体中文和印尼语界面。
- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。
## 技术实现
项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。
## 对接后端
目录数据来自短剧服务的客户端接口(`FruitServer` 的 `fruitshorts-app-api`,默认 `http://127.0.0.1:8081`):
- `GET /app/home/feed`、`GET /app/discover/feed`:轮播 + 分区,一次请求渲染整页。
每张卡片带 `preview`(首个可播免费集),首页竖屏播放可以直接起播;
没有片源的剧不下发 `preview`,也不会出现在首页的滑动队列里
- `GET /app/dramas/{id}`:详情(含剧集列表;可播放的剧集直接带播放地址)
- `GET /app/episodes/{id}/play`:播放票据,返回可访问性与地址
启动后端后直接 `flutter run` 或 Xcode 里 Run 即可,接口地址按运行环境自动选择
(见 `lib/core/config/app_config.dart`):
| 运行环境 | 默认地址 |
| --- | --- |
| iOS 模拟器 / 桌面端 | `http://127.0.0.1:8081` |
| iPhone 真机 | [`devHostBaseUrl`],即电脑当前的局域网地址(`ipconfig getifaddr en0`) |
| Android 模拟器 | 需手动指定 `--dart-define=API_BASE_URL=http://10.0.2.2:8081` |
任何环境都可以用 `--dart-define=API_BASE_URL=http://x.x.x.x:8081` 覆盖默认值。
电脑换网络后局域网地址会变,改 `devHostBaseUrl` 即可;也可以换成 Bonjour 名字
(`http://$(scutil --get LocalHostName).local:8081`),换网络也不用改。
真机调试还需注意:手机和电脑在同一个 Wi-Fi;首次启动时 iPhone 会弹「本地网络」权限,
必须允许;iOS 的明文 HTTP 与局域网访问已在 `ios/Runner/Info.plist` 里放行
(`NSAllowsLocalNetworking`、`NSLocalNetworkUsageDescription`),改动这些配置后
要重新构建安装,热重载不生效;如果仍连不上,到「系统设置 → 网络 → 防火墙 → 选项」
把 `java` 设为允许传入连接。
封面与视频都走 `http://<host>:8081/files/**`,本地不再打包演示素材
(原先 `assets/media` 里的演示视频有 316MB,已从打包清单移除)。
## 当前说明
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
收藏、点赞、评论与观看进度仍保存在本机(后端互动接口尚未开发);
金币与会员相关界面仍是占位数据。
-3
View File
@@ -32,9 +32,6 @@ android {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// 反编译保护(法律红线配套):开启 R8 混淆 + 资源压缩,发布前替换正式签名。
isMinifyEnabled = true
isShrinkResources = true
}
}
}
@@ -6,4 +6,6 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
<application android:usesCleartextTraffic="true"/>
</manifest>
+2 -1
View File
@@ -1,8 +1,9 @@
<!-- Author: fruit.com.cn -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="Nontonki"
android:label="FruitShorts"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -6,4 +6,6 @@
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
@@ -0,0 +1,74 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
import 'package:integration_test/integration_test.dart';
/// 设备侧集成测试:**在手机进程里**真实请求开发机上的 app-api。
///
/// 跑法(红线第 10 条要求的"设备侧取证"):
/// ```
/// IP=$(ipconfig getifaddr en0)
/// flutter test integration_test -d <device-id> --dart-define=API_BASE_URL=http://$IP:8081
/// ```
/// 断言失败时会把实际使用的地址一起打出来,便于区分"地址不对"还是"设备连不上"。
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final baseUrl = AppConfig.apiBaseUrl;
testWidgets('should_fetch_home_feed_and_drama_detail_when_device_reaches_backend', (
tester,
) async {
debugPrint('[device] API_BASE_URL=$baseUrl');
final client = ApiClient(baseUrl: baseUrl);
addTearDown(client.close);
final api = CatalogApi(client);
final feed = await api.homeFeed();
expect(
feed.dramas,
isNotEmpty,
reason: '设备侧拿不到首页列表(API_BASE_URL=$baseUrl)',
);
debugPrint('[device] 首页分区=${feed.sections.length} 剧目=${feed.dramas.length}');
final card = feed.dramas.first;
expect(card.id, isNotEmpty, reason: '设备侧首页卡片 id 为空');
final detail = await api.dramaDetail(card.id);
expect(detail.id, card.id, reason: '详情返回的 id 与卡片不一致');
expect(
detail.episodes,
isNotEmpty,
reason: '设备侧拿到的详情没有剧集(dramaId=${card.id})',
);
debugPrint('[device] 详情 id=${detail.id} 剧集=${detail.episodes.length}');
});
testWidgets('should_render_home_drama_list_when_app_boots_on_device', (
tester,
) async {
// 用真 App(真网络、真目录控制器):首页底部标签栏出现即代表"剧目列表加载成功",
// 也就是用户在真机上遇到的那个「剧目列表加载失败」已经消失。
await tester.pumpWidget(const LumaDramaApp());
var loaded = false;
for (var attempt = 0; attempt < 60 && !loaded; attempt++) {
await tester.pump(const Duration(milliseconds: 500));
loaded = find.byKey(const Key('home-tabs-bar')).evaluate().isNotEmpty;
}
expect(
loaded,
isTrue,
reason: '首页 30 秒内没渲染出剧目列表(API_BASE_URL=$baseUrl)',
);
debugPrint('[device] 首页剧目列表已渲染:API_BASE_URL=$baseUrl');
});
}
-1
View File
@@ -1,2 +1 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
-1
View File
@@ -1,2 +1 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
-43
View File
@@ -1,43 +0,0 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+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 = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -487,7 +487,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -538,7 +538,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
+9 -2
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nontonki</string>
<string>FruitShorts</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Nontonki</string>
<string>FruitShorts</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -26,6 +26,13 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
+111 -35
View File
@@ -1,63 +1,139 @@
// 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/repositories/auth_repository.dart';
import 'core/data/remote/api_client.dart';
import 'core/data/remote/auth_api.dart';
import 'core/data/remote/catalog_api.dart';
import 'core/data/remote/library_api.dart';
import 'core/data/remote/playback_api.dart';
import 'core/data/remote/watch_api.dart';
import 'core/data/repositories/catalog_repository.dart';
import 'core/data/repositories/engagement_repository.dart';
import 'core/data/repositories/http_catalog_repository.dart';
import 'core/data/services/api_client.dart';
import 'core/data/services/app_catalog_controller.dart';
import 'core/data/repositories/favorite_repository.dart';
import 'core/data/repositories/playback_repository.dart';
import 'core/data/repositories/watch_history_repository.dart';
import 'core/data/services/auth_controller.dart';
import 'core/data/services/auth_session_store.dart';
import 'core/data/services/catalog_controller.dart';
import 'core/data/services/engagement_controller.dart';
import 'core/data/services/event_tracker.dart';
import 'core/data/services/favorite_controller.dart';
import 'core/data/services/playback_session.dart';
import 'core/data/services/token_storage.dart';
import 'core/data/services/video_player_port.dart';
import 'l10n/app_localizations.dart';
import 'pages/login/login_view.dart';
import 'pages/home/home_view.dart';
class LumaDramaApp extends StatelessWidget {
const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new});
class LumaDramaApp extends StatefulWidget {
const LumaDramaApp({
super.key,
this.playerFactory = VideoPlayerPort.new,
this.catalogRepository,
this.watchHistoryRepository,
this.favoriteRepository,
this.playbackRepository,
});
final PlayerFactory playerFactory;
/// 测试注入用;生产走 HTTP 仓库。
final CatalogRepository? catalogRepository;
final WatchHistoryRepository? watchHistoryRepository;
final FavoriteRepository? favoriteRepository;
final PlaybackRepository? playbackRepository;
@override
State<LumaDramaApp> createState() => _LumaDramaAppState();
}
class _LumaDramaAppState extends State<LumaDramaApp> {
late final AuthSessionStore _sessionStore = AuthSessionStore();
/// 全 App 共用一个 ApiClient:这样目录、账号、后续的互动/进度请求
/// 都自动带 `Authorization` 与 `X-Device-Id`,401 的刷新重试也只有一处实现。
late final ApiClient _apiClient = ApiClient(
accessTokenReader: () => _auth.accessToken,
tokenRefresher: () => _auth.refresh(),
onUnauthorized: () => _auth.clear(),
deviceIdReader: _sessionStore.deviceId,
);
late final AuthController _auth = AuthController(AuthApi(_apiClient), _sessionStore);
late final WatchHistoryRepository _watchHistory =
widget.watchHistoryRepository ??
HttpWatchHistoryRepository(
WatchApi(_apiClient),
isSignedIn: () => _auth.isSignedIn,
);
late final FavoriteRepository _favorites =
widget.favoriteRepository ??
HttpFavoriteRepository(
LibraryApi(_apiClient),
isSignedIn: () => _auth.isSignedIn,
);
late final PlaybackRepository _playback =
widget.playbackRepository ?? HttpPlaybackRepository(PlaybackApi(_apiClient));
late final FavoriteController _favoriteController = FavoriteController(_favorites);
@override
void initState() {
super.initState();
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
_auth.load();
// 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联
_auth.addListener(_onAuthChanged);
if (_auth.isSignedIn) unawaited(_favoriteController.load());
}
bool _wasSignedIn = false;
void _onAuthChanged() {
if (_auth.isSignedIn == _wasSignedIn) return;
_wasSignedIn = _auth.isSignedIn;
if (_auth.isSignedIn) {
unawaited(_favoriteController.load());
} else {
_favoriteController.clear();
}
}
@override
void dispose() {
_auth.removeListener(_onAuthChanged);
_favoriteController.dispose();
_apiClient.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider(create: (_) => PlaybackCoordinator()),
Provider<PlayerFactory>(create: (_) => playerFactory),
Provider(create: (_) => ApiClient()),
Provider(create: (ctx) => EventTracker(ctx.read<ApiClient>())),
Provider(create: (_) => TokenStorage()),
Provider(
create: (ctx) => AuthRepository(
ctx.read<ApiClient>(),
ctx.read<TokenStorage>(),
),
),
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
Provider<PlaybackRepository>.value(value: _playback),
Provider<WatchHistoryRepository>.value(value: _watchHistory),
Provider<FavoriteRepository>.value(value: _favorites),
ChangeNotifierProvider(
create: (ctx) => AuthController(ctx.read<AuthRepository>()),
),
Provider(
create: (ctx) => HttpCatalogRepository(ctx.read<ApiClient>()),
),
ChangeNotifierProvider(
create: (ctx) => AppCatalogController(ctx.read<HttpCatalogRepository>()),
),
ChangeNotifierProvider(
create: (_) => CatalogController(DemoCatalogRepository())..load(),
create: (_) => CatalogController(
widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)),
)..load(),
),
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
ChangeNotifierProvider.value(value: _favoriteController),
ChangeNotifierProvider(
create: (_) =>
EngagementController(SharedPreferencesEngagementRepository())
..load(),
),
ChangeNotifierProvider.value(value: _auth),
],
child: Consumer<LocaleController>(
builder: (context, locale, _) => MaterialApp(
@@ -74,23 +150,23 @@ class LumaDramaApp extends StatelessWidget {
return supported.first;
},
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark,
themeMode: ThemeMode.system,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFFF5B3D),
seedColor: const Color(0xFF2F7BFF),
),
scaffoldBackgroundColor: const Color(0xFF0B0C10),
scaffoldBackgroundColor: const Color(0xFFF8F7FC),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFFF5B3D),
seedColor: const Color(0xFF2F7BFF),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF0B0C10),
scaffoldBackgroundColor: const Color(0xFF101019),
),
home: const LoginPage(),
home: const HomePage(),
),
),
);
-15
View File
@@ -1,15 +0,0 @@
// Author: fruit.com.cn
/// 接口地址与超时配置。
class ApiConfig {
ApiConfig._();
/// 客户端 app-api 地址,可用 --dart-define=API_BASE_URL=... 覆盖。
static const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://127.0.0.1:8081',
);
static const int connectTimeoutSeconds = 10;
static const int receiveTimeoutSeconds = 15;
}
+50
View File
@@ -0,0 +1,50 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
/// 客户端与后端约定的运行时配置。
///
/// 接口地址的优先级:`--dart-define=API_BASE_URL=...` > 按运行环境判断的默认值。
/// 开发机的局域网地址会随网络变化,换网络后改 [devHostBaseUrl] 即可。
class AppConfig {
const AppConfig._();
/// 开发机的局域网地址。真机上的 `127.0.0.1` 指向手机自己,只能走这个地址。
///
/// 这个地址跟着电脑所在的网络走,换网络后要用
/// `ipconfig getifaddr en0` 重新确认并改这里;
/// 也可以换成 Bonjour 名字 `http://<LocalHostName>.local:8081`(本机是
/// `jiangdeMacBook-Air.local`),换网络不用改,但部分办公网会拦组播解析。
static const String devHostBaseUrl =
'http://jiangdeMacBook-Air.local:8081';
static const String _loopbackBaseUrl = 'http://127.0.0.1:8081';
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
static String get apiBaseUrl =>
_overrideBaseUrl.isNotEmpty ? _overrideBaseUrl : _defaultBaseUrl;
static final String _defaultBaseUrl =
_runsOnDevMachine ? _loopbackBaseUrl : devHostBaseUrl;
/// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。
/// Android 没有等价的探测方式,统一按真机处理(模拟器用
/// `--dart-define=API_BASE_URL=http://10.0.2.2:8081` 覆盖)。
static final bool _runsOnDevMachine =
kIsWeb ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
(defaultTargetPlatform == TargetPlatform.iOS &&
Platform.environment.containsKey('SIMULATOR_DEVICE_NAME'));
static const Duration requestTimeout = Duration(seconds: 8);
/// 后端返回的是相对路径(/files/...),这里补上服务地址。
static String mediaUrl(String path) {
if (path.isEmpty) return '';
if (path.startsWith('http://') || path.startsWith('https://')) return path;
return '$apiBaseUrl$path';
}
}
-188
View File
@@ -1,188 +0,0 @@
// Author: fruit.com.cn
// 与后端 /app/** 内容接口对齐的数据模型。
class AppEpisode {
AppEpisode({
required this.id,
required this.number,
required this.title,
required this.durationSec,
required this.locked,
required this.unlockCoin,
this.playUrl,
});
final int id;
final int number;
final String title;
final int durationSec;
final bool locked;
final int unlockCoin;
final String? playUrl;
factory AppEpisode.fromJson(Map<String, dynamic> json) => AppEpisode(
id: json['id'] as int,
number: json['number'] as int,
title: json['title'] as String? ?? '',
durationSec: json['durationSec'] as int? ?? 0,
locked: json['locked'] as bool? ?? false,
unlockCoin: json['unlockCoin'] as int? ?? 0,
playUrl: json['playUrl'] as String?,
);
}
class AppDramaCard {
AppDramaCard({
required this.id,
required this.title,
required this.cover,
required this.synopsis,
required this.genre,
required this.category,
required this.totalEpisodes,
required this.isHot,
required this.viewCount,
this.preview,
});
final int id;
final String title;
final String cover;
final String synopsis;
final String genre;
final String category;
final int totalEpisodes;
final bool isHot;
final int viewCount;
final AppEpisode? preview;
factory AppDramaCard.fromJson(Map<String, dynamic> json) => AppDramaCard(
id: json['id'] as int,
title: json['title'] as String? ?? '',
cover: json['cover'] as String? ?? '',
synopsis: json['synopsis'] as String? ?? '',
genre: json['genre'] as String? ?? '',
category: json['category'] as String? ?? '',
totalEpisodes: json['totalEpisodes'] as int? ?? 0,
isHot: json['isHot'] as bool? ?? false,
viewCount: json['viewCount'] as int? ?? 0,
preview: json['preview'] == null
? null
: AppEpisode.fromJson(json['preview'] as Map<String, dynamic>),
);
}
class AppDramaDetail {
AppDramaDetail({
required this.id,
required this.title,
required this.synopsis,
required this.cover,
required this.genre,
required this.category,
required this.totalEpisodes,
required this.freeEpisodes,
required this.unlockCoin,
required this.isHot,
required this.viewCount,
required this.episodes,
});
final int id;
final String title;
final String synopsis;
final String cover;
final String genre;
final String category;
final int totalEpisodes;
final int freeEpisodes;
final int unlockCoin;
final bool isHot;
final int viewCount;
final List<AppEpisode> episodes;
factory AppDramaDetail.fromJson(Map<String, dynamic> json) => AppDramaDetail(
id: json['id'] as int,
title: json['title'] as String? ?? '',
synopsis: json['synopsis'] as String? ?? '',
cover: json['cover'] as String? ?? '',
genre: json['genre'] as String? ?? '',
category: json['category'] as String? ?? '',
totalEpisodes: json['totalEpisodes'] as int? ?? 0,
freeEpisodes: json['freeEpisodes'] as int? ?? 0,
unlockCoin: json['unlockCoin'] as int? ?? 0,
isHot: json['isHot'] as bool? ?? false,
viewCount: json['viewCount'] as int? ?? 0,
episodes: (json['episodes'] as List? ?? const [])
.map((e) => AppEpisode.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
class AppBanner {
AppBanner({
required this.id,
required this.imageUrl,
required this.linkType,
required this.linkValue,
});
final int id;
final String imageUrl;
final String linkType;
final String linkValue;
factory AppBanner.fromJson(Map<String, dynamic> json) => AppBanner(
id: json['id'] as int,
imageUrl: json['imageUrl'] as String? ?? '',
linkType: json['linkType'] as String? ?? '',
linkValue: json['linkValue'] as String? ?? '',
);
}
class AppCategory {
AppCategory({required this.id, required this.name});
final int id;
final String name;
factory AppCategory.fromJson(Map<String, dynamic> json) =>
AppCategory(id: json['id'] as int, name: json['name'] as String? ?? '');
}
class AppSection {
AppSection({
required this.code,
required this.title,
required this.dramas,
});
final String code;
final String title;
final List<AppDramaCard> dramas;
factory AppSection.fromJson(Map<String, dynamic> json) => AppSection(
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
dramas: (json['dramas'] as List? ?? const [])
.map((e) => AppDramaCard.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
class AppFeed {
AppFeed({required this.banners, required this.sections});
final List<AppBanner> banners;
final List<AppSection> sections;
factory AppFeed.fromJson(Map<String, dynamic> json) => AppFeed(
banners: (json['banners'] as List? ?? const [])
.map((e) => AppBanner.fromJson(e as Map<String, dynamic>))
.toList(),
sections: (json['sections'] as List? ?? const [])
.map((e) => AppSection.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
+54
View File
@@ -0,0 +1,54 @@
import 'json_numbers.dart';
/// 登录态:服务端下发的令牌与用户摘要。
///
/// 只保留客户端真正要用的字段(uid / 昵称 / 头像),不做整份 AppUserRespVO 的映射 ——
/// 首页卡片、个人页需要的都在这里,避免为用不到的字段维护一份平行模型。
class AuthUser {
const AuthUser({required this.uid, required this.nickname, this.avatarUrl = ''});
final String uid;
final String nickname;
final String avatarUrl;
factory AuthUser.fromJson(Map<String, dynamic> json) => AuthUser(
uid: json['uid'] as String? ?? '',
nickname: json['nickname'] as String? ?? '',
avatarUrl: json['avatarUrl'] as String? ?? '',
);
Map<String, dynamic> toJson() => {
'uid': uid,
'nickname': nickname,
'avatarUrl': avatarUrl,
};
}
class AuthSession {
const AuthSession({
required this.accessToken,
required this.refreshToken,
this.expiresIn = 0,
required this.user,
});
final String accessToken;
final String refreshToken;
final int expiresIn;
final AuthUser user;
factory AuthSession.fromJson(Map<String, dynamic> json) => AuthSession(
accessToken: json['accessToken'] as String? ?? '',
refreshToken: json['refreshToken'] as String? ?? '',
// expiresIn 后端是 long,按字符串下发(见 json_numbers.dart)
expiresIn: jsonToInt(json['expiresIn']),
user: AuthUser.fromJson((json['user'] as Map?)?.cast<String, dynamic>() ?? const {}),
);
Map<String, dynamic> toJson() => {
'accessToken': accessToken,
'refreshToken': refreshToken,
'expiresIn': expiresIn,
'user': user.toJson(),
};
}
+101 -8
View File
@@ -1,21 +1,55 @@
// Author: fruit.com.cn
enum DramaFormat { animated, liveAction }
import 'json_numbers.dart';
/// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。
///
/// 封面与播放地址都是后端返回的相对路径(`/files/...`),
/// 渲染前用 `AppConfig.mediaUrl` 补上服务地址。
class Episode {
const Episode({
required this.id,
required this.number,
required this.title,
required this.assetPath,
required this.isFree,
this.videoUrl = '',
this.durationSec = 0,
this.locked = false,
});
final String id;
final int number;
final String title;
final String assetPath;
final bool isFree;
/// 播放地址:详情接口不下发,由 `/app/episodes/{id}/play` 返回后写入。
final String videoUrl;
final int durationSec;
/// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。
final bool locked;
Episode withVideoUrl(String url) => Episode(
id: id,
number: number,
title: title,
isFree: isFree,
videoUrl: url,
durationSec: durationSec,
locked: locked,
);
factory Episode.fromJson(Map<String, dynamic> json) {
return Episode(
id: (json['id'] ?? '').toString(),
number: (json['number'] as num?)?.toInt() ?? 0,
title: json['title'] as String? ?? '',
isFree: json['isFree'] == true,
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
locked: json['locked'] == true,
videoUrl: json['playUrl'] as String? ?? '',
);
}
}
class Drama {
@@ -23,15 +57,74 @@ class Drama {
required this.id,
required this.title,
required this.synopsis,
required this.posterAsset,
required this.episodes,
this.format = DramaFormat.liveAction,
required this.posterUrl,
this.episodes = const [],
this.category = '',
this.genre = '',
this.totalEpisodes = 0,
this.freeEpisodes = 0,
this.isHot = false,
this.viewCount = 0,
this.detailLoaded = false,
});
final String id;
final String title;
final String synopsis;
final String posterAsset;
final String posterUrl;
final List<Episode> episodes;
final DramaFormat format;
final String category;
final String genre;
final int totalEpisodes;
final int freeEpisodes;
final bool isHot;
final int viewCount;
/// 详情接口是否已经取过完整剧集列表。
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
final bool detailLoaded;
/// 卡片只带一集预览,集数以后端给的总集数为准。
int get episodeCount => totalEpisodes > 0 ? totalEpisodes : episodes.length;
/// 列表卡片:带一集可播预览(首页直接起播),完整剧集进详情再取。
factory Drama.fromCardJson(Map<String, dynamic> json) {
final preview = json['preview'];
return Drama(
id: (json['id'] ?? '').toString(),
title: json['title'] as String? ?? '',
synopsis: json['synopsis'] as String? ?? '',
posterUrl: json['cover'] as String? ?? '',
episodes: preview is Map<String, dynamic>
? [Episode.fromJson(preview)]
: const [],
category: json['category'] as String? ?? '',
genre: json['genre'] as String? ?? '',
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
isHot: json['isHot'] == true,
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
viewCount: jsonToInt(json['viewCount']),
);
}
/// 详情:带剧集列表。
factory Drama.fromDetailJson(Map<String, dynamic> json) {
return Drama(
id: (json['id'] ?? '').toString(),
title: json['title'] as String? ?? '',
synopsis: json['synopsis'] as String? ?? '',
posterUrl: json['cover'] as String? ?? '',
category: json['category'] as String? ?? '',
genre: json['genre'] as String? ?? '',
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
isHot: json['isHot'] == true,
viewCount: jsonToInt(json['viewCount']),
detailLoaded: true,
episodes: (json['episodes'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Episode.fromJson)
.toList(),
);
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'drama.dart';
/// 首页/发现页聚合数据,一次请求拿到轮播与分区。
class HomeFeed {
const HomeFeed({this.banners = const [], this.sections = const []});
final List<FeedBanner> banners;
final List<FeedSection> sections;
bool get isEmpty => sections.every((section) => section.dramas.isEmpty);
/// 分区打平后的短剧列表,供现有 UI 的横滑/竖滑复用;按 id 去重。
List<Drama> get dramas {
final seen = <String>{};
final result = <Drama>[];
for (final section in sections) {
for (final drama in section.dramas) {
if (seen.add(drama.id)) result.add(drama);
}
}
return result;
}
factory HomeFeed.fromJson(Map<String, dynamic> json) {
final banners = (json['banners'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(FeedBanner.fromJson)
.toList();
final sections = (json['sections'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(FeedSection.fromJson)
.toList();
return HomeFeed(banners: banners, sections: sections);
}
}
class FeedSection {
const FeedSection({
required this.code,
required this.title,
this.dramas = const [],
});
final String code;
final String title;
final List<Drama> dramas;
factory FeedSection.fromJson(Map<String, dynamic> json) {
return FeedSection(
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
dramas: (json['dramas'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Drama.fromCardJson)
.toList(),
);
}
}
class FeedBanner {
const FeedBanner({
required this.id,
required this.imageUrl,
this.linkType = 'NONE',
this.linkValue = '',
});
final String id;
final String imageUrl;
final String linkType;
final String linkValue;
/// 点击轮播要打开的短剧 id,非短剧跳转返回 null。
String? get dramaId => linkType == 'DRAMA' && linkValue.isNotEmpty
? linkValue
: null;
factory FeedBanner.fromJson(Map<String, dynamic> json) {
return FeedBanner(
id: (json['id'] ?? '').toString(),
imageUrl: json['imageUrl'] as String? ?? '',
linkType: json['linkType'] as String? ?? 'NONE',
linkValue: json['linkValue'] as String? ?? '',
);
}
}
+13
View File
@@ -0,0 +1,13 @@
// Author: fruit.com.cn
/// 数值字段的兼容解析。
///
/// 后端契约(FruitServer/docs/api-phase1.md §0):`Long`/`long` 字段**一律以字符串下发**
/// (雪花 id 与计数都算),`Integer`/`int` 仍是数字。所以这里既接受数字也接受数字字符串,
/// 否则一个 `"12345"` 就能让 `as num?` 直接抛异常、整页解析失败。
int jsonToInt(dynamic value, {int fallback = 0}) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value.trim()) ?? fallback;
return fallback;
}
+55
View File
@@ -0,0 +1,55 @@
import 'json_numbers.dart';
/// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。
class PlayTicket {
const PlayTicket({
required this.episodeId,
required this.accessible,
required this.accessType,
this.dramaId = '',
this.number = 0,
this.title = '',
this.playUrl = '',
this.durationSec = 0,
this.unlockCoin = 0,
this.previewSeconds = 0,
});
static const String free = 'FREE';
static const String coinRequired = 'COIN_REQUIRED';
static const String vipRequired = 'VIP_REQUIRED';
static const String noMedia = 'NO_MEDIA';
final String episodeId;
final String dramaId;
final int number;
final String title;
final bool accessible;
final String accessType;
/// 签名播放地址:**原样使用**(含 `?e=&s=`),客户端不拼接、不改写。
final String playUrl;
final int durationSec;
final int unlockCoin;
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
final int previewSeconds;
bool get needsUnlock =>
accessType == coinRequired || accessType == vipRequired;
factory PlayTicket.fromJson(Map<String, dynamic> json) {
return PlayTicket(
episodeId: (json['episodeId'] ?? '').toString(),
dramaId: (json['dramaId'] ?? '').toString(),
number: jsonToInt(json['number']),
title: json['title'] as String? ?? '',
accessible: json['accessible'] == true,
accessType: json['accessType'] as String? ?? noMedia,
playUrl: json['playUrl'] as String? ?? '',
durationSec: jsonToInt(json['durationSec']),
unlockCoin: jsonToInt(json['unlockCoin']),
previewSeconds: jsonToInt(json['previewSeconds']),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 服务端续看记录:`GET /app/watch/progress?dramaId=` 与 `/app/watch/recent` 的下发结构。
///
/// 服务端按 `(user, drama)` 只留一条,所以它是"这部剧看到哪一集的哪一秒",
/// 不是逐集进度。ID 与毫秒都是字符串(见 api-phase1.md §0),统一走 jsonToInt 解析。
class WatchProgress {
const WatchProgress({
required this.dramaId,
required this.episodeId,
required this.position,
required this.duration,
this.finished = false,
this.playCount = 0,
});
final String dramaId;
final String episodeId;
final Duration position;
final Duration duration;
final bool finished;
final int playCount;
bool get hasPosition => position > Duration.zero;
/// 进度比例 0~1,时长未知时为 0,避免 UI 出现 NaN。
double get fraction {
if (duration <= Duration.zero) return 0;
return (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0);
}
factory WatchProgress.fromJson(Map<String, dynamic> json) {
return WatchProgress(
dramaId: (json['dramaId'] ?? '').toString(),
episodeId: (json['episodeId'] ?? '').toString(),
position: Duration(milliseconds: jsonToInt(json['positionMs'])),
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
finished: json['finished'] == true,
playCount: jsonToInt(json['playCount']),
);
}
Map<String, dynamic> toJson() => {
'dramaId': dramaId,
'episodeId': episodeId,
'positionMs': position.inMilliseconds,
'durationMs': duration.inMilliseconds,
'finished': finished,
'playCount': playCount,
};
}
/// 续看点:从哪一集的哪个位置继续看。
class ResumePoint {
const ResumePoint({required this.episodeId, required this.position});
final String episodeId;
final Duration position;
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:dio/dio.dart';
import '../../config/app_config.dart';
import 'api_exception.dart';
import 'api_interceptors.dart';
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
export 'api_exception.dart';
/// 后端统一响应壳 `{code, message, data}` 的解析入口。
///
/// 职责:
/// - 用 dio 发请求(`AuthHeaderInterceptor` 注入令牌与设备号,`EnvelopeInterceptor` 解包与映射错误码);
/// - 编排「401 → 刷新令牌 → 重发一次」——**刻意不放在拦截器里重发**,因为 dio 的排队拦截器
/// 在回调里再发起请求会把自己排到队尾,造成自锁。
class ApiClient {
ApiClient({
String? baseUrl,
HttpClientAdapter? adapter,
this.accessTokenReader,
this.tokenRefresher,
this.onUnauthorized,
this.deviceIdReader,
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl {
_dio = Dio(
BaseOptions(
baseUrl: this.baseUrl,
connectTimeout: AppConfig.requestTimeout,
receiveTimeout: AppConfig.requestTimeout,
// 拿原始字节自己解码:后端消息是 UTF-8,且"不是 JSON 的响应体"要能被识别出来
responseType: ResponseType.bytes,
// 非 2xx 不抛异常:业务结果由响应体里的 code 表达(与旧实现一致)
validateStatus: (_) => true,
),
);
_dio.interceptors.addAll([
AuthHeaderInterceptor(
accessTokenReader: accessTokenReader,
deviceIdReader: deviceIdReader,
),
const EnvelopeInterceptor(),
]);
if (adapter != null) {
_dio.httpClientAdapter = adapter;
}
}
final String baseUrl;
late final Dio _dio;
/// 取当前 access token(未登录返回 null)
final String? Function()? accessTokenReader;
/// 刷新令牌:返回 true 表示刷新成功,可以重试原请求
final Future<bool> Function()? tokenRefresher;
/// 刷新也失败时回调(清本地登录态、回收 UI)
final void Function()? onUnauthorized;
/// 设备号(`X-Device-Id`):注册风控、后续下载绑定都要用
final Future<String> Function()? deviceIdReader;
Future<Object?> get(String path, {Map<String, String>? query}) {
return _request('GET', path, query: query);
}
Future<Object?> post(
String path, {
Object? body,
Map<String, String>? query,
bool skipAuthRetry = false,
}) {
return _request(
'POST',
path,
body: body,
query: query,
skipAuthRetry: skipAuthRetry,
);
}
Future<Object?> delete(
String path, {
Object? body,
Map<String, String>? query,
}) {
return _request('DELETE', path, body: body, query: query);
}
Future<Object?> _request(
String method,
String path, {
Object? body,
Map<String, String>? query,
bool retried = false,
bool skipAuthRetry = false,
}) async {
final disableAuthRetry = skipAuthRetry || tokenRefresher == null;
try {
final response = await _dio.request<Object?>(
path,
data: body,
queryParameters: (query?.isEmpty ?? true) ? null : query,
options: Options(
method: method,
contentType: body == null ? null : Headers.jsonContentType,
extra: {
ApiRequestFlags.disableAuthRetry: disableAuthRetry,
ApiRequestFlags.retried: retried,
},
),
);
return response.data;
} on DioException catch (error) {
final cause = error.error;
if (cause is TokenExpiredException) {
final refreshed = await tokenRefresher?.call() ?? false;
if (refreshed) {
return _request(
method,
path,
body: body,
query: query,
retried: true,
skipAuthRetry: skipAuthRetry,
);
}
onUnauthorized?.call();
throw ApiException(401, cause.message ?? '登录已过期');
}
if (cause is ApiException) {
throw cause;
}
rethrow;
}
}
void close() => _dio.close(force: true);
}
+15
View File
@@ -0,0 +1,15 @@
// Author: fruit.com.cn
/// 接口异常:code 为后端业务码,message 可直接展示。
///
/// 单独成文件是为了让 [ApiClient] 与拦截器都能引用它而不互相 import
/// (`api_client.dart` 会 `export` 本文件,历史调用方继续 `import 'api_client.dart'` 即可拿到它)。
class ApiException implements Exception {
const ApiException(this.code, this.message);
final int code;
final String message;
@override
String toString() => 'ApiException($code, $message)';
}
+133
View File
@@ -0,0 +1,133 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:dio/dio.dart';
import 'api_exception.dart';
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
class ApiRequestFlags {
const ApiRequestFlags._();
/// 本次请求不参与「401 → 刷新 → 重试」:刷新令牌自身、登出,以及没有刷新回调时。
static const String disableAuthRetry = 'app.auth.disableRetry';
/// 本次请求已经是刷新后的重试,见到它就不再刷新,避免无限循环。
static const String retried = 'app.auth.retried';
}
/// 内部信号:响应体里 `code == 401`,应当由 `ApiClient` 刷新令牌后重发一次。
///
/// 只在本层流转,不会漏给调用方 —— 调用方拿到的始终是 `ApiException`。
class TokenExpiredException implements Exception {
const TokenExpiredException(this.message);
/// 后端返回的提示语(可能为空),用于刷新失败时拼 `ApiException` 的消息。
final String? message;
}
/// 注入鉴权与设备标识请求头。
///
/// 每个请求都重新取一次令牌,所以刷新成功后重发的那次会自动带上新令牌。
class AuthHeaderInterceptor extends Interceptor {
AuthHeaderInterceptor({this.accessTokenReader, this.deviceIdReader});
/// 当前 access token;未登录返回 null 或空串。
final String? Function()? accessTokenReader;
/// 设备号(`X-Device-Id`):注册风控、下载绑定都用它。
final Future<String> Function()? deviceIdReader;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
options.headers['Accept'] = 'application/json';
final token = accessTokenReader?.call();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
final deviceId = await deviceIdReader?.call();
if (deviceId != null && deviceId.isNotEmpty) {
options.headers['X-Device-Id'] = deviceId;
}
handler.next(options);
}
}
/// 解包后端统一响应壳 `{code,message,data}`,并把业务码映射成异常。
///
/// 判定口径与旧的 `http` 实现逐条对齐:
/// - body 不是合法 JSON → 视为 `code = -1`,抛 `ApiException(状态码, 'HTTP <状态码>')`
/// - `code != 0` → 抛 `ApiException(code, message ?? '请求失败')`
/// - `code == 401` 且允许刷新 → 抛 [TokenExpiredException] 作为信号(由 `ApiClient` 刷新后重发)
class EnvelopeInterceptor extends Interceptor {
const EnvelopeInterceptor();
@override
void onResponse(
Response<dynamic> response,
ResponseInterceptorHandler handler,
) {
final options = response.requestOptions;
final shell = _decodeShell(response.data);
final code = shell['code'] as int? ?? -1;
final message = shell['message'] as String?;
final retried = options.extra[ApiRequestFlags.retried] == true;
final disableRetry =
options.extra[ApiRequestFlags.disableAuthRetry] == true;
if (code == 401 && !retried && !disableRetry) {
return handler.reject(
DioException(
requestOptions: options,
response: response,
type: DioExceptionType.badResponse,
error: TokenExpiredException(message),
),
true,
);
}
if (code == -1) {
final status = response.statusCode ?? -1;
return handler.reject(
DioException(
requestOptions: options,
response: response,
type: DioExceptionType.badResponse,
error: ApiException(status, 'HTTP $status'),
),
true,
);
}
if (code != 0) {
return handler.reject(
DioException(
requestOptions: options,
response: response,
type: DioExceptionType.badResponse,
error: ApiException(code, message ?? '请求失败'),
),
true,
);
}
response.data = shell['data'];
handler.next(response);
}
/// 统一按 UTF-8 解码:后端的中文提示语不能因为缺 charset 而乱码。
Map<String, dynamic> _decodeShell(Object? body) {
final text = switch (body) {
List<int> bytes => utf8.decode(bytes, allowMalformed: true),
String text => text,
_ => '',
};
try {
return (jsonDecode(text) as Map).cast<String, dynamic>();
} catch (_) {
return const {};
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import '../models/auth_session.dart';
import 'api_client.dart';
/// 账号相关接口(/app/auth/**)。
///
/// 注册/登录都用「邮箱 + 密码」;邮箱注册不需要验证码(后端按 targetType 放开),
/// 手机号注册仍要验证码 —— 客户端这一批只开放邮箱入口。
class AuthApi {
const AuthApi(this._client);
final ApiClient _client;
Future<AuthSession> register({
required String email,
required String password,
required String deviceId,
String platform = '',
String appVersion = '',
}) async {
final data = await _client.post(
'/app/auth/register',
body: {
'targetType': 'EMAIL',
'target': email,
'password': password,
'countryCode': 'ID',
'deviceId': deviceId,
'platform': platform,
'appVersion': appVersion,
},
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<AuthSession> login({
required String email,
required String password,
required String deviceId,
String platform = '',
String appVersion = '',
}) async {
final data = await _client.post(
'/app/auth/login',
body: {
'targetType': 'EMAIL',
'target': email,
'password': password,
'deviceId': deviceId,
'platform': platform,
'appVersion': appVersion,
},
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<AuthSession> refresh(String refreshToken) async {
final data = await _client.post(
'/app/auth/refresh',
body: {'refreshToken': refreshToken},
// 刷新请求本身不带旧令牌重试,失败就是失败
skipAuthRetry: true,
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<void> logout(String refreshToken) async {
await _client.post(
'/app/auth/logout',
body: {'refreshToken': refreshToken},
skipAuthRetry: true,
);
}
}
+29
View File
@@ -0,0 +1,29 @@
import '../models/drama.dart';
import '../models/home_feed.dart';
import 'api_client.dart';
/// 客户端目录接口,对应后端 `/app/**`。
class CatalogApi {
const CatalogApi(this._client);
final ApiClient _client;
Future<HomeFeed> homeFeed() async {
return HomeFeed.fromJson(_asMap(await _client.get('/app/home/feed')));
}
Future<HomeFeed> discoverFeed() async {
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
}
Future<Drama> dramaDetail(String dramaId) async {
return Drama.fromDetailJson(
_asMap(await _client.get('/app/dramas/$dramaId')),
);
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '返回数据格式异常');
}
}
+35
View File
@@ -0,0 +1,35 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import 'api_client.dart';
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
///
/// 列表返回的是与首页一致的卡片结构,所以直接用 `Drama.fromCardJson` 解析。
class LibraryApi {
const LibraryApi(this._client);
final ApiClient _client;
/// 切换收藏,返回切换后的状态(true = 已收藏),客户端以它为唯一真相。
Future<bool> toggleFavorite(String dramaId) async {
final data = await _client.post(
'/app/favorites/toggle',
body: {'dramaId': dramaId},
);
return data == true;
}
Future<List<Drama>> favorites() async {
final data = await _client.get('/app/favorites');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) Drama.fromCardJson(item),
];
}
Future<void> removeFavorite(String dramaId) async {
await _client.delete('/app/favorites/$dramaId');
}
}
+20
View File
@@ -0,0 +1,20 @@
// Author: fruit.com.cn
import '../models/play_ticket.dart';
import 'api_client.dart';
/// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。
///
/// **签名地址原样使用**:后端下发的 `playUrl` 带 `?e=过期时间&s=签名`,
/// 客户端只做「相对路径补 host」,不拼接、不改写、不丢 query —— 否则签名校验直接失败。
class PlaybackApi {
const PlaybackApi(this._client);
final ApiClient _client;
Future<PlayTicket> ticket(String episodeId) async {
final data = await _client.get('/app/episodes/$episodeId/play');
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data);
throw const ApiException(-1, '返回数据格式异常');
}
}
+59
View File
@@ -0,0 +1,59 @@
// Author: fruit.com.cn
import '../models/watch_progress.dart';
import 'api_client.dart';
/// 观看进度接口:`/app/watch/*`(契约 §1.5)。全部需要登录态。
class WatchApi {
const WatchApi(this._client);
final ApiClient _client;
/// 上报进度。服务端从 `episodeId` 推出所属剧集,并保证「同集只前进不后退」。
Future<WatchProgress?> saveProgress({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
String source = 'app',
}) async {
final data = await _client.post(
'/app/watch/progress',
body: {
'episodeId': episodeId,
'positionMs': position.inMilliseconds,
if (duration != null) 'durationMs': duration.inMilliseconds,
'finished': finished,
'source': source,
},
);
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 某部剧的续看位置;没有记录时后端返回空 data。
Future<WatchProgress?> progressOfDrama(String dramaId) async {
final data = await _client.get(
'/app/watch/progress',
query: {'dramaId': dramaId},
);
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 最近观看(服务端按下标返回,已按更新时间倒序)。
Future<List<WatchProgress>> recent({int limit = 20}) async {
final data = await _client.get(
'/app/watch/recent',
query: {'limit': '$limit'},
);
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) WatchProgress.fromJson(item),
];
}
/// 删除某部剧的观看记录(记录页清单条用)。
Future<void> removeProgress(String dramaId) async {
await _client.delete('/app/watch/progress/$dramaId');
}
}
@@ -1,91 +0,0 @@
// Author: fruit.com.cn
import 'api_client.dart';
import 'token_storage.dart';
/// 登录/刷新成功后返回的会话令牌。
class AuthSession {
AuthSession({
required this.accessToken,
required this.refreshToken,
required this.expiresIn,
required this.newUser,
});
final String accessToken;
final String refreshToken;
final int expiresIn;
final bool newUser;
factory AuthSession.fromJson(Map<String, dynamic> json) {
return AuthSession(
accessToken: json['accessToken'] as String,
refreshToken: json['refreshToken'] as String,
expiresIn: (json['expiresIn'] as num?)?.toInt() ?? 7200,
newUser: json['newUser'] as bool? ?? false,
);
}
}
/// 账号与鉴权接口封装。
class AuthRepository {
AuthRepository(this._client, this._tokenStorage);
final ApiClient _client;
final TokenStorage _tokenStorage;
/// 发送验证码。
Future<void> sendOtp({
required String scene,
required String targetType,
required String target,
}) async {
await _client.post('/app/auth/otp', data: {
'scene': scene,
'targetType': targetType,
'target': target,
});
}
/// 验证码登录。
Future<AuthSession> loginWithCode({
required String targetType,
required String target,
required String code,
}) async {
final data = await _client.post('/app/auth/login', data: {
'targetType': targetType,
'target': target,
'code': code,
});
return AuthSession.fromJson(data as Map<String, dynamic>);
}
/// 密码登录。
Future<AuthSession> loginWithPassword({
required String targetType,
required String target,
required String password,
}) async {
final data = await _client.post('/app/auth/login', data: {
'targetType': targetType,
'target': target,
'password': password,
});
return AuthSession.fromJson(data as Map<String, dynamic>);
}
/// 保存会话令牌。
Future<void> saveSession(AuthSession session) async {
await _tokenStorage.saveTokens(
access: session.accessToken,
refresh: session.refreshToken,
);
}
/// 登出并清理本地令牌。
Future<void> logout() async {
await _client.post('/app/auth/logout');
await _tokenStorage.clear();
}
}
@@ -1,106 +1,33 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../remote/catalog_api.dart';
/// 目录数据来源:首页/发现页聚合、详情。
///
/// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。
/// **播放票据不在这里**:它属于 [PlaybackRepository](票据有有效期,需要单独缓存策略)。
abstract interface class CatalogRepository {
Future<List<Drama>> fetchDramas();
Future<HomeFeed> fetchHomeFeed();
Future<HomeFeed> fetchDiscoverFeed();
Future<Drama> fetchDrama(String dramaId);
}
class DemoCatalogRepository implements CatalogRepository {
class HttpCatalogRepository implements CatalogRepository {
HttpCatalogRepository(this._api);
final CatalogApi _api;
@override
Future<List<Drama>> fetchDramas() async => const [
Drama(
id: 'the-signal-demo',
title: 'The Signal',
synopsis:
'A mock series for testing vertical episode playback with one reused local clip.',
posterAsset: 'assets/images/stitch_signal_poster_clean.png',
format: DramaFormat.animated,
episodes: [
Episode(
id: 'signal-01',
number: 1,
title: 'Episode 1',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'signal-02',
number: 2,
title: 'Episode 2',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'signal-03',
number: 3,
title: 'Episode 3',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
],
),
Drama(
id: 'city-lights-demo',
title: 'City Lights & Velvet Nights',
synopsis:
'A mock romance series using the same local video for navigation testing.',
posterAsset: 'assets/images/stitch_city_poster_clean.png',
format: DramaFormat.liveAction,
episodes: [
Episode(
id: 'city-01',
number: 1,
title: 'Episode 1',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'city-02',
number: 2,
title: 'Episode 2',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'city-03',
number: 3,
title: 'Episode 3',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
],
),
Drama(
id: 'vengeance-demo',
title: 'Vengeance Rising',
synopsis:
'A mock action series using the same local video for navigation testing.',
posterAsset: 'assets/images/stitch_vengeance_poster_clean.png',
format: DramaFormat.liveAction,
episodes: [
Episode(
id: 'vengeance-01',
number: 1,
title: 'Episode 1',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'vengeance-02',
number: 2,
title: 'Episode 2',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'vengeance-03',
number: 3,
title: 'Episode 3',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
],
),
];
Future<HomeFeed> fetchHomeFeed() => _api.homeFeed();
@override
Future<HomeFeed> fetchDiscoverFeed() => _api.discoverFeed();
@override
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
}
@@ -0,0 +1,68 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../remote/library_api.dart';
/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。
abstract interface class FavoriteRepository {
/// 切换收藏,返回切换后的状态(true = 已收藏)。
Future<bool> toggle(String dramaId);
/// 收藏列表(卡片结构与首页一致)。
Future<List<Drama>> list();
Future<void> remove(String dramaId);
}
class HttpFavoriteRepository implements FavoriteRepository {
const HttpFavoriteRepository(this._api, {this.isSignedIn});
final LibraryApi _api;
/// 未登录时不请求:收藏属于账号,匿名浏览时列表按空处理(点收藏按钮会先引导登录)。
final bool Function()? isSignedIn;
bool get _signedIn => isSignedIn?.call() ?? true;
@override
Future<bool> toggle(String dramaId) => _api.toggleFavorite(dramaId);
@override
Future<List<Drama>> list() async {
if (!_signedIn) return const [];
return _api.favorites();
}
@override
Future<void> remove(String dramaId) => _api.removeFavorite(dramaId);
}
/// 内存实现:单测与 widget 测试注入用。
class InMemoryFavoriteRepository implements FavoriteRepository {
InMemoryFavoriteRepository({Set<String>? saved, List<Drama>? cards})
: _saved = {...?saved},
_cards = [...?cards];
final Set<String> _saved;
final List<Drama> _cards;
bool failToggle = false;
bool failList = false;
int toggleCalls = 0;
@override
Future<bool> toggle(String dramaId) async {
toggleCalls += 1;
if (failToggle) throw StateError('favorite unavailable');
if (!_saved.add(dramaId)) _saved.remove(dramaId);
return _saved.contains(dramaId);
}
@override
Future<List<Drama>> list() async {
if (failList) throw StateError('favorite unavailable');
return _cards.where((card) => _saved.contains(card.id)).toList();
}
@override
Future<void> remove(String dramaId) async => _saved.remove(dramaId);
}
@@ -1,33 +0,0 @@
// Author: fruit.com.cn
import '../models/app_catalog.dart';
import '../services/api_client.dart';
/// 对接后端 `/app/**` 内容接口(A3)。
class HttpCatalogRepository {
HttpCatalogRepository(this._client);
final ApiClient _client;
Future<AppFeed> fetchHomeFeed() async {
final data = await _client.get('/app/home/feed');
return AppFeed.fromJson(data as Map<String, dynamic>);
}
Future<AppFeed> fetchDiscoverFeed() async {
final data = await _client.get('/app/discover/feed');
return AppFeed.fromJson(data as Map<String, dynamic>);
}
Future<List<AppCategory>> fetchCategories() async {
final data = await _client.get('/app/categories');
return (data as List)
.map((e) => AppCategory.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<AppDramaDetail> fetchDramaDetail(int id) async {
final data = await _client.get('/app/dramas/$id');
return AppDramaDetail.fromJson(data as Map<String, dynamic>);
}
}
@@ -1,61 +0,0 @@
// Author: fruit.com.cn
import '../services/api_client.dart';
/// 评论、点赞、举报(A6)。
class InteractionRepository {
InteractionRepository(this._client);
final ApiClient _client;
Future<void> postComment({
required int dramaId,
required String content,
int? rootId,
}) async {
await _client.post('/app/comments', data: {
'dramaId': dramaId,
'content': content,
if (rootId != null) 'rootId': rootId,
});
}
Future<List<dynamic>> fetchComments({
required int dramaId,
int? cursor,
int size = 20,
}) async {
final data = await _client.get('/app/comments', query: {
'dramaId': dramaId,
if (cursor != null) 'cursor': cursor,
'size': size,
});
return data as List;
}
Future<void> deleteComment(int id) async {
await _client.delete('/app/comments/$id');
}
Future<void> toggleLike({
required String targetType,
required int targetId,
}) async {
await _client.post('/app/likes/toggle', data: {
'targetType': targetType,
'targetId': targetId,
});
}
Future<void> report({
required String targetType,
required int targetId,
required String reason,
}) async {
await _client.post('/app/reports', data: {
'targetType': targetType,
'targetId': targetId,
'reason': reason,
});
}
}
@@ -1,16 +1,96 @@
// Author: fruit.com.cn
import '../services/api_client.dart';
import '../models/play_ticket.dart';
import '../remote/playback_api.dart';
/// 播放票据(A4)。
class PlaybackRepository {
PlaybackRepository(this._client);
/// 播放票据来源。
///
/// 票据里的签名地址有有效期(契约 §1.4:签名默认 15 分钟),所以**不能一直缓存**:
/// [ticketFor] 命中未过期的缓存就用缓存,过期或 [forceRefresh] 时重新向后端要一张。
abstract interface class PlaybackRepository {
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false});
final ApiClient _client;
/// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。
bool isFresh(String episodeId);
}
/// 签发播放票据与签名地址,客户端原样使用 playUrl。
Future<Map<String, dynamic>> fetchPlayTicket(int episodeId) async {
final data = await _client.get('/app/episodes/$episodeId/play');
return data as Map<String, dynamic>;
class HttpPlaybackRepository implements PlaybackRepository {
HttpPlaybackRepository(
this._api, {
this.ttl = const Duration(minutes: 10),
DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now;
final PlaybackApi _api;
/// 缓存时长:比后端 15 分钟签名短 5 分钟,避免"地址刚好在播放中途失效"。
final Duration ttl;
final DateTime Function() _clock;
final Map<String, _CachedTicket> _cache = {};
@override
Future<PlayTicket> ticketFor(
String episodeId, {
bool forceRefresh = false,
}) async {
final cached = _cache[episodeId];
if (!forceRefresh && cached != null && !cached.isExpired(_clock(), ttl)) {
return cached.ticket;
}
final ticket = await _api.ticket(episodeId);
_cache[episodeId] = _CachedTicket(ticket, _clock());
return ticket;
}
@override
bool isFresh(String episodeId) {
final cached = _cache[episodeId];
if (cached == null) return false;
return !cached.isExpired(_clock(), ttl);
}
/// 供测试与"退出播放页"清理用。
void clear() => _cache.clear();
}
class _CachedTicket {
const _CachedTicket(this.ticket, this.fetchedAt);
final PlayTicket ticket;
final DateTime fetchedAt;
bool isExpired(DateTime now, Duration ttl) => now.difference(fetchedAt) >= ttl;
}
/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。
class FakePlaybackRepository implements PlaybackRepository {
FakePlaybackRepository({this.ticket, this.error, this.fresh = true});
PlayTicket? ticket;
Object? error;
final List<String> requests = [];
final List<bool> forced = [];
/// 模拟"票据是否还有效":置 false 可验证回前台会重新取。
bool fresh;
@override
bool isFresh(String episodeId) => fresh;
@override
Future<PlayTicket> ticketFor(
String episodeId, {
bool forceRefresh = false,
}) async {
requests.add(episodeId);
forced.add(forceRefresh);
if (error != null) throw error!;
return ticket ??
PlayTicket(
episodeId: episodeId,
accessible: true,
accessType: PlayTicket.free,
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
);
}
}
@@ -1,25 +0,0 @@
// Author: fruit.com.cn
import '../services/api_client.dart';
/// 钱包、金币解锁(09 收费支付)。
class WalletRepository {
WalletRepository(this._client);
final ApiClient _client;
Future<Map<String, dynamic>> fetchWallet() async {
final data = await _client.get('/app/wallet');
return data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> unlockEpisode(int episodeId) async {
final data = await _client.post('/app/episodes/$episodeId/unlock');
return data as Map<String, dynamic>;
}
Future<List<dynamic>> fetchOrders() async {
final data = await _client.get('/app/orders');
return data as List;
}
}
@@ -1,67 +1,142 @@
// Author: fruit.com.cn
import 'package:shared_preferences/shared_preferences.dart';
const demoProfileId = 'demo-local-v1';
import '../models/watch_progress.dart';
import '../remote/watch_api.dart';
/// 观看进度来源。
///
/// **服务端按 `(user, drama)` 只留一条续看记录**(契约 §1.5),所以这里不再是
/// "本地按 profile+剧+集存位置",而是"这部剧看到哪一集、哪一秒"。
/// 本地实现已删除:进度以服务端为准,换设备也能续看。
abstract interface class WatchHistoryRepository {
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
);
Future<String?> lastEpisodeId(String profileId, String dramaId);
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
);
/// 某部剧的续看记录;没有记录返回 null(不是异常)。
Future<WatchProgress?> progressFor(String dramaId);
/// 上报进度(客户端每 10 秒一次,切集/退出/切后台各补一次)。
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
});
/// 最近观看(默认 20 条)。
Future<List<WatchProgress>> recent({int limit = 20});
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
Future<void> remove(String dramaId);
}
class SharedPreferencesWatchHistoryRepository
implements WatchHistoryRepository {
SharedPreferencesWatchHistoryRepository({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
class HttpWatchHistoryRepository implements WatchHistoryRepository {
const HttpWatchHistoryRepository(this._api, {this.isSignedIn});
final SharedPreferencesAsync _preferences;
final WatchApi _api;
String _positionKey(String profile, String drama, String episode) =>
'watch.v1.$profile.$drama.$episode.positionMs';
String _lastKey(String profile, String drama) =>
'watch.v1.$profile.$drama.lastEpisode';
/// 未登录时这些接口必然 401:直接短路成"没有记录",让浏览/试看保持匿名可用。
final bool Function()? isSignedIn;
bool get _signedIn => isSignedIn?.call() ?? true;
@override
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
) async {
final milliseconds = await _preferences.getInt(
_positionKey(profileId, dramaId, episodeId),
);
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
Future<WatchProgress?> progressFor(String dramaId) async {
if (!_signedIn) return null;
return _api.progressOfDrama(dramaId);
}
@override
Future<String?> lastEpisodeId(String profileId, String dramaId) =>
_preferences.getString(_lastKey(profileId, dramaId));
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async {
if (!_signedIn) return null;
return _api.saveProgress(
episodeId: episodeId,
position: position,
duration: duration,
finished: finished,
);
}
@override
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
) async {
await _preferences.setInt(
_positionKey(profileId, dramaId, episodeId),
position.inMilliseconds,
);
await _preferences.setString(_lastKey(profileId, dramaId), episodeId);
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (!_signedIn) return const [];
return _api.recent(limit: limit);
}
@override
Future<void> remove(String dramaId) async {
if (!_signedIn) return;
await _api.removeProgress(dramaId);
}
}
/// 内存实现:单测与 widget 测试注入用(不依赖网络与偏好存储)。
class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
InMemoryWatchHistoryRepository({List<WatchProgress>? seed})
: _records = {for (final item in seed ?? const <WatchProgress>[]) item.dramaId: item};
final Map<String, WatchProgress> _records;
int failReads = 0;
bool failWrites = false;
final List<String> writes = [];
@override
Future<WatchProgress?> progressFor(String dramaId) async {
if (failReads > 0) {
failReads -= 1;
throw StateError('progress unavailable');
}
return _records[dramaId];
}
@override
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async {
if (failWrites) throw StateError('progress unavailable');
writes.add('$episodeId|${position.inMilliseconds}|$finished');
final existing = _records[episodeId];
final record = WatchProgress(
dramaId: existing?.dramaId ?? '',
episodeId: episodeId,
position: position,
duration: duration ?? Duration.zero,
finished: finished,
);
return record;
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (failReads > 0) {
failReads -= 1;
throw StateError('progress unavailable');
}
return _records.values.take(limit).toList();
}
@override
Future<void> remove(String dramaId) async => _records.remove(dramaId);
}
/// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。
extension WatchProgressResume on WatchHistoryRepository {
Future<ResumePoint?> resumePointFor(String dramaId) async {
final progress = await progressFor(dramaId);
if (progress == null || progress.episodeId.isEmpty) return null;
return ResumePoint(
episodeId: progress.episodeId,
position: progress.position,
);
}
}
/// 续看位置钳位:片尾附近与异常值一律从 0 开始,避免"一进来就播完"。
Duration clampResumePosition(Duration saved, Duration duration) {
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
if (saved <= Duration.zero) return Duration.zero;
@@ -1,59 +0,0 @@
// Author: fruit.com.cn
import '../services/api_client.dart';
/// 观看记录与收藏(A5)。
class WatchRepository {
WatchRepository(this._client);
final ApiClient _client;
Future<void> reportProgress({
required int episodeId,
required int positionSec,
required int durationSec,
}) async {
await _client.post('/app/watch/progress', data: {
'episodeId': episodeId,
'positionSec': positionSec,
'durationSec': durationSec,
});
}
Future<Map<String, dynamic>> fetchProgress(int dramaId) async {
final data = await _client.get('/app/watch/progress', query: {
'dramaId': dramaId,
});
return data as Map<String, dynamic>;
}
Future<List<dynamic>> fetchRecent({int limit = 20}) async {
final data = await _client.get('/app/watch/recent', query: {'limit': limit});
return data as List;
}
Future<void> deleteProgress(int dramaId) async {
await _client.delete('/app/watch/progress/$dramaId');
}
Future<void> clearProgress() async {
await _client.delete('/app/watch/progress');
}
Future<bool> toggleFavorite(int dramaId) async {
final data = await _client.post('/app/favorites/toggle', data: {
'dramaId': dramaId,
});
final map = data as Map<String, dynamic>;
return map['favorited'] as bool? ?? false;
}
Future<List<dynamic>> fetchFavorites() async {
final data = await _client.get('/app/favorites');
return data as List;
}
Future<void> deleteFavorite(int dramaId) async {
await _client.delete('/app/favorites/$dramaId');
}
}
-136
View File
@@ -1,136 +0,0 @@
// Author: fruit.com.cn
import 'package:dio/dio.dart';
import '../../config/api_config.dart';
import 'token_storage.dart';
/// 统一业务异常:code 为后端业务码,message 可直接展示。
class ApiException implements Exception {
ApiException(this.code, this.message);
final int code;
final String message;
@override
String toString() => 'ApiException($code): $message';
}
/// 统一 HTTP 客户端:BaseUrl、Token 注入、401 自动刷新、{code,message,data} 解包。
class ApiClient {
ApiClient({Dio? dio, TokenStorage? tokenStorage})
: _dio = dio ?? Dio(),
_tokenStorage = tokenStorage ?? TokenStorage() {
_dio.options
..baseUrl = ApiConfig.baseUrl
..connectTimeout = const Duration(
seconds: ApiConfig.connectTimeoutSeconds,
)
..receiveTimeout = const Duration(
seconds: ApiConfig.receiveTimeoutSeconds,
);
_dio.interceptors.add(
InterceptorsWrapper(onRequest: _onRequest, onError: _onError),
);
}
final Dio _dio;
final TokenStorage _tokenStorage;
bool _refreshing = false;
Future<void> _onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final access = await _tokenStorage.readAccessToken();
if (access != null && access.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $access';
}
// 设备标识:第一阶段固定占位,A2 接入真实 deviceId。
options.headers['X-Device-Id'] = 'device-0001';
handler.next(options);
}
Future<void> _onError(
DioException error,
ErrorInterceptorHandler handler,
) async {
final status = error.response?.statusCode;
final path = error.requestOptions.path;
final isAuthEndpoint =
path.endsWith('/auth/refresh') || path.endsWith('/auth/login');
if (status == 401 && !isAuthEndpoint && !_refreshing) {
final refreshed = await _refresh();
if (refreshed) {
handler.resolve(await _retry(error.requestOptions));
return;
}
}
handler.next(error);
}
Future<bool> _refresh() async {
_refreshing = true;
try {
final refresh = await _tokenStorage.readRefreshToken();
if (refresh == null || refresh.isEmpty) return false;
final response = await _dio.post<dynamic>(
'/app/auth/refresh',
data: {'refreshToken': refresh},
);
final body = response.data;
if (body is Map && body['code'] == 0) {
final data = body['data'] as Map;
await _tokenStorage.saveTokens(
access: data['accessToken'] as String,
refresh: data['refreshToken'] as String,
);
return true;
}
return false;
} catch (_) {
return false;
} finally {
_refreshing = false;
}
}
Future<Response<dynamic>> _retry(RequestOptions options) {
return _dio.fetch<dynamic>(options);
}
Future<dynamic> get(String path, {Map<String, dynamic>? query}) {
return _request(() => _dio.get<dynamic>(path, queryParameters: query));
}
Future<dynamic> post(String path, {Object? data}) {
return _request(() => _dio.post<dynamic>(path, data: data));
}
Future<dynamic> put(String path, {Object? data}) {
return _request(() => _dio.put<dynamic>(path, data: data));
}
Future<dynamic> delete(String path, {Object? data}) {
return _request(() => _dio.delete<dynamic>(path, data: data));
}
Future<dynamic> _request(Future<Response<dynamic>> Function() send) async {
try {
final response = await send();
final body = response.data;
if (body is Map && body.containsKey('code')) {
final code = body['code'];
if (code == 0) return body['data'];
throw ApiException(code as int, (body['message'] as String?) ?? '');
}
return body;
} on DioException catch (e) {
throw ApiException(
e.response?.statusCode ?? -1,
e.message ?? 'network error',
);
}
}
}
@@ -1,39 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/app_catalog.dart';
import '../repositories/http_catalog_repository.dart';
/// 首页/发现页 feed 状态(A3 数据层)。
class AppCatalogController extends ChangeNotifier {
AppCatalogController(this._repository);
final HttpCatalogRepository _repository;
AppFeed? feed;
bool loading = false;
String? error;
Future<void> loadFeed() async {
loading = true;
error = null;
notifyListeners();
try {
feed = await _repository.fetchHomeFeed();
} catch (e) {
error = e.toString();
} finally {
loading = false;
notifyListeners();
}
}
Future<AppDramaDetail> loadDetail(int id) {
return _repository.fetchDramaDetail(id);
}
Future<List<AppCategory>> loadCategories() {
return _repository.fetchCategories();
}
}
+84 -46
View File
@@ -1,65 +1,103 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../repositories/auth_repository.dart';
import '../models/auth_session.dart';
import '../remote/api_client.dart';
import '../remote/auth_api.dart';
import 'auth_session_store.dart';
enum AuthStatus { unknown, authenticated, unauthenticated }
/// 登录状态与账号操作。
/// 登录态的唯一来源。
///
/// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新;
/// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 ——
/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。
class AuthController extends ChangeNotifier {
AuthController(this._repository);
AuthController(this._api, this._store);
final AuthRepository _repository;
final AuthApi _api;
final AuthSessionStore _store;
AuthStatus _status = AuthStatus.unknown;
AuthStatus get status => _status;
AuthSession? _session;
String _deviceId = '';
bool _loaded = false;
Future<void> sendOtp({
required String scene,
required String targetType,
required String target,
}) {
return _repository.sendOtp(
scene: scene,
targetType: targetType,
target: target,
);
}
bool get isLoaded => _loaded;
bool get isSignedIn => _session != null;
AuthUser? get user => _session?.user;
String? get accessToken => _session?.accessToken;
String get deviceId => _deviceId;
Future<void> loginWithCode({
required String targetType,
required String target,
required String code,
}) async {
final session = await _repository.loginWithCode(
targetType: targetType,
target: target,
code: code,
);
await _repository.saveSession(session);
_status = AuthStatus.authenticated;
/// App 启动时调用:恢复本地令牌与设备号。
Future<void> load() async {
_deviceId = await _store.deviceId();
_session = await _store.read();
_loaded = true;
notifyListeners();
}
Future<void> loginWithPassword({
required String targetType,
required String target,
required String password,
}) async {
final session = await _repository.loginWithPassword(
targetType: targetType,
target: target,
Future<void> register({required String email, required String password}) async {
final session = await _api.register(
email: email,
password: password,
deviceId: await _store.deviceId(),
platform: _platform,
);
await _repository.saveSession(session);
_status = AuthStatus.authenticated;
await _apply(session);
}
Future<void> signIn({required String email, required String password}) async {
final session = await _api.login(
email: email,
password: password,
deviceId: await _store.deviceId(),
platform: _platform,
);
await _apply(session);
}
Future<void> signOut() async {
final refreshToken = _session?.refreshToken ?? '';
_session = null;
await _store.clear();
notifyListeners();
if (refreshToken.isNotEmpty) {
// 服务端吊销失败不影响本地已登出,异常直接吞掉
try {
await _api.logout(refreshToken);
} catch (_) {}
}
}
/// 供 [ApiClient] 调用:刷新令牌并落盘。失败时清本地会话。
Future<bool> refresh() async {
final refreshToken = _session?.refreshToken ?? '';
if (refreshToken.isEmpty) {
await clear();
return false;
}
try {
await _apply(await _api.refresh(refreshToken));
return true;
} catch (_) {
await clear();
return false;
}
}
Future<void> clear() async {
if (_session == null) return;
_session = null;
await _store.clear();
notifyListeners();
}
Future<void> logout() async {
await _repository.logout();
_status = AuthStatus.unauthenticated;
Future<void> _apply(AuthSession session) async {
_session = session;
await _store.save(session);
notifyListeners();
}
String get _platform {
if (kIsWeb) return 'WEB';
return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID';
}
}
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/auth_session.dart';
/// 令牌与设备号的本地持久化。
///
/// 用 `SharedPreferencesAsync`(与项目里 engagement 仓库一致):旧的
/// `SharedPreferences.getInstance()` 在新版本里已标注弃用,两套混用还会让
/// widget 测试只 mock 到其中一套。
///
/// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**;
/// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。
class AuthSessionStore {
static const _kAccessToken = 'auth.accessToken';
static const _kRefreshToken = 'auth.refreshToken';
static const _kUser = 'auth.user';
static const _kDeviceId = 'device.id';
String? _cachedDeviceId;
Future<String> deviceId() async {
if (_cachedDeviceId != null) return _cachedDeviceId!;
final prefs = SharedPreferencesAsync();
var value = await prefs.getString(_kDeviceId) ?? '';
if (value.isEmpty) {
final random = Random();
value =
'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-'
'${random.nextInt(1 << 32).toRadixString(36)}';
await prefs.setString(_kDeviceId, value);
}
_cachedDeviceId = value;
return value;
}
Future<AuthSession?> read() async {
final prefs = SharedPreferencesAsync();
final accessToken = await prefs.getString(_kAccessToken) ?? '';
final refreshToken = await prefs.getString(_kRefreshToken) ?? '';
if (accessToken.isEmpty || refreshToken.isEmpty) return null;
final rawUser = await prefs.getString(_kUser);
Map<String, dynamic> user = const {};
if (rawUser != null && rawUser.isNotEmpty) {
try {
user = (jsonDecode(rawUser) as Map).cast<String, dynamic>();
} catch (_) {
user = const {};
}
}
return AuthSession(
accessToken: accessToken,
refreshToken: refreshToken,
user: AuthUser.fromJson(user),
);
}
Future<void> save(AuthSession session) async {
final prefs = SharedPreferencesAsync();
await prefs.setString(_kAccessToken, session.accessToken);
await prefs.setString(_kRefreshToken, session.refreshToken);
await prefs.setString(_kUser, jsonEncode(session.user.toJson()));
}
Future<void> clear() async {
final prefs = SharedPreferencesAsync();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUser);
// 设备号不清:它代表"这台设备",与登录态无关
}
}
+44 -5
View File
@@ -1,28 +1,67 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../repositories/catalog_repository.dart';
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../repositories/catalog_repository.dart';
import '../remote/api_client.dart';
/// 目录状态:首页/发现页聚合数据 + 详情按需加载。
///
/// 列表卡片不含剧集,只有进详情或播放时才取一次详情,
/// 这样首页接口能保持很小的响应体。
class CatalogController extends ChangeNotifier {
CatalogController(this.repository);
final CatalogRepository repository;
List<Drama> dramas = const [];
HomeFeed feed = const HomeFeed();
bool loading = false;
String? error;
List<Drama> get dramas => feed.dramas;
List<FeedBanner> get banners => feed.banners;
List<FeedSection> get sections => feed.sections;
Future<void> load() async {
loading = true;
error = null;
notifyListeners();
try {
dramas = await repository.fetchDramas();
feed = await repository.fetchHomeFeed();
} on ApiException catch (exception) {
error = exception.message;
} catch (_) {
error = 'The catalog could not be loaded.';
error = 'catalogError';
} finally {
loading = false;
notifyListeners();
}
}
/// 需要完整剧集时补一次详情;已加载过则直接返回,避免重复请求。
Future<Drama> requireDetail(Drama drama) async {
if (drama.detailLoaded) return drama;
final detail = await repository.fetchDrama(drama.id);
_replace(detail);
return detail;
}
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();
}
}
-64
View File
@@ -1,64 +0,0 @@
// Author: fruit.com.cn
import 'dart:async';
import 'api_client.dart';
/// 核心埋点事件名(第一阶段口径,后续与运营对齐后可扩展)。
class AppEvents {
AppEvents._();
static const String playbackStart = 'playback_start';
static const String playbackEnd = 'playback_end';
static const String paySuccess = 'pay_success';
static const String register = 'register';
static const String click = 'click';
}
/// 埋点上报:本地缓冲、批量上报,失败静默丢弃不影响主流程。
class EventTracker {
EventTracker(this._api);
final ApiClient _api;
static const int _flushSize = 10;
static const Duration _flushInterval = Duration(seconds: 10);
final List<Map<String, Object?>> _buffer = [];
Timer? _timer;
void track(
String eventName, {
String? page,
Map<String, Object?>? properties,
}) {
_buffer.add({
'eventName': eventName,
'eventTime': DateTime.now().toIso8601String(),
'page': page,
'properties': properties,
});
if (_buffer.length >= _flushSize) {
flush();
} else {
_timer ??= Timer(_flushInterval, flush);
}
}
Future<void> flush() async {
if (_buffer.isEmpty) return;
final events = List<Map<String, Object?>>.from(_buffer);
_buffer.clear();
_timer?.cancel();
_timer = null;
try {
await _api.post('/app/events', data: {
'deviceId': 'device-0001',
'sessionId': 'session-0001',
'events': events,
});
} catch (_) {
// 埋点失败不影响主流程,静默丢弃。
}
}
}
@@ -0,0 +1,128 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../repositories/favorite_repository.dart';
/// 收藏状态:**以服务端为准**。
///
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
/// "点了没反应"或"看起来收藏了其实没有"。
class FavoriteController extends ChangeNotifier {
FavoriteController(this._repository);
final FavoriteRepository _repository;
final Set<String> _saved = {};
List<Drama> cards = const [];
bool loading = false;
String? error;
bool _disposed = false;
Set<String> get savedIds => Set.unmodifiable(_saved);
bool isSaved(String dramaId) => _saved.contains(dramaId);
void notify() {
if (!_disposed) notifyListeners();
}
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
Future<void> load() async {
loading = true;
error = null;
notify();
try {
final list = await _repository.list();
cards = list;
_saved
..clear()
..addAll(list.map((drama) => drama.id));
} catch (_) {
error = 'favoritesError';
} finally {
loading = false;
notify();
}
}
/// 未登录时不要调用(服务端要求登录态)。
///
/// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
Future<bool> toggle(String dramaId, {Drama? card}) async {
final wasSaved = _saved.contains(dramaId);
// 乐观更新
if (wasSaved) {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
} else {
_saved.add(dramaId);
}
notify();
try {
final saved = await _repository.toggle(dramaId);
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
if (saved) {
_saved.add(dramaId);
if (card != null && !cards.any((drama) => drama.id == dramaId)) {
cards = [card, ...cards];
}
} else {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
}
notify();
return true;
} catch (_) {
// 回滚
if (wasSaved) {
_saved.add(dramaId);
} else {
_saved.remove(dramaId);
}
notify();
return false;
}
}
/// 取消收藏("我的"页列表里删除某条)。
Future<bool> remove(String dramaId) async {
final removed = cards.firstWhere(
(drama) => drama.id == dramaId,
orElse: () => const Drama(
id: '',
title: '',
synopsis: '',
posterUrl: '',
),
);
final hadCard = removed.id.isNotEmpty;
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
notify();
try {
await _repository.remove(dramaId);
return true;
} catch (_) {
if (hadCard) cards = [...cards, removed];
_saved.add(dramaId);
notify();
return false;
}
}
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
void clear() {
_saved.clear();
cards = const [];
error = null;
notify();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+13 -1
View File
@@ -130,6 +130,9 @@ class PlaybackSession extends ChangeNotifier {
if (!slot.retiring && !_closed) slot.prepared = true;
} catch (error) {
slot.failure = error;
if (kDebugMode) {
debugPrint('片源准备失败 ${episodes[index].videoUrl}: $error');
}
}
}();
return slot;
@@ -151,14 +154,23 @@ class PlaybackSession extends ChangeNotifier {
/// use exactly the same mechanism without warming an unrelated episode.
Future<void> select(int index, {int? preloadIndex}) async {
if (_closed || index < 0 || index >= episodes.length) return;
// 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。
// 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。
if (!_selected || index != _currentIndex) {
_userWantsPlay = true;
}
final request = ++_generation;
_selected = true;
_currentIndex = index;
_nextIndex = preloadIndex;
_error = null;
if (!_visible) {
_loading = false;
_notify();
return;
}
_loading = preparedPlayerAt(index) == null && !isLocked;
_notify();
if (!_visible) return;
final acquired = await _coordinator._acquire(
this,
() => _visible && request == _generation,
-34
View File
@@ -1,34 +0,0 @@
// Author: fruit.com.cn
import 'package:shared_preferences/shared_preferences.dart';
/// 令牌本地存储。第一阶段用 shared_preferences,后续可换 secure storage。
class TokenStorage {
static const _accessKey = 'auth_access_token';
static const _refreshKey = 'auth_refresh_token';
Future<void> saveTokens({
required String access,
required String refresh,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accessKey, access);
await prefs.setString(_refreshKey, refresh);
}
Future<String?> readAccessToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_accessKey);
}
Future<String?> readRefreshToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_refreshKey);
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_accessKey);
await prefs.remove(_refreshKey);
}
}
+20 -5
View File
@@ -2,6 +2,7 @@
import 'package:video_player/video_player.dart';
import '../../config/app_config.dart';
import '../models/drama.dart';
import '../models/player_port.dart';
@@ -9,11 +10,25 @@ class VideoPlayerPort implements PlayerPort {
VideoPlayerPort(
Episode episode, {
Future<ClosedCaptionFile>? closedCaptionFile,
}) : controller = VideoPlayerController.asset(
episode.assetPath,
closedCaptionFile: closedCaptionFile,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false),
);
}) : controller = _open(episode, closedCaptionFile);
/// 后端下发的是相对路径(`/files/...`),交给播放器前补上服务地址。
/// 否则 Uri 没有 scheme/host,AVPlayer 和 ExoPlayer 都加载不了。
static Uri resolveUri(Episode episode) =>
Uri.parse(AppConfig.mediaUrl(episode.videoUrl));
static VideoPlayerController _open(
Episode episode,
Future<ClosedCaptionFile>? closedCaptionFile,
) {
final options = VideoPlayerOptions(mixWithOthers: false);
// 片源统一由后端下发(详情里的可播放地址 / 播放票据)
return VideoPlayerController.networkUrl(
resolveUri(episode),
closedCaptionFile: closedCaptionFile,
videoPlayerOptions: options,
);
}
final VideoPlayerController controller;
Future<void>? _initialization;
+16
View File
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
import '../../l10n/app_localizations.dart';
/// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与
/// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。
String errorText(AppLocalizations l10n, String? code, String fallback) {
if (code == null || code.isEmpty) return fallback;
return switch (code) {
'networkError' => l10n.networkError,
'progressError' => l10n.progressError,
'favoritesError' => l10n.favoritesError,
'ticketError' => l10n.playbackError,
final value => value,
};
}
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
/// Shared colors. Blue is the primary action color.
abstract final class AppColors {
static const blue = Color(0xFF2F7BFF);
static const blueSoft = Color(0xFF8FB4FF);
static const ink = Color(0xFF0B0C10);
static const bar = Color(0xFF101114);
static const card = Color(0xFF17191F);
static const muted = Color(0xFFB7BDC9);
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../pages/auth/auth_view.dart';
import '../data/services/auth_controller.dart';
import '../theme/app_colors.dart';
/// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库)
Future<void> openAuthPage(BuildContext context, {bool signUpMode = false}) {
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AuthPage(signUpMode: signUpMode),
),
);
}
/// 受限动作的登录闸:已登录返回 true;未登录弹底部提示并引导登录,返回 false。
///
/// 首页/发现/详情保持匿名可看,只有收藏、点赞、评论这类"要对到具体账号"的动作才走这里。
Future<bool> ensureSignedIn(BuildContext context) async {
final auth = context.read<AuthController>();
if (auth.isSignedIn) return true;
final l10n = AppLocalizations.of(context)!;
final go = await showModalBottomSheet<bool>(
context: context,
backgroundColor: AppColors.card,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
),
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.authGateTitle,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
l10n.authGateMessage,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(sheetContext).pop(false),
child: Text(l10n.cancel),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
),
onPressed: () => Navigator.of(sheetContext).pop(true),
child: Text(l10n.authGateAction),
),
),
],
),
],
),
),
),
);
if (go == true && context.mounted) {
await openAuthPage(context);
}
return false;
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../config/app_config.dart';
/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。
class RemoteImage extends StatelessWidget {
const RemoteImage(
this.path, {
super.key,
this.width,
this.height,
this.fit = BoxFit.cover,
});
final String path;
final double? width;
final double? height;
final BoxFit fit;
@override
Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _fallback();
return Image.network(
url,
width: width,
height: height,
fit: fit,
errorBuilder: (_, _, _) => _fallback(),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return _fallback();
},
);
}
Widget _fallback() {
return Container(
width: width,
height: height,
color: const Color(0xFF1C1F26),
);
}
}
+119 -10
View File
@@ -1,6 +1,6 @@
{
"@@locale": "en",
"appName": "Nontonki",
"appName": "FruitShorts",
"demo": "DEMO",
"homeHeadline": "Watch now",
"homeSubtitle": "Free short drama preview.",
@@ -34,7 +34,13 @@
"localDemoProfile": "Local demo profile",
"continueWatching": "Continue Watching",
"watchedPercent": "Watched {percent}%",
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
"@watchedPercent": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"resume": "Resume",
"history": "History",
"watchlist": "Watchlist",
@@ -72,16 +78,40 @@
"originalTag": "Original",
"dramaTag": "Short drama",
"episodeLabel": "Episode {number}",
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
"@episodeLabel": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"exploreSeries": "Explore series",
"episodeCount": "{count} EPISODES",
"@episodeCount": {"placeholders": {"count": {"type": "int"}}},
"@episodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"technicalDemoCount": "FREE PREVIEW · {count} EPISODES",
"@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}},
"@technicalDemoCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"episodes": "Episodes",
"watchEpisodeOne": "Watch episode 1",
"continueEpisode": "Continue episode {number}",
"@continueEpisode": {"placeholders": {"number": {"type": "int"}}},
"@continueEpisode": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"freeTestMedia": "Same demo clip · free",
"lockedStoreUnavailable": "Locked · store unavailable",
"freeTestClip": "Free test clip",
@@ -89,13 +119,30 @@
"freeSamplesNotice": "Two free samples · store setup pending",
"demoTestMedia": "FREE · SAME DEMO CLIP",
"episodeProgress": "EPISODE {number} / {total}",
"@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}},
"@episodeProgress": {
"placeholders": {
"number": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"testVideoNotice": "Self-generated technical test video",
"lockedTitle": "Episode {number} is locked",
"@lockedTitle": {"placeholders": {"number": {"type": "int"}}},
"@lockedTitle": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.",
"playbackError": "This clip could not be played.",
"catalogError": "The catalog could not be loaded.",
"detailError": "The episode list could not be loaded.",
"noMediaNotice": "This episode has no playable media yet.",
"noStories": "No stories available yet.",
"retry": "Retry",
"backToSeries": "Back to series",
@@ -129,6 +176,68 @@
"genreDramaMystery": "Drama · Mystery",
"continueWatchingHeading": "CONTINUE WATCHING",
"preferencesHeading": "PREFERENCES",
"brandMark": "NONTONKI · DEMO",
"shareFooter": "Nontonki demo"
"brandMark": "FRUITSHORTS",
"shareFooter": "FruitShorts",
"shortsTab": "Shorts",
"myListTab": "My List",
"watchFull": "Watch Full",
"swipeForNext": "Swipe up for next",
"clearScreen": "Clear screen",
"exitClearScreen": "Exit clear screen",
"logIn": "Log in",
"logInHint": "Keep your coins and list",
"rewards": "Rewards",
"forYou": "For You",
"hot": "Hot",
"newest": "New",
"favorite": "Favorite",
"myAccount": "My account",
"coins": "Coins",
"topUpWallet": "Top Up Wallet",
"vipMembership": "VIP Membership",
"notActive": "Not Active",
"joinVip": "Join VIP",
"earnRewards": "Earn Rewards",
"freeBonus": "Get Free Bonus",
"watchHistory": "Watch History",
"settings": "Settings",
"signOut": "Sign Out",
"mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE",
"mockNotice": "Sample data only.",
"topBadge": "TOP 1",
"signIn": "Sign In",
"signUp": "Sign Up",
"authSignInTitle": "Welcome back",
"authSignUpTitle": "Create your account",
"authSubtitle": "Sign in with your email and password.",
"authEmailLabel": "Email",
"authPasswordLabel": "Password",
"authPasswordHint": "At least 8 characters, letters and numbers",
"authEmailRequired": "Enter your email",
"authEmailInvalid": "That email doesn't look right",
"authPasswordRequired": "Enter your password",
"authPasswordLength": "Password must be 8-64 characters",
"authPasswordWeak": "Password needs both letters and numbers",
"authNetworkError": "Network error, please try again",
"authSwitchToSignUp": "New here? Create an account",
"authSwitchToSignIn": "Already have an account? Sign in",
"authGateTitle": "Sign in to continue",
"authGateMessage": "Favorites and comments need an account.",
"authGateAction": "Sign In",
"cancel": "Not now",
"noEpisodesYet": "No playable episodes yet.",
"networkError": "Network is unreachable. Please check your connection and retry.",
"progressError": "Your watch position could not be loaded.",
"favoritesError": "Your favorites could not be loaded.",
"favoriteToggleFailed": "Could not update favorites. Please retry.",
"remove": "Remove",
"unlockComingSoon": "Unlocking with coins is coming soon.",
"resumeProgress": "Resume at {position} / {total}",
"@resumeProgress": {
"placeholders": {
"position": {},
"total": {}
}
}
}
+243
View File
@@ -0,0 +1,243 @@
{
"@@locale": "id",
"appName": "FruitShorts",
"demo": "Demo",
"homeHeadline": "Tonton sekarang",
"homeSubtitle": "Pratinjau drama pendek gratis.",
"mediaNotice": "Video demo lokal dari pengguna · Pratinjau gratis",
"featuredDemo": "UNGGULAN · DEMO TEKNIS",
"demoTitle": "Sinyal",
"demoSynopsis": "Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.",
"homeTab": "Beranda",
"openQuickMenu": "Buka menu cepat",
"quickAccess": "Akses cepat",
"currentSeries": "Serial saat ini",
"discoverTab": "Jelajahi",
"searchDiscover": "Cari drama, aktor, genre...",
"featured": "Unggulan",
"suspense": "Thriller",
"romance": "Romantis",
"actionGenre": "Aksi",
"filter": "Filter",
"topCharts": "Peringkat Teratas",
"newReleases": "Rilisan Baru",
"trendingMicroDramas": "Drama Mini Populer",
"seeAll": "Lihat Semua",
"artworkOnly": "Gambar",
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
"videoPending": "Video belum siap",
"watchNow": "Tonton gratis",
"noSearchResults": "Tidak ada drama yang cocok.",
"demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.",
"myTab": "Saya",
"localViewer": "Penonton Lokal",
"localDemoProfile": "Profil demo lokal",
"continueWatching": "Lanjut Tonton",
"watchedPercent": "Sudah ditonton {percent}%",
"@watchedPercent": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"resume": "Lanjutkan",
"history": "Riwayat",
"watchlist": "Daftar Tonton",
"likedTab": "Disukai",
"all": "Semua",
"inProgress": "Sedang Berjalan",
"completed": "Selesai",
"noSavedShows": "Belum ada tontonan di sini.",
"preferences": "Preferensi",
"appLanguage": "Bahasa Aplikasi",
"followingTab": "Mengikuti",
"animatedTab": "Animasi",
"recommendedTab": "Rekomendasi",
"noCategoryDramas": "Belum ada drama di kategori ini.",
"trendingTab": "Sedang Tren",
"liveActionTab": "Live Action",
"search": "Cari",
"free": "Gratis",
"mute": "Bisukan",
"unmute": "Nyalakan suara",
"like": "Suka",
"save": "Simpan",
"comments": "Komentar",
"share": "Bagikan",
"localOnlyComments": "Hanya terlihat di perangkat ini",
"noComments": "Belum ada komentar.",
"writeComment": "Tulis komentar",
"postComment": "Kirim",
"shareFailed": "Tidak bisa membuka menu berbagi.",
"nowPlaying": "Sedang diputar",
"watchFullscreen": "Tonton mode lanskap",
"exitFullscreen": "Keluar dari layar penuh",
"pictureInPicture": "Gambar dalam gambar",
"pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini",
"originalTag": "Original",
"dramaTag": "Drama pendek",
"episodeLabel": "Episode {number}",
"@episodeLabel": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"exploreSeries": "Lihat serial",
"episodeCount": "{count} EPISODE",
"@episodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"technicalDemoCount": "PRATINJAU GRATIS · {count} EPISODE",
"@technicalDemoCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"episodes": "Episode",
"watchEpisodeOne": "Tonton episode 1",
"continueEpisode": "Lanjut episode {number}",
"@continueEpisode": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"freeTestMedia": "Klip demo sama · gratis",
"lockedStoreUnavailable": "Terkunci · toko belum tersedia",
"freeTestClip": "Klip uji gratis",
"locked": "Terkunci",
"freeSamplesNotice": "Dua sampel gratis · pengaturan toko belum selesai",
"demoTestMedia": "GRATIS · KLIP DEMO SAMA",
"episodeProgress": "EPISODE {number} / {total}",
"@episodeProgress": {
"placeholders": {
"number": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"testVideoNotice": "Video uji teknis buatan sendiri",
"lockedTitle": "Episode {number} terkunci",
"@lockedTitle": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"lockedBody": "Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.",
"playbackError": "Klip ini tidak bisa diputar.",
"catalogError": "Katalog gagal dimuat.",
"detailError": "Daftar episode gagal dimuat.",
"noMediaNotice": "Episode ini belum punya media yang bisa diputar.",
"noStories": "Belum ada cerita.",
"retry": "Coba lagi",
"backToSeries": "Kembali ke serial",
"nextEpisode": "Episode berikutnya",
"pause": "Jeda",
"play": "Putar",
"seekVideo": "Geser posisi video",
"seekFailed": "Gagal mengubah posisi putar.",
"captions": "Subtitle",
"captionsOff": "Subtitle nonaktif",
"englishCaptions": "Subtitle bahasa Inggris",
"chineseCaptions": "Subtitle bahasa Mandarin",
"language": "Bahasa",
"systemLanguage": "Sistem",
"english": "English",
"simplifiedChinese": "简体中文",
"episodeOneTitle": "Cahaya Pertama",
"episodeTwoTitle": "Frekuensi Baru",
"episodeThreeTitle": "Menyeberang",
"episodeFourTitle": "Setelah Gema",
"dramaSignalTitle": "Sinyal",
"dramaSignalSynopsis": "Serial demo untuk tontonan vertikal. Setiap episode memakai satu klip lokal yang sama.",
"dramaCityTitle": "Cahaya Kota & Malam Beludru",
"dramaCitySynopsis": "Serial romansa demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.",
"dramaVengeanceTitle": "Badai Pembalasan",
"dramaVengeanceSynopsis": "Serial aksi demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.",
"dramaEchoesTitle": "Gema Takdir",
"genreSuspenseMystery": "Thriller · Misteri",
"genreUrbanRomance": "Romansa Kota",
"genreActionThriller": "Aksi · Thriller",
"genreDramaMystery": "Drama · Misteri",
"continueWatchingHeading": "LANJUT TONTON",
"preferencesHeading": "PREFERENSI",
"brandMark": "FRUITSHORTS",
"shareFooter": "FruitShorts",
"shortsTab": "Drama Pendek",
"myListTab": "Daftar Saya",
"watchFull": "Tonton Penuh",
"swipeForNext": "Geser ke atas untuk lanjut",
"clearScreen": "Bersihkan layar",
"exitClearScreen": "Keluar dari mode bersih",
"logIn": "Masuk",
"logInHint": "Simpan koin dan daftar kamu",
"rewards": "Hadiah",
"forYou": "Untuk Kamu",
"hot": "Populer",
"newest": "Terbaru",
"favorite": "Favorit",
"myAccount": "Akun saya",
"coins": "Koin",
"topUpWallet": "Isi Ulang Dompet",
"vipMembership": "Keanggotaan VIP",
"notActive": "Tidak Aktif",
"joinVip": "Gabung VIP",
"earnRewards": "Dapatkan Hadiah",
"freeBonus": "Bonus Gratis",
"watchHistory": "Riwayat Tontonan",
"settings": "Pengaturan",
"signOut": "Keluar",
"mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE",
"mockNotice": "Hanya data contoh.",
"topBadge": "TOP 1",
"signIn": "Masuk",
"signUp": "Daftar",
"authSignInTitle": "Selamat datang kembali",
"authSignUpTitle": "Buat akun kamu",
"authSubtitle": "Masuk dengan email dan kata sandi.",
"authEmailLabel": "Email",
"authPasswordLabel": "Kata sandi",
"authPasswordHint": "Minimal 8 karakter, berisi huruf dan angka",
"authEmailRequired": "Masukkan email kamu",
"authEmailInvalid": "Email itu sepertinya tidak benar",
"authPasswordRequired": "Masukkan kata sandi kamu",
"authPasswordLength": "Kata sandi harus 8-64 karakter",
"authPasswordWeak": "Kata sandi perlu huruf dan angka",
"authNetworkError": "Jaringan bermasalah, coba lagi",
"authSwitchToSignUp": "Baru di sini? Buat akun",
"authSwitchToSignIn": "Sudah punya akun? Masuk",
"authGateTitle": "Masuk untuk melanjutkan",
"authGateMessage": "Favorit dan komentar perlu akun.",
"authGateAction": "Masuk",
"cancel": "Nanti saja",
"noEpisodesYet": "Belum ada episode yang bisa diputar.",
"networkError": "Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.",
"progressError": "Posisi tonton gagal dimuat.",
"favoritesError": "Daftar favorit gagal dimuat.",
"favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.",
"remove": "Hapus",
"unlockComingSoon": "Pembukaan dengan koin segera hadir.",
"resumeProgress": "Lanjut di {position} / {total}",
"@resumeProgress": {
"placeholders": {
"position": {},
"total": {}
}
}
}
+356 -4
View File
@@ -8,6 +8,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_id.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
@@ -97,13 +98,14 @@ abstract class AppLocalizations {
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('id'),
Locale('zh'),
];
/// No description provided for @appName.
///
/// In en, this message translates to:
/// **'Nontonki'**
/// **'FruitShorts'**
String get appName;
/// No description provided for @demo.
@@ -634,6 +636,18 @@ abstract class AppLocalizations {
/// **'The catalog could not be loaded.'**
String get catalogError;
/// No description provided for @detailError.
///
/// In en, this message translates to:
/// **'The episode list could not be loaded.'**
String get detailError;
/// No description provided for @noMediaNotice.
///
/// In en, this message translates to:
/// **'This episode has no playable media yet.'**
String get noMediaNotice;
/// No description provided for @noStories.
///
/// In en, this message translates to:
@@ -835,14 +849,350 @@ abstract class AppLocalizations {
/// No description provided for @brandMark.
///
/// In en, this message translates to:
/// **'NONTONKI · DEMO'**
/// **'FRUITSHORTS'**
String get brandMark;
/// No description provided for @shareFooter.
///
/// In en, this message translates to:
/// **'Nontonki demo'**
/// **'FruitShorts'**
String get shareFooter;
/// No description provided for @shortsTab.
///
/// In en, this message translates to:
/// **'Shorts'**
String get shortsTab;
/// No description provided for @myListTab.
///
/// In en, this message translates to:
/// **'My List'**
String get myListTab;
/// No description provided for @watchFull.
///
/// In en, this message translates to:
/// **'Watch Full'**
String get watchFull;
/// No description provided for @swipeForNext.
///
/// In en, this message translates to:
/// **'Swipe up for next'**
String get swipeForNext;
/// No description provided for @clearScreen.
///
/// In en, this message translates to:
/// **'Clear screen'**
String get clearScreen;
/// No description provided for @exitClearScreen.
///
/// In en, this message translates to:
/// **'Exit clear screen'**
String get exitClearScreen;
/// No description provided for @logIn.
///
/// In en, this message translates to:
/// **'Log in'**
String get logIn;
/// No description provided for @logInHint.
///
/// In en, this message translates to:
/// **'Keep your coins and list'**
String get logInHint;
/// No description provided for @rewards.
///
/// In en, this message translates to:
/// **'Rewards'**
String get rewards;
/// No description provided for @forYou.
///
/// In en, this message translates to:
/// **'For You'**
String get forYou;
/// No description provided for @hot.
///
/// In en, this message translates to:
/// **'Hot'**
String get hot;
/// No description provided for @newest.
///
/// In en, this message translates to:
/// **'New'**
String get newest;
/// No description provided for @favorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get favorite;
/// No description provided for @myAccount.
///
/// In en, this message translates to:
/// **'My account'**
String get myAccount;
/// No description provided for @coins.
///
/// In en, this message translates to:
/// **'Coins'**
String get coins;
/// No description provided for @topUpWallet.
///
/// In en, this message translates to:
/// **'Top Up Wallet'**
String get topUpWallet;
/// No description provided for @vipMembership.
///
/// In en, this message translates to:
/// **'VIP Membership'**
String get vipMembership;
/// No description provided for @notActive.
///
/// In en, this message translates to:
/// **'Not Active'**
String get notActive;
/// No description provided for @joinVip.
///
/// In en, this message translates to:
/// **'Join VIP'**
String get joinVip;
/// No description provided for @earnRewards.
///
/// In en, this message translates to:
/// **'Earn Rewards'**
String get earnRewards;
/// No description provided for @freeBonus.
///
/// In en, this message translates to:
/// **'Get Free Bonus'**
String get freeBonus;
/// No description provided for @watchHistory.
///
/// In en, this message translates to:
/// **'Watch History'**
String get watchHistory;
/// No description provided for @settings.
///
/// In en, this message translates to:
/// **'Settings'**
String get settings;
/// No description provided for @signOut.
///
/// In en, this message translates to:
/// **'Sign Out'**
String get signOut;
/// No description provided for @mockUserName.
///
/// In en, this message translates to:
/// **'Vicky sheng'**
String get mockUserName;
/// No description provided for @mockUserId.
///
/// In en, this message translates to:
/// **'ID: M1CZWVYE'**
String get mockUserId;
/// No description provided for @mockNotice.
///
/// In en, this message translates to:
/// **'Sample data only.'**
String get mockNotice;
/// No description provided for @topBadge.
///
/// In en, this message translates to:
/// **'TOP 1'**
String get topBadge;
/// No description provided for @signIn.
///
/// In en, this message translates to:
/// **'Sign In'**
String get signIn;
/// No description provided for @signUp.
///
/// In en, this message translates to:
/// **'Sign Up'**
String get signUp;
/// No description provided for @authSignInTitle.
///
/// In en, this message translates to:
/// **'Welcome back'**
String get authSignInTitle;
/// No description provided for @authSignUpTitle.
///
/// In en, this message translates to:
/// **'Create your account'**
String get authSignUpTitle;
/// No description provided for @authSubtitle.
///
/// In en, this message translates to:
/// **'Sign in with your email and password.'**
String get authSubtitle;
/// No description provided for @authEmailLabel.
///
/// In en, this message translates to:
/// **'Email'**
String get authEmailLabel;
/// No description provided for @authPasswordLabel.
///
/// In en, this message translates to:
/// **'Password'**
String get authPasswordLabel;
/// No description provided for @authPasswordHint.
///
/// In en, this message translates to:
/// **'At least 8 characters, letters and numbers'**
String get authPasswordHint;
/// No description provided for @authEmailRequired.
///
/// In en, this message translates to:
/// **'Enter your email'**
String get authEmailRequired;
/// No description provided for @authEmailInvalid.
///
/// In en, this message translates to:
/// **'That email doesn\'t look right'**
String get authEmailInvalid;
/// No description provided for @authPasswordRequired.
///
/// In en, this message translates to:
/// **'Enter your password'**
String get authPasswordRequired;
/// No description provided for @authPasswordLength.
///
/// In en, this message translates to:
/// **'Password must be 8-64 characters'**
String get authPasswordLength;
/// No description provided for @authPasswordWeak.
///
/// In en, this message translates to:
/// **'Password needs both letters and numbers'**
String get authPasswordWeak;
/// No description provided for @authNetworkError.
///
/// In en, this message translates to:
/// **'Network error, please try again'**
String get authNetworkError;
/// No description provided for @authSwitchToSignUp.
///
/// In en, this message translates to:
/// **'New here? Create an account'**
String get authSwitchToSignUp;
/// No description provided for @authSwitchToSignIn.
///
/// In en, this message translates to:
/// **'Already have an account? Sign in'**
String get authSwitchToSignIn;
/// No description provided for @authGateTitle.
///
/// In en, this message translates to:
/// **'Sign in to continue'**
String get authGateTitle;
/// No description provided for @authGateMessage.
///
/// In en, this message translates to:
/// **'Favorites and comments need an account.'**
String get authGateMessage;
/// No description provided for @authGateAction.
///
/// In en, this message translates to:
/// **'Sign In'**
String get authGateAction;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Not now'**
String get cancel;
/// No description provided for @noEpisodesYet.
///
/// In en, this message translates to:
/// **'No playable episodes yet.'**
String get noEpisodesYet;
/// No description provided for @networkError.
///
/// In en, this message translates to:
/// **'Network is unreachable. Please check your connection and retry.'**
String get networkError;
/// No description provided for @progressError.
///
/// In en, this message translates to:
/// **'Your watch position could not be loaded.'**
String get progressError;
/// No description provided for @favoritesError.
///
/// In en, this message translates to:
/// **'Your favorites could not be loaded.'**
String get favoritesError;
/// No description provided for @favoriteToggleFailed.
///
/// In en, this message translates to:
/// **'Could not update favorites. Please retry.'**
String get favoriteToggleFailed;
/// No description provided for @remove.
///
/// In en, this message translates to:
/// **'Remove'**
String get remove;
/// No description provided for @unlockComingSoon.
///
/// In en, this message translates to:
/// **'Unlocking with coins is coming soon.'**
String get unlockComingSoon;
/// No description provided for @resumeProgress.
///
/// In en, this message translates to:
/// **'Resume at {position} / {total}'**
String resumeProgress(Object position, Object total);
}
class _AppLocalizationsDelegate
@@ -856,7 +1206,7 @@ class _AppLocalizationsDelegate
@override
bool isSupported(Locale locale) =>
<String>['en', 'zh'].contains(locale.languageCode);
<String>['en', 'id', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
@@ -867,6 +1217,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'id':
return AppLocalizationsId();
case 'zh':
return AppLocalizationsZh();
}
+181 -3
View File
@@ -11,7 +11,7 @@ class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appName => 'Nontonki';
String get appName => 'FruitShorts';
@override
String get demo => 'DEMO';
@@ -296,6 +296,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get catalogError => 'The catalog could not be loaded.';
@override
String get detailError => 'The episode list could not be loaded.';
@override
String get noMediaNotice => 'This episode has no playable media yet.';
@override
String get noStories => 'No stories available yet.';
@@ -399,8 +405,180 @@ class AppLocalizationsEn extends AppLocalizations {
String get preferencesHeading => 'PREFERENCES';
@override
String get brandMark => 'NONTONKI · DEMO';
String get brandMark => 'FRUITSHORTS';
@override
String get shareFooter => 'Nontonki demo';
String get shareFooter => 'FruitShorts';
@override
String get shortsTab => 'Shorts';
@override
String get myListTab => 'My List';
@override
String get watchFull => 'Watch Full';
@override
String get swipeForNext => 'Swipe up for next';
@override
String get clearScreen => 'Clear screen';
@override
String get exitClearScreen => 'Exit clear screen';
@override
String get logIn => 'Log in';
@override
String get logInHint => 'Keep your coins and list';
@override
String get rewards => 'Rewards';
@override
String get forYou => 'For You';
@override
String get hot => 'Hot';
@override
String get newest => 'New';
@override
String get favorite => 'Favorite';
@override
String get myAccount => 'My account';
@override
String get coins => 'Coins';
@override
String get topUpWallet => 'Top Up Wallet';
@override
String get vipMembership => 'VIP Membership';
@override
String get notActive => 'Not Active';
@override
String get joinVip => 'Join VIP';
@override
String get earnRewards => 'Earn Rewards';
@override
String get freeBonus => 'Get Free Bonus';
@override
String get watchHistory => 'Watch History';
@override
String get settings => 'Settings';
@override
String get signOut => 'Sign Out';
@override
String get mockUserName => 'Vicky sheng';
@override
String get mockUserId => 'ID: M1CZWVYE';
@override
String get mockNotice => 'Sample data only.';
@override
String get topBadge => 'TOP 1';
@override
String get signIn => 'Sign In';
@override
String get signUp => 'Sign Up';
@override
String get authSignInTitle => 'Welcome back';
@override
String get authSignUpTitle => 'Create your account';
@override
String get authSubtitle => 'Sign in with your email and password.';
@override
String get authEmailLabel => 'Email';
@override
String get authPasswordLabel => 'Password';
@override
String get authPasswordHint => 'At least 8 characters, letters and numbers';
@override
String get authEmailRequired => 'Enter your email';
@override
String get authEmailInvalid => 'That email doesn\'t look right';
@override
String get authPasswordRequired => 'Enter your password';
@override
String get authPasswordLength => 'Password must be 8-64 characters';
@override
String get authPasswordWeak => 'Password needs both letters and numbers';
@override
String get authNetworkError => 'Network error, please try again';
@override
String get authSwitchToSignUp => 'New here? Create an account';
@override
String get authSwitchToSignIn => 'Already have an account? Sign in';
@override
String get authGateTitle => 'Sign in to continue';
@override
String get authGateMessage => 'Favorites and comments need an account.';
@override
String get authGateAction => 'Sign In';
@override
String get cancel => 'Not now';
@override
String get noEpisodesYet => 'No playable episodes yet.';
@override
String get networkError =>
'Network is unreachable. Please check your connection and retry.';
@override
String get progressError => 'Your watch position could not be loaded.';
@override
String get favoritesError => 'Your favorites could not be loaded.';
@override
String get favoriteToggleFailed =>
'Could not update favorites. Please retry.';
@override
String get remove => 'Remove';
@override
String get unlockComingSoon => 'Unlocking with coins is coming soon.';
@override
String resumeProgress(Object position, Object total) {
return 'Resume at $position / $total';
}
}
+583
View File
@@ -0,0 +1,583 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Indonesian (`id`).
class AppLocalizationsId extends AppLocalizations {
AppLocalizationsId([String locale = 'id']) : super(locale);
@override
String get appName => 'FruitShorts';
@override
String get demo => 'Demo';
@override
String get homeHeadline => 'Tonton sekarang';
@override
String get homeSubtitle => 'Pratinjau drama pendek gratis.';
@override
String get mediaNotice => 'Video demo lokal dari pengguna · Pratinjau gratis';
@override
String get featuredDemo => 'UNGGULAN · DEMO TEKNIS';
@override
String get demoTitle => 'Sinyal';
@override
String get demoSynopsis =>
'Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.';
@override
String get homeTab => 'Beranda';
@override
String get openQuickMenu => 'Buka menu cepat';
@override
String get quickAccess => 'Akses cepat';
@override
String get currentSeries => 'Serial saat ini';
@override
String get discoverTab => 'Jelajahi';
@override
String get searchDiscover => 'Cari drama, aktor, genre...';
@override
String get featured => 'Unggulan';
@override
String get suspense => 'Thriller';
@override
String get romance => 'Romantis';
@override
String get actionGenre => 'Aksi';
@override
String get filter => 'Filter';
@override
String get topCharts => 'Peringkat Teratas';
@override
String get newReleases => 'Rilisan Baru';
@override
String get trendingMicroDramas => 'Drama Mini Populer';
@override
String get seeAll => 'Lihat Semua';
@override
String get artworkOnly => 'Gambar';
@override
String get artworkOnlyNotice =>
'Ini hanya gambar katalog. Videonya belum tersedia.';
@override
String get videoPending => 'Video belum siap';
@override
String get watchNow => 'Tonton gratis';
@override
String get noSearchResults => 'Tidak ada drama yang cocok.';
@override
String get demoCatalogNotice =>
'Tiga drama demo memakai video lokal yang sama.';
@override
String get myTab => 'Saya';
@override
String get localViewer => 'Penonton Lokal';
@override
String get localDemoProfile => 'Profil demo lokal';
@override
String get continueWatching => 'Lanjut Tonton';
@override
String watchedPercent(int percent) {
return 'Sudah ditonton $percent%';
}
@override
String get resume => 'Lanjutkan';
@override
String get history => 'Riwayat';
@override
String get watchlist => 'Daftar Tonton';
@override
String get likedTab => 'Disukai';
@override
String get all => 'Semua';
@override
String get inProgress => 'Sedang Berjalan';
@override
String get completed => 'Selesai';
@override
String get noSavedShows => 'Belum ada tontonan di sini.';
@override
String get preferences => 'Preferensi';
@override
String get appLanguage => 'Bahasa Aplikasi';
@override
String get followingTab => 'Mengikuti';
@override
String get animatedTab => 'Animasi';
@override
String get recommendedTab => 'Rekomendasi';
@override
String get noCategoryDramas => 'Belum ada drama di kategori ini.';
@override
String get trendingTab => 'Sedang Tren';
@override
String get liveActionTab => 'Live Action';
@override
String get search => 'Cari';
@override
String get free => 'Gratis';
@override
String get mute => 'Bisukan';
@override
String get unmute => 'Nyalakan suara';
@override
String get like => 'Suka';
@override
String get save => 'Simpan';
@override
String get comments => 'Komentar';
@override
String get share => 'Bagikan';
@override
String get localOnlyComments => 'Hanya terlihat di perangkat ini';
@override
String get noComments => 'Belum ada komentar.';
@override
String get writeComment => 'Tulis komentar';
@override
String get postComment => 'Kirim';
@override
String get shareFailed => 'Tidak bisa membuka menu berbagi.';
@override
String get nowPlaying => 'Sedang diputar';
@override
String get watchFullscreen => 'Tonton mode lanskap';
@override
String get exitFullscreen => 'Keluar dari layar penuh';
@override
String get pictureInPicture => 'Gambar dalam gambar';
@override
String get pipUnavailable =>
'Gambar dalam gambar tidak tersedia di perangkat ini';
@override
String get originalTag => 'Original';
@override
String get dramaTag => 'Drama pendek';
@override
String episodeLabel(int number) {
return 'Episode $number';
}
@override
String get exploreSeries => 'Lihat serial';
@override
String episodeCount(int count) {
return '$count EPISODE';
}
@override
String technicalDemoCount(int count) {
return 'PRATINJAU GRATIS · $count EPISODE';
}
@override
String get episodes => 'Episode';
@override
String get watchEpisodeOne => 'Tonton episode 1';
@override
String continueEpisode(int number) {
return 'Lanjut episode $number';
}
@override
String get freeTestMedia => 'Klip demo sama · gratis';
@override
String get lockedStoreUnavailable => 'Terkunci · toko belum tersedia';
@override
String get freeTestClip => 'Klip uji gratis';
@override
String get locked => 'Terkunci';
@override
String get freeSamplesNotice =>
'Dua sampel gratis · pengaturan toko belum selesai';
@override
String get demoTestMedia => 'GRATIS · KLIP DEMO SAMA';
@override
String episodeProgress(int number, int total) {
return 'EPISODE $number / $total';
}
@override
String get testVideoNotice => 'Video uji teknis buatan sendiri';
@override
String lockedTitle(int number) {
return 'Episode $number terkunci';
}
@override
String get lockedBody =>
'Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.';
@override
String get playbackError => 'Klip ini tidak bisa diputar.';
@override
String get catalogError => 'Katalog gagal dimuat.';
@override
String get detailError => 'Daftar episode gagal dimuat.';
@override
String get noMediaNotice =>
'Episode ini belum punya media yang bisa diputar.';
@override
String get noStories => 'Belum ada cerita.';
@override
String get retry => 'Coba lagi';
@override
String get backToSeries => 'Kembali ke serial';
@override
String get nextEpisode => 'Episode berikutnya';
@override
String get pause => 'Jeda';
@override
String get play => 'Putar';
@override
String get seekVideo => 'Geser posisi video';
@override
String get seekFailed => 'Gagal mengubah posisi putar.';
@override
String get captions => 'Subtitle';
@override
String get captionsOff => 'Subtitle nonaktif';
@override
String get englishCaptions => 'Subtitle bahasa Inggris';
@override
String get chineseCaptions => 'Subtitle bahasa Mandarin';
@override
String get language => 'Bahasa';
@override
String get systemLanguage => 'Sistem';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get episodeOneTitle => 'Cahaya Pertama';
@override
String get episodeTwoTitle => 'Frekuensi Baru';
@override
String get episodeThreeTitle => 'Menyeberang';
@override
String get episodeFourTitle => 'Setelah Gema';
@override
String get dramaSignalTitle => 'Sinyal';
@override
String get dramaSignalSynopsis =>
'Serial demo untuk tontonan vertikal. Setiap episode memakai satu klip lokal yang sama.';
@override
String get dramaCityTitle => 'Cahaya Kota & Malam Beludru';
@override
String get dramaCitySynopsis =>
'Serial romansa demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.';
@override
String get dramaVengeanceTitle => 'Badai Pembalasan';
@override
String get dramaVengeanceSynopsis =>
'Serial aksi demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.';
@override
String get dramaEchoesTitle => 'Gema Takdir';
@override
String get genreSuspenseMystery => 'Thriller · Misteri';
@override
String get genreUrbanRomance => 'Romansa Kota';
@override
String get genreActionThriller => 'Aksi · Thriller';
@override
String get genreDramaMystery => 'Drama · Misteri';
@override
String get continueWatchingHeading => 'LANJUT TONTON';
@override
String get preferencesHeading => 'PREFERENSI';
@override
String get brandMark => 'FRUITSHORTS';
@override
String get shareFooter => 'FruitShorts';
@override
String get shortsTab => 'Drama Pendek';
@override
String get myListTab => 'Daftar Saya';
@override
String get watchFull => 'Tonton Penuh';
@override
String get swipeForNext => 'Geser ke atas untuk lanjut';
@override
String get clearScreen => 'Bersihkan layar';
@override
String get exitClearScreen => 'Keluar dari mode bersih';
@override
String get logIn => 'Masuk';
@override
String get logInHint => 'Simpan koin dan daftar kamu';
@override
String get rewards => 'Hadiah';
@override
String get forYou => 'Untuk Kamu';
@override
String get hot => 'Populer';
@override
String get newest => 'Terbaru';
@override
String get favorite => 'Favorit';
@override
String get myAccount => 'Akun saya';
@override
String get coins => 'Koin';
@override
String get topUpWallet => 'Isi Ulang Dompet';
@override
String get vipMembership => 'Keanggotaan VIP';
@override
String get notActive => 'Tidak Aktif';
@override
String get joinVip => 'Gabung VIP';
@override
String get earnRewards => 'Dapatkan Hadiah';
@override
String get freeBonus => 'Bonus Gratis';
@override
String get watchHistory => 'Riwayat Tontonan';
@override
String get settings => 'Pengaturan';
@override
String get signOut => 'Keluar';
@override
String get mockUserName => 'Vicky sheng';
@override
String get mockUserId => 'ID: M1CZWVYE';
@override
String get mockNotice => 'Hanya data contoh.';
@override
String get topBadge => 'TOP 1';
@override
String get signIn => 'Masuk';
@override
String get signUp => 'Daftar';
@override
String get authSignInTitle => 'Selamat datang kembali';
@override
String get authSignUpTitle => 'Buat akun kamu';
@override
String get authSubtitle => 'Masuk dengan email dan kata sandi.';
@override
String get authEmailLabel => 'Email';
@override
String get authPasswordLabel => 'Kata sandi';
@override
String get authPasswordHint => 'Minimal 8 karakter, berisi huruf dan angka';
@override
String get authEmailRequired => 'Masukkan email kamu';
@override
String get authEmailInvalid => 'Email itu sepertinya tidak benar';
@override
String get authPasswordRequired => 'Masukkan kata sandi kamu';
@override
String get authPasswordLength => 'Kata sandi harus 8-64 karakter';
@override
String get authPasswordWeak => 'Kata sandi perlu huruf dan angka';
@override
String get authNetworkError => 'Jaringan bermasalah, coba lagi';
@override
String get authSwitchToSignUp => 'Baru di sini? Buat akun';
@override
String get authSwitchToSignIn => 'Sudah punya akun? Masuk';
@override
String get authGateTitle => 'Masuk untuk melanjutkan';
@override
String get authGateMessage => 'Favorit dan komentar perlu akun.';
@override
String get authGateAction => 'Masuk';
@override
String get cancel => 'Nanti saja';
@override
String get noEpisodesYet => 'Belum ada episode yang bisa diputar.';
@override
String get networkError =>
'Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.';
@override
String get progressError => 'Posisi tonton gagal dimuat.';
@override
String get favoritesError => 'Daftar favorit gagal dimuat.';
@override
String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.';
@override
String get remove => 'Hapus';
@override
String get unlockComingSoon => 'Pembukaan dengan koin segera hadir.';
@override
String resumeProgress(Object position, Object total) {
return 'Lanjut di $position / $total';
}
}
+179 -3
View File
@@ -11,7 +11,7 @@ class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appName => 'Nontonki';
String get appName => 'FruitShorts';
@override
String get demo => '演示';
@@ -291,6 +291,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get catalogError => '剧目列表加载失败。';
@override
String get detailError => '剧集信息加载失败。';
@override
String get noMediaNotice => '该剧集还没有可播放的片源。';
@override
String get noStories => '暂无剧目。';
@@ -391,8 +397,178 @@ class AppLocalizationsZh extends AppLocalizations {
String get preferencesHeading => '偏好设置';
@override
String get brandMark => 'NONTONKI · 演示';
String get brandMark => 'FRUITSHORTS';
@override
String get shareFooter => 'Nontonki 演示';
String get shareFooter => 'FruitShorts';
@override
String get shortsTab => '短剧';
@override
String get myListTab => '片单';
@override
String get watchFull => '看全集';
@override
String get swipeForNext => '上滑看下一部';
@override
String get clearScreen => '清屏';
@override
String get exitClearScreen => '退出清屏';
@override
String get logIn => '登录';
@override
String get logInHint => '登录后保存金币和片单';
@override
String get rewards => '奖励';
@override
String get forYou => '为你推荐';
@override
String get hot => '热门';
@override
String get newest => '最新';
@override
String get favorite => '收藏';
@override
String get myAccount => '我的账户';
@override
String get coins => '金币';
@override
String get topUpWallet => '充值';
@override
String get vipMembership => '会员';
@override
String get notActive => '未开通';
@override
String get joinVip => '开通会员';
@override
String get earnRewards => '赚奖励';
@override
String get freeBonus => '领免费奖励';
@override
String get watchHistory => '观看历史';
@override
String get settings => '设置';
@override
String get signOut => '退出登录';
@override
String get mockUserName => 'Vicky sheng';
@override
String get mockUserId => 'ID: M1CZWVYE';
@override
String get mockNotice => '当前是示例数据。';
@override
String get topBadge => 'TOP 1';
@override
String get signIn => '登录';
@override
String get signUp => '注册';
@override
String get authSignInTitle => '欢迎回来';
@override
String get authSignUpTitle => '创建账号';
@override
String get authSubtitle => '用邮箱和密码登录。';
@override
String get authEmailLabel => '邮箱';
@override
String get authPasswordLabel => '密码';
@override
String get authPasswordHint => '至少 8 位,需包含字母和数字';
@override
String get authEmailRequired => '请输入邮箱';
@override
String get authEmailInvalid => '邮箱格式不正确';
@override
String get authPasswordRequired => '请输入密码';
@override
String get authPasswordLength => '密码长度需在 8-64 位之间';
@override
String get authPasswordWeak => '密码需同时包含字母和数字';
@override
String get authNetworkError => '网络异常,请稍后重试';
@override
String get authSwitchToSignUp => '还没有账号?去注册';
@override
String get authSwitchToSignIn => '已有账号?去登录';
@override
String get authGateTitle => '登录后可继续';
@override
String get authGateMessage => '收藏、评论等操作需要登录。';
@override
String get authGateAction => '去登录';
@override
String get cancel => '暂不';
@override
String get noEpisodesYet => '还没有可播放的剧集。';
@override
String get networkError => '网络异常,请检查网络后重试。';
@override
String get progressError => '续看进度加载失败。';
@override
String get favoritesError => '收藏列表加载失败。';
@override
String get favoriteToggleFailed => '收藏操作失败,请重试。';
@override
String get remove => '移除';
@override
String get unlockComingSoon => '金币解锁功能即将开放。';
@override
String resumeProgress(Object position, Object total) {
return '续看至 $position / $total';
}
}
+81 -5
View File
@@ -1,6 +1,6 @@
{
"@@locale": "zh",
"appName": "Nontonki",
"appName": "FruitShorts",
"demo": "演示",
"homeHeadline": "即刻观看",
"homeSubtitle": "免费短剧预览。",
@@ -34,7 +34,13 @@
"localDemoProfile": "本地演示档案",
"continueWatching": "继续观看",
"watchedPercent": "已观看 {percent}%",
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
"@watchedPercent": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"resume": "继续播放",
"history": "历史",
"watchlist": "追剧",
@@ -72,7 +78,13 @@
"originalTag": "原创",
"dramaTag": "短剧",
"episodeLabel": "第 {number} 集",
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
"@episodeLabel": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"exploreSeries": "查看剧集",
"episodeCount": "{count} 集",
"technicalDemoCount": "免费试看 · {count} 集",
@@ -91,6 +103,8 @@
"lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。",
"playbackError": "此视频暂时无法播放。",
"catalogError": "剧目列表加载失败。",
"detailError": "剧集信息加载失败。",
"noMediaNotice": "该剧集还没有可播放的片源。",
"noStories": "暂无剧目。",
"retry": "重试",
"backToSeries": "返回剧集详情",
@@ -124,6 +138,68 @@
"genreDramaMystery": "剧情 · 悬疑",
"continueWatchingHeading": "继续观看",
"preferencesHeading": "偏好设置",
"brandMark": "NONTONKI · 演示",
"shareFooter": "Nontonki 演示"
"brandMark": "FRUITSHORTS",
"shareFooter": "FruitShorts",
"shortsTab": "短剧",
"myListTab": "片单",
"watchFull": "看全集",
"swipeForNext": "上滑看下一部",
"clearScreen": "清屏",
"exitClearScreen": "退出清屏",
"logIn": "登录",
"logInHint": "登录后保存金币和片单",
"rewards": "奖励",
"forYou": "为你推荐",
"hot": "热门",
"newest": "最新",
"favorite": "收藏",
"myAccount": "我的账户",
"coins": "金币",
"topUpWallet": "充值",
"vipMembership": "会员",
"notActive": "未开通",
"joinVip": "开通会员",
"earnRewards": "赚奖励",
"freeBonus": "领免费奖励",
"watchHistory": "观看历史",
"settings": "设置",
"signOut": "退出登录",
"mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE",
"mockNotice": "当前是示例数据。",
"topBadge": "TOP 1",
"signIn": "登录",
"signUp": "注册",
"authSignInTitle": "欢迎回来",
"authSignUpTitle": "创建账号",
"authSubtitle": "用邮箱和密码登录。",
"authEmailLabel": "邮箱",
"authPasswordLabel": "密码",
"authPasswordHint": "至少 8 位,需包含字母和数字",
"authEmailRequired": "请输入邮箱",
"authEmailInvalid": "邮箱格式不正确",
"authPasswordRequired": "请输入密码",
"authPasswordLength": "密码长度需在 8-64 位之间",
"authPasswordWeak": "密码需同时包含字母和数字",
"authNetworkError": "网络异常,请稍后重试",
"authSwitchToSignUp": "还没有账号?去注册",
"authSwitchToSignIn": "已有账号?去登录",
"authGateTitle": "登录后可继续",
"authGateMessage": "收藏、评论等操作需要登录。",
"authGateAction": "去登录",
"cancel": "暂不",
"noEpisodesYet": "还没有可播放的剧集。",
"networkError": "网络异常,请检查网络后重试。",
"progressError": "续看进度加载失败。",
"favoritesError": "收藏列表加载失败。",
"favoriteToggleFailed": "收藏操作失败,请重试。",
"remove": "移除",
"unlockComingSoon": "金币解锁功能即将开放。",
"resumeProgress": "续看至 {position} / {total}",
"@resumeProgress": {
"placeholders": {
"position": {},
"total": {}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import 'auth_logic.dart';
/// 与其它页保持一致的三层写法:binding 负责装配,logic 放状态与逻辑,view 只渲染。
class AuthBinding {
AuthLogic create({bool signUpMode = false}) => AuthLogic(signUpMode: signUpMode);
}
+50
View File
@@ -0,0 +1,50 @@
/// 登录 / 注册页的表单状态与提交逻辑。
///
/// 校验规则与后端一致(邮箱格式、密码 8-64 位且含字母数字),本地先拦一次是为了少一次网络往返、
/// 也让用户立刻看到哪个字段不合格;后端仍会再校验一遍(前端校验不作为安全边界)。
class AuthLogic {
bool signUpMode;
String email = '';
String password = '';
bool busy = false;
String? errorMessage;
AuthLogic({this.signUpMode = false, this.errorMessage});
void toggleMode() {
signUpMode = !signUpMode;
errorMessage = null;
}
/// 返回 null 表示校验通过,否则返回错误文案(由视图用 l10n 映射)
AuthFieldError? validate() {
final mail = email.trim();
if (mail.isEmpty) return AuthFieldError.emailRequired;
if (!_emailPattern.hasMatch(mail)) return AuthFieldError.emailInvalid;
if (password.isEmpty) return AuthFieldError.passwordRequired;
if (password.length < 8 || password.length > 64) {
return AuthFieldError.passwordLength;
}
if (!_hasLetter.hasMatch(password) || !_hasDigit.hasMatch(password)) {
return AuthFieldError.passwordWeak;
}
return null;
}
String get normalizedEmail => email.trim().toLowerCase();
static final _emailPattern = RegExp(
r'^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?'
r'(\.[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,}$',
);
static final _hasLetter = RegExp('[A-Za-z]');
static final _hasDigit = RegExp('[0-9]');
}
enum AuthFieldError {
emailRequired,
emailInvalid,
passwordRequired,
passwordLength,
passwordWeak,
}
+227
View File
@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/remote/api_client.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
import 'auth_logic.dart';
/// 登录 / 注册页:邮箱 + 密码。
///
/// 风格沿用 FruitShorts 现有页面(深色卡片 + 圆角 + 主色蓝),不另起一套视觉。
class AuthPage extends StatefulWidget {
const AuthPage({super.key, this.signUpMode = false});
final bool signUpMode;
@override
State<AuthPage> createState() => _AuthPageState();
}
class _AuthPageState extends State<AuthPage> {
late AuthLogic _logic;
@override
void initState() {
super.initState();
_logic = AuthLogic(signUpMode: widget.signUpMode);
}
String _errorText(AppLocalizations l10n, AuthFieldError error) {
switch (error) {
case AuthFieldError.emailRequired:
return l10n.authEmailRequired;
case AuthFieldError.emailInvalid:
return l10n.authEmailInvalid;
case AuthFieldError.passwordRequired:
return l10n.authPasswordRequired;
case AuthFieldError.passwordLength:
return l10n.authPasswordLength;
case AuthFieldError.passwordWeak:
return l10n.authPasswordWeak;
}
}
Future<void> _submit() async {
final l10n = AppLocalizations.of(context)!;
final invalid = _logic.validate();
if (invalid != null) {
setState(() => _logic.errorMessage = _errorText(l10n, invalid));
return;
}
setState(() {
_logic.busy = true;
_logic.errorMessage = null;
});
final auth = context.read<AuthController>();
try {
if (_logic.signUpMode) {
await auth.register(
email: _logic.normalizedEmail,
password: _logic.password,
);
} else {
await auth.signIn(
email: _logic.normalizedEmail,
password: _logic.password,
);
}
if (!mounted) return;
Navigator.of(context).maybePop();
} on ApiException catch (error) {
// 后端文案已经是可直接展示的中文/印尼语提示,错误码不外露
setState(() => _logic.errorMessage = error.message);
} catch (_) {
setState(() => _logic.errorMessage = l10n.authNetworkError);
} finally {
if (mounted) setState(() => _logic.busy = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
elevation: 0,
foregroundColor: Colors.white,
title: Text(_logic.signUpMode ? l10n.signUp : l10n.signIn),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
children: [
Text(
_logic.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
l10n.authSubtitle,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 22),
_field(
label: l10n.authEmailLabel,
hint: 'name@example.com',
keyboardType: TextInputType.emailAddress,
onChanged: (value) => _logic.email = value,
autofocus: true,
),
const SizedBox(height: 14),
_field(
label: l10n.authPasswordLabel,
hint: l10n.authPasswordHint,
obscure: true,
onChanged: (value) => _logic.password = value,
),
if (_logic.errorMessage != null) ...[
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, color: Color(0xFFFF6B6B), size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
_logic.errorMessage!,
style: const TextStyle(color: Color(0xFFFF6B6B), fontSize: 13),
),
),
],
),
],
const SizedBox(height: 22),
SizedBox(
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
onPressed: _logic.busy ? null : _submit,
child: _logic.busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
_logic.signUpMode ? l10n.signUp : l10n.signIn,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(height: 14),
TextButton(
onPressed: _logic.busy
? null
: () => setState(() => _logic.toggleMode()),
child: Text(
_logic.signUpMode ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp,
style: const TextStyle(color: AppColors.blueSoft, fontSize: 13),
),
),
],
),
),
);
}
Widget _field({
required String label,
required String hint,
required ValueChanged<String> onChanged,
bool obscure = false,
bool autofocus = false,
TextInputType? keyboardType,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
TextField(
autofocus: autofocus,
obscureText: obscure,
keyboardType: keyboardType,
onChanged: onChanged,
style: const TextStyle(color: Colors.white, fontSize: 15),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Color(0xFF6C7280), fontSize: 14),
filled: true,
fillColor: AppColors.card,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
],
);
}
}
+2 -2
View File
@@ -7,9 +7,9 @@ import 'discover_logic.dart';
class DiscoverBinding {
DiscoverLogic create({
required Drama drama,
WatchHistoryRepository? history,
required WatchHistoryRepository history,
}) => DiscoverLogic(
drama: drama,
history: history ?? SharedPreferencesWatchHistoryRepository(),
history: history,
);
}
+20 -8
View File
@@ -20,6 +20,7 @@ class DiscoverLogic extends ChangeNotifier {
final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController();
int category = 0;
bool searchOpen = false;
bool _disposed = false;
void notify() {
@@ -27,11 +28,11 @@ class DiscoverLogic extends ChangeNotifier {
}
void refreshPosition() {
position = history.positionFor(
demoProfileId,
drama.id,
drama.episodes.first.id,
);
// 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看"
position = history
.resumePointFor(drama.id)
.then((point) => point?.position)
.catchError((Object _) => null);
}
void selectCategory(int index) {
@@ -41,6 +42,14 @@ class DiscoverLogic extends ChangeNotifier {
void onSearchChanged() => notify();
bool get showSearch => searchOpen || search.text.isNotEmpty;
void openSearch() {
searchOpen = true;
notify();
searchFocus.requestFocus();
}
void syncDrama({
required bool active,
required bool wasActive,
@@ -56,6 +65,8 @@ class DiscoverLogic extends ChangeNotifier {
notify();
}
if (active && searchRequest != previousSearchRequest) {
searchOpen = true;
notify();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isActive()) searchFocus.requestFocus();
});
@@ -115,8 +126,9 @@ class DiscoverLogic extends ChangeNotifier {
void showFilters(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final labels = [
l10n.featured,
l10n.suspense,
l10n.forYou,
l10n.hot,
l10n.newest,
l10n.romance,
l10n.actionGenre,
];
@@ -133,7 +145,7 @@ class DiscoverLogic extends ChangeNotifier {
ListTile(
title: Text(labels[index]),
trailing: index == category
? const Icon(Icons.check_rounded, color: Color(0xFFFF5B3D))
? const Icon(Icons.check_rounded, color: Color(0xFF2F7BFF))
: null,
onTap: () {
selectCategory(index);
+374 -454
View File
@@ -2,13 +2,18 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'discover_binding.dart';
import 'discover_logic.dart';
import 'widgets/cover_carousel.dart';
class DiscoverPage extends StatefulWidget {
const DiscoverPage({
@@ -20,6 +25,10 @@ class DiscoverPage extends StatefulWidget {
this.embedded = false,
this.active = true,
this.searchRequest = 0,
this.signedIn = false,
this.onSignIn,
this.onCoins,
this.onRewards,
this.onAction,
this.dramas,
this.onDramaSelected,
@@ -32,6 +41,10 @@ class DiscoverPage extends StatefulWidget {
final bool embedded;
final bool active;
final int searchRequest;
final bool signedIn;
final VoidCallback? onSignIn;
final VoidCallback? onCoins;
final VoidCallback? onRewards;
final ValueChanged<DiscoverPageAction>? onAction;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
@@ -48,8 +61,9 @@ class _DiscoverPageState extends State<DiscoverPage> {
super.initState();
logic = DiscoverBinding().create(
drama: widget.drama,
history: widget.history,
history: widget.history ?? context.read<WatchHistoryRepository>(),
);
if (widget.focusSearch) logic.searchOpen = true;
}
@override
@@ -82,50 +96,64 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cards = _cardsFor(l10n, logic.category, logic.search.text);
final catalog = context.watch<CatalogController>();
final allDramas = widget.dramas ?? [logic.drama];
final cards = _cardsFor(
allDramas,
catalog.sections,
logic.category,
'',
);
final content = SafeArea(
bottom: !widget.embedded,
child: Column(
children: [
_topBar(l10n),
_header(l10n),
Expanded(
child: ListView(
controller: logic.scroll,
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
padding: const EdgeInsets.only(bottom: 24),
children: [
_searchBar(l10n),
const SizedBox(height: 12),
_categories(l10n),
const SizedBox(height: 15),
_continueCard(l10n),
const SizedBox(height: 15),
_quickActions(l10n),
const SizedBox(height: 20),
_sectionTitle(l10n),
const SizedBox(height: 13),
if (cards.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
l10n.noSearchResults,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: cards.length,
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 14,
childAspectRatio: 0.59,
),
itemBuilder: (context, index) =>
_posterCard(l10n, cards[index]),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: _carousel(l10n, catalog.banners, allDramas),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_categories(l10n),
const SizedBox(height: 18),
_continueHeader(l10n),
const SizedBox(height: 12),
if (cards.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
l10n.noSearchResults,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
)
else
SizedBox(
height: 196,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (final card in cards) ...[
_continueTile(l10n, card),
const SizedBox(width: 12),
],
],
),
),
],
),
),
],
),
),
@@ -144,412 +172,233 @@ class _DiscoverPageState extends State<DiscoverPage> {
);
}
Widget _topBar(AppLocalizations l10n) => SizedBox(
height: 51,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
Widget _header(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
child: Row(
children: [
Container(
width: 29,
height: 29,
decoration: BoxDecoration(
color: _orange,
borderRadius: BorderRadius.circular(7),
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
),
const SizedBox(width: 7),
Text(
l10n.discoverTab,
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
),
const Spacer(),
IconButton(
tooltip: l10n.search,
onPressed: () => logic.searchFocus.requestFocus(),
icon: const Icon(Icons.search_rounded, size: 22),
),
const SizedBox(width: 4),
InkWell(
onTap: () => logic.act(
context,
DiscoverPageAction.my,
embedded: widget.embedded,
onAction: widget.onAction,
),
child: const CircleAvatar(
radius: 14,
backgroundColor: Color(0xFF34353B),
child: Icon(Icons.person_rounded, size: 19),
),
),
],
),
),
);
Widget _searchBar(AppLocalizations l10n) => Container(
height: 43,
decoration: BoxDecoration(
color: const Color(0xFF25252A),
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
const SizedBox(width: 11),
const Icon(Icons.search_rounded, color: Color(0xFFF2EEEF), size: 20),
const SizedBox(width: 7),
Expanded(
child: TextField(
controller: logic.search,
focusNode: logic.searchFocus,
autofocus: widget.focusSearch,
onChanged: (_) => logic.onSearchChanged(),
style: const TextStyle(fontSize: 12),
decoration: InputDecoration(
hintText: l10n.searchDiscover,
hintStyle: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
border: InputBorder.none,
isDense: true,
),
),
),
IconButton(
tooltip: l10n.filter,
onPressed: () => logic.showFilters(context),
icon: const Icon(Icons.tune_rounded, size: 20),
),
],
),
);
Widget _categories(AppLocalizations l10n) {
final labels = [
l10n.featured,
l10n.suspense,
l10n.romance,
l10n.actionGenre,
];
return SizedBox(
height: 30,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: labels.length,
separatorBuilder: (_, _) => const SizedBox(width: 7),
itemBuilder: (context, index) => ChoiceChip(
label: Text(labels[index]),
selected: logic.category == index,
onSelected: (_) => logic.selectCategory(index),
showCheckmark: false,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 7),
backgroundColor: const Color(0xFF29272B),
selectedColor: _orange,
side: BorderSide.none,
labelStyle: TextStyle(
color: logic.category == index ? Colors.white : const Color(0xFFDDD5D7),
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
);
}
Widget _continueCard(AppLocalizations l10n) => FutureBuilder<Duration?>(
future: logic.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
final total = widget.duration > Duration.zero
? widget.duration
: const Duration(seconds: 12);
final progress = (position.inMilliseconds / total.inMilliseconds).clamp(
0.0,
1.0,
);
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF1C1D21),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
width: 55,
height: 69,
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(widget.drama.posterAsset, fit: BoxFit.cover),
const Positioned(
left: 0,
right: 0,
bottom: 0,
height: 12,
child: ColoredBox(color: Color(0xFF1C1D21)),
),
],
),
),
),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.continueWatchingHeading,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFFFA490),
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
localizedDramaTitle(l10n, widget.drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
l10n.episodeProgress(1, widget.drama.episodes.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 11,
),
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: progress,
minHeight: 3,
backgroundColor: const Color(0xFF474248),
color: _orange,
),
),
],
),
),
const SizedBox(width: 9),
FilledButton.icon(
onPressed: () => logic.act(
context,
DiscoverPageAction.resume,
embedded: widget.embedded,
onAction: widget.onAction,
),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFBD5C),
foregroundColor: const Color(0xFF2A160B),
padding: const EdgeInsets.symmetric(horizontal: 10),
minimumSize: const Size(0, 32),
textStyle: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 14),
label: Text(l10n.resume),
),
],
),
);
},
);
Widget _quickActions(AppLocalizations l10n) {
final actions = [
(Icons.tune_rounded, l10n.filter, () => logic.showFilters(context)),
(Icons.bar_chart_rounded, l10n.topCharts, () => logic.catalogInfo(context)),
(
Icons.auto_awesome_rounded,
l10n.newReleases,
() => logic.catalogInfo(context),
),
(
Icons.bookmark_outline_rounded,
l10n.watchlist,
() {
logic.act(
context,
DiscoverPageAction.watchlist,
embedded: widget.embedded,
onAction: widget.onAction,
);
},
),
];
return Row(
children: [
for (var index = 0; index < actions.length; index++) ...[
const _BrandMark(),
const SizedBox(width: 8),
Expanded(
child: InkWell(
onTap: actions[index].$3,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 77,
padding: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: const Color(0xFF202126),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFF333036),
child: Icon(
actions[index].$1,
size: 19,
color: index == 1
? const Color(0xFFFFC15E)
: const Color(0xFFFFAB96),
),
),
const SizedBox(height: 7),
Text(
actions[index].$2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
child: Text(
l10n.appName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
if (index < actions.length - 1) const SizedBox(width: 7),
const SizedBox(width: 8),
if (widget.signedIn) ...[
_HeaderPill(
icon: Icons.monetization_on_rounded,
label: r'$ 0',
onTap: widget.onCoins,
),
const SizedBox(width: 8),
_HeaderPill(
icon: Icons.card_giftcard_rounded,
label: l10n.rewards,
onTap: widget.onRewards,
),
] else
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn),
],
),
);
}
Widget _carousel(
AppLocalizations l10n,
List<FeedBanner> banners,
List<Drama> allDramas,
) {
final items = banners.where((banner) => banner.imageUrl.isNotEmpty).toList();
if (items.isEmpty) return const SizedBox.shrink();
return CoverCarousel(
items: [
for (final banner in items)
CoverItem(
title: _bannerTitle(banner, allDramas),
subtitle: '',
image: banner.imageUrl,
onOpen: () => _openBanner(banner, allDramas),
),
],
);
}
Widget _sectionTitle(AppLocalizations l10n) => Row(
String _bannerTitle(FeedBanner banner, List<Drama> allDramas) {
final dramaId = banner.dramaId;
if (dramaId == null) return '';
for (final drama in allDramas) {
if (drama.id == dramaId) return drama.title;
}
return '';
}
void _openBanner(FeedBanner banner, List<Drama> allDramas) {
final dramaId = banner.dramaId;
if (dramaId == null) return;
for (final drama in allDramas) {
if (drama.id == dramaId) {
logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
);
return;
}
}
}
Widget _continueHeader(AppLocalizations l10n) => Row(
children: [
Container(width: 3, height: 20, color: _orange),
const SizedBox(width: 7),
Expanded(
child: Text(
l10n.trendingMicroDramas,
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.2,
),
),
),
TextButton(
onPressed: () => logic.selectCategory(0),
child: Text(
l10n.seeAll,
style: const TextStyle(color: Color(0xFFFFB3A1)),
const SizedBox(width: 8),
Text(
l10n.seeAll,
style: const TextStyle(
color: AppColors.blue,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
);
Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) {
final playable =
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]) != null;
return InkWell(
onTap: () => logic.openCard(
context,
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]),
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
),
borderRadius: BorderRadius.circular(11),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(card.image, fit: BoxFit.cover),
const Positioned(
left: 0,
right: 0,
bottom: 0,
height: 37,
child: ColoredBox(color: Color(0xFF101116)),
Widget _continueTile(AppLocalizations l10n, _ShowcaseCard card) {
final drama = logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]);
return SizedBox(
width: 132,
child: InkWell(
onTap: () => logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(card.image),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0xE6000000)],
stops: [0.45, 1],
),
Positioned(
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: playable ? _orange : const Color(0xDD37343B),
borderRadius: BorderRadius.circular(7),
),
child: Text(
playable ? l10n.free : l10n.artworkOnly,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
),
),
),
if (drama == null)
const ColoredBox(
color: Color(0x99000000),
child: Center(
child: Icon(Icons.image_outlined, color: Colors.white),
),
Positioned(
bottom: 10,
left: 8,
right: 8,
child: Text(
playable ? l10n.freeTestMedia : l10n.videoPending,
),
Positioned(
left: 10,
right: 10,
bottom: 10,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFFFC789),
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
),
],
const SizedBox(height: 3),
Text(
drama == null ? l10n.videoPending : card.genre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xE6FFFFFF),
fontSize: 11,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
],
),
),
],
),
),
),
);
}
Widget _categories(AppLocalizations l10n) {
final labels = [
l10n.forYou,
l10n.hot,
l10n.newest,
l10n.romance,
l10n.actionGenre,
];
return SizedBox(
height: 32,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: labels.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final selected = logic.category == index;
return GestureDetector(
onTap: () => logic.selectCategory(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: selected ? AppColors.blue : const Color(0xFF1C1E24),
borderRadius: BorderRadius.circular(16),
),
child: Text(
labels[index],
maxLines: 1,
style: TextStyle(
color: selected ? Colors.white : AppColors.muted,
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
height: 1.1,
),
),
),
),
const SizedBox(height: 6),
Text(
card.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(
card.genre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 11),
),
],
);
},
),
);
}
@@ -639,60 +488,131 @@ class _ShowcaseCard {
required this.title,
required this.genre,
required this.image,
required this.category,
this.dramaId,
});
final String title;
final String genre;
final String image;
final int category;
final String? dramaId;
}
/// 分区由服务端下发:0 为全部,其余按分区顺序对应筛选菜单(热门 / 最新 / 各分类)。
List<_ShowcaseCard> _cardsFor(
AppLocalizations l10n,
List<Drama> dramas,
List<FeedSection> sections,
int category,
String query,
) {
final cards = [
_ShowcaseCard(
title: l10n.dramaSignalTitle,
genre: l10n.genreSuspenseMystery,
image: 'assets/images/stitch_signal_poster_clean.png',
category: 1,
dramaId: 'the-signal-demo',
),
_ShowcaseCard(
title: l10n.dramaCityTitle,
genre: l10n.genreUrbanRomance,
image: 'assets/images/stitch_city_poster_clean.png',
category: 2,
dramaId: 'city-lights-demo',
),
_ShowcaseCard(
title: l10n.dramaVengeanceTitle,
genre: l10n.genreActionThriller,
image: 'assets/images/stitch_vengeance_poster_clean.png',
category: 3,
dramaId: 'vengeance-demo',
),
_ShowcaseCard(
title: l10n.dramaEchoesTitle,
genre: l10n.genreDramaMystery,
image: 'assets/images/stitch_rain_scene.jpg',
category: 1,
),
];
final source = switch (category) {
0 => dramas,
_ => category < sections.length ? sections[category].dramas : const <Drama>[],
};
final normalized = query.trim().toLowerCase();
return cards.where((card) {
final categoryMatches = category == 0 || card.category == category;
final queryMatches =
normalized.isEmpty ||
card.title.toLowerCase().contains(normalized) ||
card.genre.toLowerCase().contains(normalized);
return categoryMatches && queryMatches;
}).toList();
return [
for (final drama in source)
if (normalized.isEmpty ||
drama.title.toLowerCase().contains(normalized) ||
drama.genre.toLowerCase().contains(normalized) ||
drama.category.toLowerCase().contains(normalized))
_ShowcaseCard(
title: drama.title,
genre: drama.genre.isEmpty ? drama.category : drama.genre,
image: drama.posterUrl,
dramaId: drama.id,
),
];
}
const _orange = Color(0xFFFF5B3D);
const _orange = AppColors.blue;
const _coin = Color(0xFFF5C451);
class _BrandMark extends StatelessWidget {
const _BrandMark();
@override
Widget build(BuildContext context) {
return Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 20,
),
);
}
}
class _HeaderPill extends StatelessWidget {
const _HeaderPill({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF1C1E24),
shape: const StadiumBorder(side: BorderSide(color: Color(0x66F5C451))),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: _coin),
const SizedBox(width: 4),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1.1,
),
),
],
),
),
),
);
}
}
class _LoginButton extends StatelessWidget {
const _LoginButton({required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: AppColors.blue,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: const StadiumBorder(),
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
child: Text(label, maxLines: 1),
);
}
}
@@ -0,0 +1,206 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../core/widgets/remote_image.dart';
class CoverItem {
const CoverItem({
required this.title,
required this.subtitle,
required this.image,
required this.onOpen,
});
final String title;
final String subtitle;
final String image;
final VoidCallback onOpen;
}
/// Full-bleed posters that turn as faces of a cube. Titles sit on the image.
class CoverCarousel extends StatefulWidget {
const CoverCarousel({super.key, required this.items});
final List<CoverItem> items;
@override
State<CoverCarousel> createState() => _CoverCarouselState();
}
class _CoverCarouselState extends State<CoverCarousel>
with SingleTickerProviderStateMixin {
late final AnimationController _snap;
double _page = 0;
double _from = 0;
double _to = 0;
int? _pointer;
double _drag = 0;
double _dragPage = 0;
@override
void initState() {
super.initState();
_snap = AnimationController(vsync: this)..addListener(_onSnap);
}
@override
void dispose() {
_snap.dispose();
super.dispose();
}
double get _last => math.max(0, widget.items.length - 1).toDouble();
void _onSnap() {
final t = Curves.easeOutCubic.transform(_snap.value);
setState(() => _page = _from + (_to - _from) * t);
}
void _turnTo(double target) {
_from = _page;
_to = target.clamp(0, _last);
_snap.duration = const Duration(milliseconds: 420);
_snap.forward(from: 0);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = math.min(width * 0.52, 210).toDouble();
return SizedBox(
width: double.infinity,
height: height,
child: _stage(width),
);
},
);
}
Widget _stage(double width) {
return Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) {
_snap.stop();
_pointer = event.pointer;
_drag = 0;
_dragPage = _page;
},
onPointerMove: (event) {
if (event.pointer != _pointer) return;
_drag += event.delta.dx;
setState(() {
_page = (_dragPage - _drag / width).clamp(0.0, _last);
});
},
onPointerUp: (event) {
if (event.pointer != _pointer) return;
_pointer = null;
_turnTo(_page.roundToDouble());
},
onPointerCancel: (event) {
if (event.pointer != _pointer) return;
_pointer = null;
_turnTo(_page.roundToDouble());
},
child: ClipRect(
child: Stack(
fit: StackFit.expand,
children: [for (final index in _visible) _face(index, width)],
),
),
);
}
List<int> get _visible {
final first = _page.floor();
final indexes = <int>[first, first + 1];
indexes.retainWhere((index) => index >= 0 && index < widget.items.length);
indexes.sort((a, b) => (b - _page).abs().compareTo((a - _page).abs()));
return indexes;
}
Widget _face(int index, double width) {
final delta = index - _page;
if (delta <= -1 || delta >= 1) return const SizedBox.shrink();
final item = widget.items[index];
final angle = delta * math.pi / 2;
// Push the face out to the cube surface, spin the whole cube on its
// vertical center, then bring that center back to the banner.
final face = Matrix4.identity()
..translateByDouble(0, 0, -width / 2, 1)
..rotateY(angle)
..translateByDouble(0, 0, width / 2, 1);
final transform = Matrix4.identity()
..setEntry(3, 2, 0.0018)
..multiply(face);
return Positioned.fill(
child: Transform(
alignment: Alignment.center,
filterQuality: FilterQuality.medium,
transform: transform,
child: GestureDetector(
onTap: () {
if ((_page - index).abs() < 0.08) item.onOpen();
},
child: _poster(item),
),
),
);
}
Widget _poster(CoverItem item) {
return Stack(
fit: StackFit.expand,
children: [
RemoteImage(item.image),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0x00000000), Color(0xE6000000)],
stops: [0, 0.5, 1],
),
),
),
Positioned(
left: 20,
right: 20,
bottom: 22,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1.15,
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
),
),
const SizedBox(height: 6),
Text(
item.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xF2FFFFFF),
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.2,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
),
),
],
),
),
],
);
}
}
@@ -1,15 +1,21 @@
// 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,
WatchHistoryRepository? history,
required WatchHistoryRepository history,
required PlaybackRepository playback,
required FavoriteController favorites,
}) => DramaDetailLogic(
drama: drama,
history: history ?? SharedPreferencesWatchHistoryRepository(),
history: history,
playback: playback,
favorites: favorites,
);
}
+83 -11
View File
@@ -1,37 +1,106 @@
// 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});
DramaDetailLogic({
required this.drama,
required this.history,
required this.playback,
required this.favorites,
});
final Drama drama;
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
Drama drama;
final WatchHistoryRepository history;
final PlaybackRepository playback;
final FavoriteController favorites;
bool loading = false;
bool failed = false;
/// 接口给出的失败原因;为空时界面回退到本地文案。
String? failureMessage;
int? continueIndex;
WatchProgress? progress;
String? progressError;
bool _disposed = false;
void notify() {
if (!_disposed) notifyListeners();
}
/// 列表卡片只带一集可播预览,进详情时补一次完整剧集列表。
Future<void> load(Future<Drama> Function() fetch) async {
if (drama.detailLoaded) {
await loadContinue();
return;
}
loading = true;
failed = false;
failureMessage = null;
notify();
try {
drama = await fetch();
await loadContinue();
} on ApiException catch (exception) {
failed = true;
failureMessage = exception.message;
} on DioException {
// 断网/超时:交给 l10n 文案,不把 HTTP 细节丢给用户
failed = true;
failureMessage = 'networkError';
} catch (_) {
failed = true;
} finally {
loading = false;
notify();
}
}
Future<void> loadContinue() async {
try {
final id = await history.lastEpisodeId(demoProfileId, drama.id);
if (id == null || _disposed) return;
final index = drama.episodes.indexWhere((episode) => episode.id == id);
if (index >= 0 && drama.episodes[index].isFree) {
continueIndex = index;
notify();
}
} catch (_) {
// Keep the first episode available if preferences cannot be read.
final record = await history.progressFor(drama.id);
if (_disposed) return;
progress = record;
progressError = null;
continueIndex = record == null
? null
: _indexOf(record.episodeId) ?? continueIndex;
notify();
} catch (exception) {
if (_disposed) return;
// 拿不到续看位置不影响看第一集,但要让用户知道(可重试)
progressError = exception is ApiException
? exception.message
: 'progressError';
notify();
}
}
int? _indexOf(String episodeId) {
final index = drama.episodes.indexWhere(
(episode) => episode.id == episodeId,
);
return index >= 0 ? index : null;
}
/// 该集的续看进度(0~1),用于剧集行上的进度条;没有记录返回 null。
double? progressFractionFor(String episodeId) {
final record = progress;
if (record == null || record.episodeId != episodeId) return null;
if (!record.hasPosition) return null;
return record.fraction;
}
Future<void> openPlayer(BuildContext context, int index) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
@@ -41,6 +110,9 @@ class DramaDetailLogic extends ChangeNotifier {
await loadContinue();
}
/// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。
Future<bool> toggleFavorite() => favorites.toggle(drama.id, card: drama);
@override
void dispose() {
_disposed = true;
+213 -15
View File
@@ -1,12 +1,22 @@
// 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/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/widgets/auth_gate.dart';
import '../../l10n/app_localizations.dart';
import 'drama_detail_binding.dart';
import 'drama_detail_logic.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
class DramaDetailPage extends StatefulWidget {
const DramaDetailPage({super.key, required this.drama});
@@ -22,8 +32,21 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
@override
void initState() {
super.initState();
logic = DramaDetailBinding().create(drama: widget.drama);
logic.loadContinue();
logic = DramaDetailBinding().create(
drama: widget.drama,
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
favorites: context.read<FavoriteController>(),
);
_load();
}
void _load() {
unawaited(
logic.load(
() => context.read<CatalogController>().requireDetail(widget.drama),
),
);
}
@override
@@ -42,7 +65,69 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (logic.loading) {
return const Scaffold(
backgroundColor: Color(0xFF111111),
body: Center(child: CircularProgressIndicator()),
);
}
if (logic.failed) {
return Scaffold(
backgroundColor: const Color(0xFF111111),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorText(l10n, logic.failureMessage, l10n.detailError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: Text(l10n.retry)),
],
),
),
),
);
}
final drama = logic.drama;
if (drama.episodes.isEmpty) {
// 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏
return Scaffold(
backgroundColor: const Color(0xFF111111),
appBar: AppBar(
backgroundColor: const Color(0xFF111111),
foregroundColor: Colors.white,
title: Text(localizedDramaTitle(l10n, drama)),
),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.movie_filter_outlined,
size: 48,
color: Colors.white38,
),
const SizedBox(height: 12),
Text(
l10n.noEpisodesYet,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: Text(l10n.retry)),
],
),
),
),
);
}
return Scaffold(
body: CustomScrollView(
slivers: [
@@ -51,8 +136,9 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
pinned: true,
backgroundColor: const Color(0xFF111111),
foregroundColor: Colors.white,
actions: [_favoriteAction(context, l10n)],
flexibleSpace: FlexibleSpaceBar(
background: Image.asset(drama.posterAsset, fit: BoxFit.cover),
background: RemoteImage(drama.posterUrl),
),
),
SliverPadding(
@@ -63,7 +149,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
l10n.technicalDemoCount(drama.episodes.length),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
letterSpacing: 1.4,
color: const Color(0xFF9278EA),
color: const Color(0xFF2F7BFF),
fontWeight: FontWeight.bold,
),
),
@@ -82,15 +168,54 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: () =>
logic.openPlayer(context, logic.continueIndex ?? 0),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
logic.continueIndex == null
? l10n.watchEpisodeOne
: l10n.continueEpisode(logic.continueIndex! + 1),
),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: () =>
logic.openPlayer(context, logic.continueIndex ?? 0),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
logic.continueIndex == null
? l10n.watchEpisodeOne
: l10n.continueEpisode(logic.continueIndex! + 1),
),
),
if (_continueFraction != null) ...[
const SizedBox(height: 8),
_ResumeBar(
fraction: _continueFraction!,
label: l10n.resumeProgress(
_clock(logic.progress!.position),
_clock(logic.progress!.duration),
),
),
],
if (logic.progressError != null) ...[
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
errorText(
l10n,
logic.progressError,
l10n.progressError,
),
style: const TextStyle(
color: Colors.white60,
fontSize: 12,
),
),
),
TextButton(
onPressed: () => unawaited(logic.loadContinue()),
child: Text(l10n.retry),
),
],
),
],
],
),
const SizedBox(height: 30),
Text(
@@ -108,18 +233,24 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
itemCount: drama.episodes.length,
itemBuilder: (context, index) {
final episode = drama.episodes[index];
final fraction = logic.progressFractionFor(episode.id);
return Card(
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
minTileHeight: 76,
leading: CircleAvatar(
backgroundColor: const Color(
0xFFFF6B45,
0xFF2F7BFF,
).withValues(alpha: 0.18),
child: Text('${episode.number}'),
),
title: Text(l10n.episodeLabel(episode.number)),
subtitle: Text(l10n.freeTestMedia),
subtitle: fraction == null
? null
: Padding(
padding: const EdgeInsets.only(top: 6),
child: _ResumeBar(fraction: fraction),
),
trailing: const Icon(Icons.play_circle_outline_rounded),
onTap: () => logic.openPlayer(context, index),
),
@@ -131,4 +262,71 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
),
);
}
double? get _continueFraction {
final record = logic.progress;
if (record == null || !record.hasPosition) return null;
if (logic.continueIndex == null) return null;
return record.fraction;
}
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
final favorites = context.watch<FavoriteController>();
final saved = favorites.isSaved(logic.drama.id);
return IconButton(
tooltip: l10n.favorite,
icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border),
onPressed: () async {
if (!await ensureSignedIn(context)) return;
final ok = await logic.toggleFavorite();
if (ok || !context.mounted) return;
// 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚)
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
},
);
}
String _clock(Duration duration) {
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
return duration.inHours > 0
? '${duration.inHours}:$minutes:$seconds'
: '$minutes:$seconds';
}
}
/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。
class _ResumeBar extends StatelessWidget {
const _ResumeBar({required this.fraction, this.label});
final double fraction;
final String? label;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: fraction,
minHeight: 3,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation(Color(0xFF2F7BFF)),
),
),
if (label != null) ...[
const SizedBox(height: 4),
Text(
label!,
style: const TextStyle(color: Colors.white54, fontSize: 11),
),
],
],
);
}
}
-32
View File
@@ -1,32 +0,0 @@
// Author: fruit.com.cn
import '../../core/data/models/drama.dart';
enum HomeFeedCategory { following, animated, liveAction, recommended }
/// Maps catalog entries into home feeds without owning playback or UI state.
class HomeFeedSections {
HomeFeedSections(this.dramas, {this.followedDramaIds = const {}});
final List<Drama> dramas;
final Set<String> followedDramaIds;
List<int> indicesFor(HomeFeedCategory category) => [
for (var index = 0; index < dramas.length; index++)
if (dramas[index].episodes.isNotEmpty &&
switch (category) {
HomeFeedCategory.following => followedDramaIds.contains(
dramas[index].id,
),
HomeFeedCategory.animated =>
dramas[index].format == DramaFormat.animated,
HomeFeedCategory.liveAction =>
dramas[index].format == DramaFormat.liveAction,
HomeFeedCategory.recommended => true,
})
index,
];
Drama dramaAt(HomeFeedCategory category, int categoryIndex) =>
dramas[indicesFor(category)[categoryIndex]];
}
+38 -9
View File
@@ -1,18 +1,21 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../discover/discover_logic.dart';
import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_logic.dart';
import '../player/player_view.dart';
class HomeLogic extends ChangeNotifier {
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
final scaffoldKey = GlobalKey<ScaffoldState>();
Drama currentDrama;
int tab = 0;
int tab = 1;
int searchRequest = 0;
int sectionRequest = 0;
int resumeRequest = 0;
@@ -21,6 +24,7 @@ class HomeLogic extends ChangeNotifier {
bool drawerOpen = false;
bool detailOpen = false;
bool videoImmersive = false;
bool profile = false;
bool _disposed = false;
void notify() {
@@ -64,9 +68,7 @@ class HomeLogic extends ChangeNotifier {
if (!context.mounted) return;
try {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => DramaDetailPage(drama: currentDrama),
),
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: currentDrama)),
);
} finally {
if (context.mounted) {
@@ -82,6 +84,34 @@ class HomeLogic extends ChangeNotifier {
);
}
Future<void> playDrama(BuildContext context, Drama drama) async {
// 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。
var playable = drama;
try {
playable = await context.read<CatalogController>().requireDetail(drama);
} catch (_) {
// 取详情失败就用卡片自带的那一集起播。
}
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(drama: playable, initialIndex: 0),
),
);
}
void openProfile() {
profile = true;
scaffoldKey.currentState?.closeDrawer();
select(2, mySection: 0);
}
void openLibrary({int section = 1}) {
profile = false;
scaffoldKey.currentState?.closeDrawer();
select(2, mySection: section);
}
void discoverAction(BuildContext context, DiscoverPageAction action) {
switch (action) {
case DiscoverPageAction.home:
@@ -89,7 +119,7 @@ class HomeLogic extends ChangeNotifier {
case DiscoverPageAction.my:
select(2);
case DiscoverPageAction.watchlist:
select(2, watchlist: true);
openLibrary();
case DiscoverPageAction.resume:
select(0, resume: true);
case DiscoverPageAction.detail:
@@ -114,11 +144,10 @@ class HomeLogic extends ChangeNotifier {
} else if (videoImmersive) {
backRequest++;
notify();
} else if (tab != 0) {
select(0);
} else if (tab != 1) {
select(1);
} else {
backRequest++;
notify();
openMenu();
}
}
+74 -29
View File
@@ -1,10 +1,15 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_view.dart';
import '../mine/mine_view.dart';
@@ -31,7 +36,6 @@ class HomePage extends StatelessWidget {
CatalogController catalog,
AppLocalizations l10n,
) {
if (catalog.loading) {
return const Scaffold(
backgroundColor: Colors.black,
@@ -48,6 +52,13 @@ class HomePage extends StatelessWidget {
Text(catalog.error == null ? l10n.noStories : l10n.catalogError),
const SizedBox(height: 12),
FilledButton(onPressed: catalog.load, child: Text(l10n.retry)),
if (kDebugMode) ...[
const SizedBox(height: 16),
Text(
AppConfig.apiBaseUrl,
style: const TextStyle(fontSize: 12, color: Colors.white54),
),
],
],
),
),
@@ -94,8 +105,11 @@ class _HomeTabsState extends State<_HomeTabs> {
);
}
Widget _scaffold(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关)
final signedIn = context.watch<AuthController>().isSignedIn;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
@@ -109,8 +123,8 @@ class _HomeTabsState extends State<_HomeTabs> {
drama: logic.currentDrama,
onOpenSeries: () => logic.openCurrentSeries(context),
onDiscover: () => logic.selectFromMenu(1),
onHistory: () => logic.selectFromMenu(2, mySection: 0),
onWatchlist: () => logic.selectFromMenu(2, mySection: 1),
onHistory: () => logic.openLibrary(section: 0),
onWatchlist: logic.openLibrary,
onLiked: () => logic.selectFromMenu(2, mySection: 2),
),
drawerEnableOpenDragGesture: false,
@@ -129,7 +143,7 @@ class _HomeTabsState extends State<_HomeTabs> {
resumeRequest: logic.resumeRequest,
backRequest: logic.backRequest,
onDiscover: () => logic.select(1),
onMy: () => logic.select(2),
onMy: logic.openProfile,
onSearch: () => logic.select(1, search: true),
onMenu: logic.openMenu,
onImmersiveChanged: logic.onImmersiveChanged,
@@ -137,11 +151,15 @@ class _HomeTabsState extends State<_HomeTabs> {
DiscoverPage(
drama: logic.currentDrama,
dramas: widget.dramas,
onDramaSelected: (drama) => logic.openDrama(context, drama),
onDramaSelected: (drama) => logic.playDrama(context, drama),
duration: Duration.zero,
embedded: true,
active: logic.tab == 1,
searchRequest: logic.searchRequest,
signedIn: signedIn,
onSignIn: () => openAuthPage(context),
onCoins: logic.openProfile,
onRewards: logic.openProfile,
onAction: (action) => logic.discoverAction(context, action),
),
MyPage(
@@ -150,10 +168,15 @@ class _HomeTabsState extends State<_HomeTabs> {
onDramaSelected: (drama) => logic.openDrama(context, drama),
duration: Duration.zero,
initialSection: logic.mySection,
profile: logic.profile,
sectionRequest: logic.sectionRequest,
embedded: true,
active: logic.tab == 2,
onAction: logic.myAction,
onOpenHistory: () => logic.openLibrary(section: 0),
signedIn: signedIn,
onSignIn: () => openAuthPage(context),
onSignOut: () => context.read<AuthController>().signOut(),
),
],
),
@@ -169,28 +192,38 @@ class _HomeTabsState extends State<_HomeTabs> {
border: Border(top: BorderSide(color: Color(0xFF242329))),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_TabButton(
key: const Key('tab-home'),
icon: Icons.play_circle_outline_rounded,
label: l10n.homeTab,
selected: logic.tab == 0,
onTap: () => logic.select(0),
),
_TabButton(
key: const Key('tab-discover'),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
label: l10n.homeTab,
selected: logic.tab == 1,
onTap: () => logic.select(1),
),
_TabButton(
key: const Key('tab-home'),
icon: Icons.movie_outlined,
selectedIcon: Icons.movie_rounded,
label: l10n.shortsTab,
selected: logic.tab == 0,
onTap: () => logic.select(0),
),
_TabButton(
key: const Key('tab-my-list'),
icon: Icons.bookmark_border_rounded,
selectedIcon: Icons.bookmark_rounded,
label: l10n.myListTab,
selected: logic.tab == 2 && !logic.profile,
onTap: logic.openLibrary,
),
_TabButton(
key: const Key('tab-my'),
icon: Icons.video_library_outlined,
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
label: l10n.myTab,
selected: logic.tab == 2,
onTap: () => logic.select(2),
selected: logic.tab == 2 && logic.profile,
onTap: logic.openProfile,
),
],
),
@@ -205,35 +238,47 @@ class _TabButton extends StatelessWidget {
const _TabButton({
super.key,
required this.icon,
required this.selectedIcon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final IconData selectedIcon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
child: SizedBox(
width: 80,
Widget build(BuildContext context) => Expanded(
child: InkWell(
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 21,
color: selected ? const Color(0xFFFF5C37) : const Color(0xFFE6E1DE),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 3),
decoration: selected
? BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(14),
)
: null,
child: Icon(
selected ? selectedIcon : icon,
size: 20,
color: selected ? Colors.white : AppColors.muted,
),
),
const SizedBox(height: 2),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
color: selected
? const Color(0xFFFF5C37)
: const Color(0xFFE6E1DE),
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? AppColors.blue : AppColors.muted,
),
),
],
@@ -1,37 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
/// Converts horizontal drags into a previous/next category request.
/// Placement decides where the gesture is active; the seek bar sits outside it.
class CategorySwipeRegion extends StatefulWidget {
const CategorySwipeRegion({
super.key,
required this.onShift,
required this.child,
});
final ValueChanged<int> onShift;
final Widget child;
@override
State<CategorySwipeRegion> createState() => _CategorySwipeRegionState();
}
class _CategorySwipeRegionState extends State<CategorySwipeRegion> {
double _distance = 0;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (_) => _distance = 0,
onHorizontalDragUpdate: (details) => _distance += details.delta.dx,
onHorizontalDragEnd: (details) {
final velocity = details.primaryVelocity ?? 0;
if (_distance.abs() < 44 && velocity.abs() < 300) return;
final direction = _distance.abs() >= 44 ? _distance : velocity;
widget.onShift(direction < 0 ? 1 : -1);
},
child: widget.child,
);
}
@@ -1,172 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../l10n/app_localizations.dart';
import '../home_feed_sections.dart';
import 'category_swipe_region.dart';
/// Reused by playable and empty home feeds.
class HomeFeedHeader extends StatelessWidget {
const HomeFeedHeader({
super.key,
required this.l10n,
required this.category,
required this.onCategorySelected,
required this.onCategoryShift,
required this.onSearch,
required this.onMy,
this.onMenu,
this.onMute,
this.muted = false,
});
final AppLocalizations l10n;
final HomeFeedCategory category;
final ValueChanged<HomeFeedCategory> onCategorySelected;
final ValueChanged<int> onCategoryShift;
final VoidCallback onSearch;
final VoidCallback onMy;
final VoidCallback? onMenu;
final VoidCallback? onMute;
final bool muted;
String _label(HomeFeedCategory category) => switch (category) {
HomeFeedCategory.following => l10n.followingTab,
HomeFeedCategory.animated => l10n.animatedTab,
HomeFeedCategory.liveAction => l10n.liveActionTab,
HomeFeedCategory.recommended => l10n.recommendedTab,
};
@override
Widget build(BuildContext context) => IconTheme(
data: const IconThemeData(color: Colors.white),
child: DefaultTextStyle.merge(
style: const TextStyle(color: Colors.white),
child: _header(context),
),
);
Widget _header(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: [
if (onMenu != null)
IconButton(
key: const Key('home-open-menu'),
tooltip: l10n.openQuickMenu,
onPressed: onMenu,
icon: const Icon(Icons.menu_rounded),
),
Container(
width: 29,
height: 29,
decoration: BoxDecoration(
color: const Color(0xFFFF5C37),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
),
const SizedBox(width: 8),
Text(
l10n.homeTab,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
tooltip: l10n.search,
onPressed: onSearch,
icon: const Icon(Icons.search_rounded),
),
IconButton(
tooltip: l10n.myTab,
onPressed: onMy,
icon: const CircleAvatar(
radius: 15,
backgroundColor: Color(0xFF3C3A3A),
child: Icon(
Icons.person_rounded,
color: Colors.white,
size: 19,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 0),
child: Row(
children: [
Expanded(
child: CategorySwipeRegion(
key: const Key('home-category-swipe'),
onShift: onCategoryShift,
child: Row(
children: [
for (final item in HomeFeedCategory.values)
Expanded(
child: InkWell(
key: Key('home-category-${item.name}'),
onTap: () => onCategorySelected(item),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 9),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_label(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: category == item
? const Color(0xFFFFC2B0)
: const Color(0xFFF7F4F2),
shadows: const [
Shadow(blurRadius: 8, color: Colors.black),
],
fontSize: 12,
fontWeight: category == item
? FontWeight.w700
: FontWeight.w500,
),
),
const SizedBox(height: 4),
SizedBox(
width: 18,
height: 2,
child: DecoratedBox(
decoration: BoxDecoration(
color: category == item
? const Color(0xFFFF5C37)
: Colors.transparent,
),
),
),
],
),
),
),
),
],
),
),
),
if (onMute != null)
IconButton(
tooltip: muted ? l10n.unmute : l10n.mute,
onPressed: onMute,
icon: Icon(
muted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
size: 21,
),
),
],
),
),
],
);
}
+5 -14
View File
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../../../core/config/locale_controller.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// Shortcuts for the existing root tabs; this widget owns no navigation state.
@@ -29,7 +30,7 @@ class HomeQuickDrawer extends StatelessWidget {
static const _panel = Color(0xFF222225);
static const _card = Color(0xFF303033);
static const _orange = Color(0xFFFF5C37);
static const _orange = Color(0xFF2F7BFF);
@override
Widget build(BuildContext context) {
@@ -90,11 +91,10 @@ class HomeQuickDrawer extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.asset(
drama.posterAsset,
child: RemoteImage(
drama.posterUrl,
width: 74,
height: 100,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
@@ -113,18 +113,9 @@ class HomeQuickDrawer extends StatelessWidget {
),
const SizedBox(height: 7),
Text(
l10n.episodeCount(drama.episodes.length),
l10n.episodeCount(drama.episodeCount),
style: const TextStyle(color: Color(0xFFE7E2E0)),
),
const SizedBox(height: 5),
Text(
l10n.freeTestMedia,
maxLines: 2,
style: const TextStyle(
color: Color(0xFFFFB99E),
fontSize: 11,
),
),
],
),
),
-468
View File
@@ -1,468 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/services/auth_controller.dart';
import '../home/home_view.dart';
/// 登录页:Google / 手机号 / 邮箱 三种登录入口 + 游客浏览。
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
static const _orange = Color(0xFFFF5B3D);
static const _purple = Color(0xFF9278EA);
void _enterHome() {
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(builder: (_) => const HomePage()),
);
}
Future<void> _showLoginSheet(String targetType) async {
final auth = context.read<AuthController>();
final targetCtrl = TextEditingController();
final codeCtrl = TextEditingController();
final isPhone = targetType == 'PHONE';
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF161822),
builder: (sheetContext) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
isPhone ? 'Phone Login' : 'Email Login',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 16),
TextField(
controller: targetCtrl,
keyboardType: isPhone
? TextInputType.phone
: TextInputType.emailAddress,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: isPhone ? 'Phone number' : 'Email',
hintStyle: const TextStyle(color: Colors.white38),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: codeCtrl,
keyboardType: TextInputType.number,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Code',
hintStyle: const TextStyle(color: Colors.white38),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
),
const SizedBox(width: 12),
TextButton(
onPressed: () async {
try {
await auth.sendOtp(
scene: 'LOGIN',
targetType: targetType,
target: targetCtrl.text.trim(),
);
if (sheetContext.mounted) {
ScaffoldMessenger.of(sheetContext).showSnackBar(
const SnackBar(content: Text('Code sent')),
);
}
} catch (e) {
if (sheetContext.mounted) {
ScaffoldMessenger.of(sheetContext).showSnackBar(
SnackBar(content: Text('$e')),
);
}
}
},
child: const Text('Send Code'),
),
],
),
const SizedBox(height: 20),
FilledButton(
onPressed: () async {
try {
await auth.loginWithCode(
targetType: targetType,
target: targetCtrl.text.trim(),
code: codeCtrl.text.trim(),
);
if (sheetContext.mounted) {
Navigator.of(sheetContext).pop();
}
_enterHome();
} catch (e) {
if (sheetContext.mounted) {
ScaffoldMessenger.of(sheetContext).showSnackBar(
SnackBar(content: Text('$e')),
);
}
}
},
style: FilledButton.styleFrom(
backgroundColor: _orange,
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: const Text('Sign In'),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
// 顶部:关闭 + 游客浏览
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_IconButton(icon: Icons.close, onTap: () {}),
TextButton(
onPressed: _enterHome,
style: TextButton.styleFrom(
foregroundColor: Colors.white54,
backgroundColor: Colors.white.withValues(alpha: 0.05),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
shape: const StadiumBorder(),
),
child: const Text('Guest Explore'),
),
],
),
),
// 中央内容
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _BrandLogo(),
const SizedBox(height: 20),
const Text(
'DramaPulse',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
const SizedBox(height: 8),
const Text(
'Sign in to sync your progress and coins',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Colors.white54,
),
),
const SizedBox(height: 36),
_GoogleButton(onTap: _enterHome),
const SizedBox(height: 14),
_DarkButton(
icon: Icons.phone_iphone,
iconColor: _orange,
label: 'Continue with Phone Number',
onTap: () => _showLoginSheet('PHONE'),
),
const SizedBox(height: 14),
_DarkButton(
icon: Icons.email_outlined,
iconColor: _purple,
label: 'Continue with Email',
onTap: () => _showLoginSheet('EMAIL'),
),
const SizedBox(height: 32),
const _BonusPill(),
],
),
),
),
),
// 底部协议
const Padding(
padding: EdgeInsets.only(bottom: 24),
child: Text(
'By continuing, you agree to DramaPulse\'s\n'
'Terms of Service · Privacy Policy · Content Policy',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: Colors.white38),
),
),
],
),
),
);
}
}
class _IconButton extends StatelessWidget {
const _IconButton({required this.icon, required this.onTap});
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
border: Border.all(color: Colors.white12),
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white54, size: 20),
),
);
}
}
class _BrandLogo extends StatelessWidget {
const _BrandLogo();
@override
Widget build(BuildContext context) {
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF5B3D), Color(0xFFD8536C), Color(0xFF9278EA)],
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white24),
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40),
);
}
}
class _GoogleButton extends StatelessWidget {
const _GoogleButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return _AuthButton(
onTap: onTap,
background: Colors.white,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const _GoogleG(),
const SizedBox(width: 12),
Text(
'Continue with Google',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey.shade900,
),
),
],
),
);
}
}
/// 简化版 Google「G」logo。
class _GoogleG extends StatelessWidget {
const _GoogleG();
@override
Widget build(BuildContext context) {
return Container(
width: 20,
height: 20,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFF4285F4),
),
child: const Center(
child: Text(
'G',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
);
}
}
class _DarkButton extends StatelessWidget {
const _DarkButton({
required this.icon,
required this.iconColor,
required this.label,
required this.onTap,
});
final IconData icon;
final Color iconColor;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return _AuthButton(
onTap: onTap,
background: const Color(0xFF161822),
border: const Color(0xFF232634),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: iconColor, size: 20),
const SizedBox(width: 12),
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
),
);
}
}
class _AuthButton extends StatelessWidget {
const _AuthButton({
required this.onTap,
required this.background,
required this.child,
this.border,
});
final VoidCallback onTap;
final Color background;
final Color? border;
final Widget child;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 52,
child: Material(
color: background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: border == null ? BorderSide.none : BorderSide(color: border!),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: child,
),
),
);
}
}
class _BonusPill extends StatelessWidget {
const _BonusPill();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.04),
border: Border.all(color: Colors.white12),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: Color(0xFF34D399),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
const Text(
'Get ',
style: TextStyle(fontSize: 11, color: Colors.white54),
),
const Text(
'100 Free Bonus Coins',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const Text(
' upon first sign-in',
style: TextStyle(fontSize: 11, color: Colors.white54),
),
],
),
);
}
}
+2 -2
View File
@@ -8,10 +8,10 @@ class MineBinding {
MineLogic create({
required Drama drama,
required int initialSection,
WatchHistoryRepository? history,
required WatchHistoryRepository history,
}) => MineLogic(
drama: drama,
initialSection: initialSection,
history: history ?? SharedPreferencesWatchHistoryRepository(),
history: history,
);
}
+33 -5
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../../core/config/locale_controller.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../l10n/app_localizations.dart';
@@ -22,6 +23,11 @@ class MineLogic extends ChangeNotifier {
final WatchHistoryRepository history;
Drama drama;
late Future<Duration?> position;
List<WatchProgress> recent = const [];
bool recentLoading = false;
String? recentError;
/// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始)
bool positionFailed = false;
int section;
int filter = 0;
bool _disposed = false;
@@ -31,11 +37,33 @@ class MineLogic extends ChangeNotifier {
}
void refreshPosition() {
position = history.positionFor(
demoProfileId,
drama.id,
drama.episodes.first.id,
);
position = history
.resumePointFor(drama.id)
.then((point) {
positionFailed = false;
return point?.position;
})
.catchError((Object _) {
positionFailed = true;
notify();
return null;
});
refreshRecent();
}
/// 最近观看:服务端只给 dramaId/位置,剧名封面用已加载的目录卡片按 id 映射。
Future<void> refreshRecent() async {
recentLoading = true;
recentError = null;
notify();
try {
recent = await history.recent(limit: 20);
} catch (_) {
recentError = 'progressError';
} finally {
recentLoading = false;
notify();
}
}
void selectSection(int index) {
+400 -223
View File
@@ -1,12 +1,19 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/engagement_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
import 'mine_binding.dart';
import 'mine_logic.dart';
@@ -17,11 +24,16 @@ class MyPage extends StatefulWidget {
required this.drama,
required this.duration,
this.initialSection = 0,
this.profile = false,
this.onOpenHistory,
this.history,
this.embedded = false,
this.active = true,
this.sectionRequest = 0,
this.onAction,
this.signedIn = false,
this.onSignIn,
this.onSignOut,
this.dramas,
this.onDramaSelected,
});
@@ -29,11 +41,16 @@ class MyPage extends StatefulWidget {
final Drama drama;
final Duration duration;
final int initialSection;
final bool profile;
final VoidCallback? onOpenHistory;
final WatchHistoryRepository? history;
final bool embedded;
final bool active;
final int sectionRequest;
final ValueChanged<MyPageAction>? onAction;
final bool signedIn;
final VoidCallback? onSignIn;
final VoidCallback? onSignOut;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
@@ -50,7 +67,7 @@ class _MyPageState extends State<MyPage> {
logic = MineBinding().create(
drama: widget.drama,
initialSection: widget.initialSection,
history: widget.history,
history: widget.history ?? context.read<WatchHistoryRepository>(),
);
}
@@ -88,7 +105,7 @@ class _MyPageState extends State<MyPage> {
bottom: !widget.embedded,
child: Column(
children: [
_topBar(l10n),
if (!widget.profile) _topBar(l10n),
Expanded(
child: FutureBuilder<Duration?>(
future: logic.position,
@@ -103,15 +120,13 @@ class _MyPageState extends State<MyPage> {
1.0,
);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
children: [
_profile(l10n),
const SizedBox(height: 22),
_continueCard(l10n, fraction, position, total),
const SizedBox(height: 26),
_library(l10n, position, total, fraction),
const SizedBox(height: 26),
_preferences(l10n),
if (widget.profile)
..._accountPage(l10n)
else ...[
_library(l10n, position, total, fraction),
],
const SizedBox(height: 24),
Center(
child: Text(
@@ -149,8 +164,8 @@ class _MyPageState extends State<MyPage> {
const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27),
const SizedBox(width: 7),
Text(
l10n.myTab,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
l10n.myListTab,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
),
const Spacer(),
IconButton(
@@ -174,158 +189,247 @@ class _MyPageState extends State<MyPage> {
),
);
Widget _profile(AppLocalizations l10n) => Row(
children: [
const CircleAvatar(
radius: 30,
backgroundColor: Color(0xFF32343A),
child: Icon(Icons.person_rounded, color: Colors.white, size: 34),
List<Widget> _accountPage(AppLocalizations l10n) => [
if (widget.signedIn) _signedInRow(l10n) else _guestRow(l10n),
if (widget.signedIn) ...[const SizedBox(height: 18), _wallet(l10n)],
const SizedBox(height: 14),
_menuCard(l10n),
const SizedBox(height: 18),
Text(
l10n.preferencesHeading,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 1.1,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.localViewer,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
),
const SizedBox(height: 4),
Text(
l10n.localDemoProfile,
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 12),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF24252A),
borderRadius: BorderRadius.circular(20),
),
child: Text(l10n.demo, style: const TextStyle(fontSize: 11)),
),
],
);
Widget _continueCard(
AppLocalizations l10n,
double fraction,
Duration position,
Duration total,
) => Container(
padding: const EdgeInsets.all(13),
decoration: BoxDecoration(
color: const Color(0xFF201B1E),
borderRadius: BorderRadius.circular(13),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.language_rounded, color: AppColors.blueSoft),
title: Text(l10n.appLanguage),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => logic.showLanguage(context),
),
];
Widget _guestRow(AppLocalizations l10n) {
return Row(
children: [
Row(
children: [
const Icon(Icons.history_rounded, color: _orange, size: 17),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.continueWatching,
const CircleAvatar(
radius: 28,
backgroundColor: Color(0xFF32343A),
child: Icon(Icons.person_rounded, color: Colors.white, size: 30),
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.logInHint,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 8),
TextButton(
onPressed: widget.onSignIn,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: AppColors.blue,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: const StadiumBorder(),
),
child: Text(l10n.logIn),
),
],
);
}
Widget _signedInRow(AppLocalizations l10n) {
// 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案
final user = context.watch<AuthController>().user;
final name = (user?.nickname.isNotEmpty ?? false) ? user!.nickname : l10n.mockUserName;
final uidText = (user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.mockUserId;
return Row(
children: [
const CircleAvatar(
radius: 28,
backgroundColor: Color(0xFF32343A),
child: Icon(Icons.person_rounded, color: Colors.white, size: 30),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
),
Text(
l10n.episodeProgress(1, widget.drama.episodes.length),
style: const TextStyle(color: Color(0xFFFFA38F), fontSize: 10),
),
],
),
const SizedBox(height: 12),
Row(
children: [
_poster(width: 75, height: 96),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, widget.drama),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
l10n.dramaTag,
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
),
const SizedBox(height: 10),
Text(
l10n.watchedPercent((fraction * 100).round()),
style: const TextStyle(
color: Color(0xFFE7DEDD),
fontSize: 11,
),
),
const SizedBox(height: 5),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: fraction,
minHeight: 4,
backgroundColor: const Color(0xFF444046),
color: _orange,
),
),
const SizedBox(height: 6),
Text(
'${_clock(position)} / ${_clock(total)}',
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
),
],
const SizedBox(height: 2),
Text(
uidText,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => logic.act(
context,
MyPageAction.resume,
embedded: widget.embedded,
onAction: widget.onAction,
),
style: FilledButton.styleFrom(
backgroundColor: _orange,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(37),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: Text(l10n.resume),
],
),
),
TextButton(
onPressed: widget.onSignOut ?? () => _mockSnack(l10n),
style: TextButton.styleFrom(
backgroundColor: const Color(0xFF2A2C33),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: const StadiumBorder(),
),
child: Text(l10n.signOut),
),
],
),
);
);
}
Widget _wallet(AppLocalizations l10n) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF17191F),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.myAccount,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _accountTile(
label: l10n.coins,
value: '\$ 0',
action: l10n.topUpWallet,
color: AppColors.blue,
onPressed: () => _mockSnack(l10n),
),
),
const SizedBox(width: 10),
Expanded(
child: _accountTile(
label: l10n.vipMembership,
value: l10n.notActive,
action: l10n.joinVip,
color: const Color(0xFFF5C451),
onPressed: () => _mockSnack(l10n),
),
),
],
),
],
),
);
}
void _mockSnack(AppLocalizations l10n) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.mockNotice)));
}
Widget _accountTile({
required String label,
required String value,
required String action,
required Color color,
required VoidCallback onPressed,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF12141A),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: color.withValues(alpha: 0.45)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(color: color, fontSize: 12)),
const SizedBox(height: 6),
Text(
value,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: color,
foregroundColor: color == AppColors.blue
? Colors.white
: const Color(0xFF2A2110),
visualDensity: VisualDensity.compact,
shape: const StadiumBorder(),
),
child: Text(action, maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
],
),
);
}
Widget _menuCard(AppLocalizations l10n) {
return Material(
color: const Color(0xFF17191F),
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
ListTile(
leading: const Icon(
Icons.card_giftcard_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.earnRewards),
trailing: Text(
l10n.freeBonus,
style: const TextStyle(color: AppColors.blue, fontSize: 12),
),
onTap: () => _mockSnack(l10n),
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
leading: const Icon(
Icons.history_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.watchHistory),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: widget.onOpenHistory,
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
leading: const Icon(
Icons.settings_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.settings),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => logic.showLanguage(context),
),
],
),
);
}
Widget _library(
AppLocalizations l10n,
@@ -340,11 +444,14 @@ class _MyPageState extends State<MyPage> {
(logic.filter == 1 && fraction < 0.99) ||
(logic.filter == 2 && fraction >= 0.99);
final engagement = context.watch<EngagementController>();
final markedDramas = (widget.dramas ?? [widget.drama]).where((drama) {
return logic.section == 1
? engagement.isSaved(drama.id)
: engagement.isLiked(drama.id);
}).toList();
// 收藏以服务端为准(收藏列表接口返回卡片结构);点赞仍是本地状态(K3 接互动)。
final favorites = context.watch<FavoriteController>();
final isWatchlist = logic.section == 1;
final markedDramas = isWatchlist
? favorites.cards
: (widget.dramas ?? [widget.drama])
.where((drama) => engagement.isLiked(drama.id))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -354,15 +461,30 @@ class _MyPageState extends State<MyPage> {
children: [
for (var index = 0; index < tabs.length; index++) ...[
InkWell(
onTap: () => logic.selectSection(index),
child: Text(
tabs[index],
style: TextStyle(
onTap: () {
logic.selectSection(index);
// 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐)
if (index == 1) unawaited(favorites.load());
},
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: logic.section == index
? _orange
: const Color(0xFFE4DBDD),
fontSize: 16,
fontWeight: FontWeight.w800,
? AppColors.blue
: const Color(0xFF24262E),
borderRadius: BorderRadius.circular(20),
),
child: Text(
tabs[index],
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
@@ -448,7 +570,7 @@ class _MyPageState extends State<MyPage> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFFFA18D),
color: AppColors.blueSoft,
fontSize: 10,
),
),
@@ -468,6 +590,34 @@ class _MyPageState extends State<MyPage> {
),
),
)
else if (isWatchlist && favorites.loading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 22),
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
else if (isWatchlist && favorites.error != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
errorText(l10n, favorites.error, l10n.favoritesError),
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
TextButton(
onPressed: () => unawaited(favorites.load()),
child: Text(l10n.retry),
),
],
),
)
else if (logic.section != 0 && markedDramas.isNotEmpty)
for (final drama in markedDramas) ...[
_markedDramaCard(drama, l10n),
@@ -481,10 +631,90 @@ class _MyPageState extends State<MyPage> {
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
),
// 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧;
// 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。
if (logic.section == 0) ...[
const SizedBox(height: 18),
Text(
l10n.history,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 9),
if (logic.positionFailed)
Row(
children: [
Expanded(
child: Text(
l10n.progressError,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
),
TextButton(
onPressed: logic.refreshPosition,
child: Text(l10n.retry),
),
],
)
else if (logic.recentLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
else if (logic.recentError != null)
Row(
children: [
Expanded(
child: Text(
errorText(l10n, logic.recentError, l10n.progressError),
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
),
TextButton(
onPressed: () => unawaited(logic.refreshRecent()),
child: Text(l10n.retry),
),
],
)
else if (_recentDramas.isEmpty)
Text(
l10n.noStories,
style: const TextStyle(color: Color(0xFFB7B3B8)),
)
else
for (final drama in _recentDramas) ...[
_markedDramaCard(drama, l10n),
const SizedBox(height: 9),
],
],
],
);
}
/// 服务端最近观看 → 已加载目录里的卡片(按 id 匹配,去重,当前剧也算一条)。
List<Drama> get _recentDramas {
final pool = <Drama>[...?widget.dramas, widget.drama];
final result = <Drama>[];
for (final item in logic.recent) {
for (final drama in pool) {
if (drama.id == item.dramaId &&
!result.any((picked) => picked.id == drama.id)) {
result.add(drama);
}
}
}
return result;
}
Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell(
onTap: () {
if (widget.onDramaSelected != null) {
@@ -509,12 +739,7 @@ class _MyPageState extends State<MyPage> {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
drama.posterAsset,
width: 74,
height: 103,
fit: BoxFit.cover,
),
child: RemoteImage(drama.posterUrl, width: 74, height: 103),
),
const SizedBox(width: 11),
Expanded(
@@ -532,7 +757,7 @@ class _MyPageState extends State<MyPage> {
),
const SizedBox(height: 7),
Text(
l10n.episodeCount(drama.episodes.length),
l10n.episodeCount(drama.episodeCount),
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
@@ -547,49 +772,6 @@ class _MyPageState extends State<MyPage> {
),
);
Widget _preferences(AppLocalizations l10n) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.preferencesHeading,
style: const TextStyle(
color: Color(0xFFCEBBB9),
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
const SizedBox(height: 12),
Material(
color: const Color(0xFF1B1C21),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: const Icon(
Icons.translate_rounded,
color: Color(0xFFFFBC65),
),
title: Text(l10n.appLanguage),
subtitle: Text(l10n.language),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
Localizations.localeOf(context).languageCode == 'zh'
? l10n.simplifiedChinese
: l10n.english,
style: const TextStyle(color: Color(0xFFFFB2A1), fontSize: 11),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right_rounded, size: 18),
],
),
onTap: () => logic.showLanguage(context),
),
),
],
);
Widget _bottomNav(AppLocalizations l10n) => Container(
height: 63,
decoration: const BoxDecoration(
@@ -631,12 +813,7 @@ class _MyPageState extends State<MyPage> {
Widget _poster({required double width, required double height}) => ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Image.asset(
widget.drama.posterAsset,
width: width,
height: height,
fit: BoxFit.cover,
),
child: RemoteImage(widget.drama.posterUrl, width: width, height: height),
);
}
@@ -685,4 +862,4 @@ String _clock(Duration duration) {
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}';
}
const _orange = Color(0xFFFF5B3D);
const _orange = AppColors.blue;
+7 -2
View File
@@ -1,12 +1,17 @@
// Author: fruit.com.cn
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import 'player_logic.dart';
class PlayerBinding {
PlayerLogic create() => PlayerLogic(
history: SharedPreferencesWatchHistoryRepository(),
PlayerLogic create({
required WatchHistoryRepository history,
required PlaybackRepository playback,
}) => PlayerLogic(
history: history,
playback: playback,
pictureInPicture: AndroidPictureInPicture(),
);
}
+323 -120
View File
@@ -4,11 +4,17 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/play_ticket.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/models/player_port.dart';
import '../../core/data/remote/api_client.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/platform_share_service.dart';
import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.dart';
@@ -18,30 +24,42 @@ import '../../l10n/app_localizations.dart';
import '../discover/discover_logic.dart';
import '../discover/discover_view.dart';
import '../drama_detail/drama_detail_view.dart';
import '../home/home_feed_sections.dart';
import '../mine/mine_logic.dart';
import '../mine/mine_view.dart';
import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart';
class PlayerLogic extends ChangeNotifier {
PlayerLogic({required this.history, required this.pictureInPicture});
PlayerLogic({
required this.history,
required this.playback,
required this.pictureInPicture,
});
final WatchHistoryRepository history;
final PlaybackRepository playback;
final AndroidPictureInPicture pictureInPicture;
late PageController pages;
late PlaybackQueue queue;
late HomeFeedSections homeSections;
late List<int> visibleIndices;
HomeFeedCategory category = HomeFeedCategory.recommended;
int categoryRequest = 0;
late PlaybackSession session;
VideoPlayerPort? trackedPlayer;
Episode? trackedEpisode;
Drama? trackedDrama;
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
/// 进度上报节流:契约建议每 10 秒一次
Duration progressInterval = const Duration(seconds: 10);
Future<void> pendingWrite = Future<void>.value();
/// 服务端续看点:进入播放页时定位到"那一集的哪一秒"
ResumePoint? resumePoint;
bool resumeLoaded = false;
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
bool ticketLoading = false;
String? ticketError;
PlayTicket? blockedTicket;
String? blockedEpisodeId;
List<Episode> sessionEpisodes = const [];
int pageRequest = 0;
bool muted = false;
bool stoppedAtFeedEnd = false;
@@ -49,6 +67,8 @@ class PlayerLogic extends ChangeNotifier {
bool fullscreen = false;
bool pip = false;
bool pipPreparing = false;
bool screenCleared = false;
final Map<String, int> pickedEpisode = {};
bool Function() isMounted = () => false;
bool homeMode = false;
@@ -66,9 +86,30 @@ class PlayerLogic extends ChangeNotifier {
ValueChanged<Drama>? onDramaChanged;
ValueChanged<bool>? onImmersiveChanged;
bool _disposed = false;
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
bool _started = false;
bool get immersive => fullscreen || pip || pipPreparing;
bool get chromeHidden => immersive || screenCleared;
Episode displayEpisode(Drama drama, Episode fallback) {
final number = pickedEpisode[drama.id];
if (number == null) return fallback;
for (final episode in drama.episodes) {
if (episode.number == number) return episode;
}
return fallback;
}
void setScreenCleared(bool value) {
if (screenCleared == value) return;
screenCleared = value;
notify();
notifyImmersive();
}
void notify() {
if (!_disposed) notifyListeners();
}
@@ -95,21 +136,20 @@ class PlayerLogic extends ChangeNotifier {
required PlayerFactory factory,
required PlaybackCoordinator coordinator,
}) {
_started = true;
pictureInPicture.setStateListener(onPipChanged);
queue = feedDramas == null
? PlaybackQueue.series(pageDrama)
: PlaybackQueue.home(feedDramas!);
homeSections = HomeFeedSections([
for (final entry in queue.entries) entry.drama,
]);
visibleIndices = homeMode
? homeSections.indicesFor(category)
: List<int>.generate(queue.length, (index) => index);
visibleIndices = List<int>.generate(queue.length, (index) => index);
pages = PageController(initialPage: initialIndex);
// 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它)
sessionEpisodes = [for (final entry in queue.entries) entry.episode];
session = PlaybackSession(
episodes: [for (final entry in queue.entries) entry.episode],
episodes: sessionEpisodes,
factory: factory,
canPlay: (episode) => episode.isFree,
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
canPlay: (episode) => episode.videoUrl.isNotEmpty,
prepare: preparePlayer,
activate: activatePlayer,
coordinator: coordinator,
@@ -117,9 +157,88 @@ class PlayerLogic extends ChangeNotifier {
if (!active || visibleIndices.isEmpty) {
session.setRouteVisible(false);
}
unawaited(loadResumePoint());
unawaited(selectEpisode(initialIndex));
}
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
Future<void> loadResumePoint() async {
final dramaId = queue.entryAt(initialIndex).drama.id;
try {
resumePoint = await history.resumePointFor(dramaId);
} catch (_) {
// 拿不到续看位置不影响播放:从当前集开头看
resumePoint = null;
} finally {
resumeLoaded = true;
if (isMounted()) notify();
}
}
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
/// 该集是否该定位到服务端记录的位置:只有"记录里的那一集"才定位,其它集从头播。
Duration? resumePositionFor(String episodeId) {
final point = resumePoint;
if (point == null || point.episodeId != episodeId) return null;
if (point.position <= Duration.zero) return null;
return point.position;
}
/// 进度上报节流:距上次上报不足 [progressInterval] 就不报。
///
/// 切集/退出/切后台走 [saveCurrentProgress],那条路径**不受节流限制**。
bool shouldReportProgress(DateTime now) {
if (now.difference(lastProgressWrite) < progressInterval) return false;
lastProgressWrite = now;
return true;
}
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
/// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。
Future<bool> ensureTicket(int index) async {
if (index < 0 || index >= sessionEpisodes.length) return false;
final episode = sessionEpisodes[index];
final request = pageRequest;
ticketLoading = true;
ticketError = null;
notify();
try {
final ticket = await playback.ticketFor(episode.id);
if (request != pageRequest) return false;
blockedTicket = null;
blockedEpisodeId = null;
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
return true;
}
blockedTicket = ticket;
blockedEpisodeId = episode.id;
return false;
} catch (exception) {
if (request != pageRequest) return false;
// 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了
if (episode.videoUrl.isNotEmpty) {
ticketError = null;
return true;
}
ticketError = exception is ApiException
? exception.message
: 'ticketError';
return false;
} finally {
ticketLoading = false;
if (isMounted()) notify();
}
}
/// 重试当前集的票据(失败态的"重试"按钮)。
Future<void> retryTicket() async {
ticketError = null;
notify();
unawaited(selectEpisode(session.currentIndex));
}
void onPageUpdated(PlayerPage previous) {
if (previous.active != active) {
session.setRouteVisible(active && visibleIndices.isNotEmpty);
@@ -137,8 +256,10 @@ class PlayerLogic extends ChangeNotifier {
if (isMounted() && backRequest == request) {
if (fullscreen) {
unawaited(setFullscreen(false));
} else if (screenCleared) {
setScreenCleared(false);
} else {
shiftCategory(-1);
onMenu?.call();
}
}
});
@@ -147,6 +268,14 @@ class PlayerLogic extends ChangeNotifier {
void onLifecycle(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress());
if (state == AppLifecycleState.resumed) {
// 签名地址有有效期:回前台时若票据已过期,重新取一张再起播,
// 否则会撞上 403(契约 §1.4)
final episode = trackedEpisode;
if (episode != null && !playback.isFresh(episode.id)) {
unawaited(selectEpisode(session.currentIndex));
}
}
session.setAppResumed(
state == AppLifecycleState.resumed || pip || pipPreparing,
);
@@ -165,14 +294,16 @@ class PlayerLogic extends ChangeNotifier {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
}
unawaited(saveCurrentProgress());
session.setRouteVisible(false);
cancelScrub();
unawaited(session.close());
pages.dispose();
if (_started) {
session.setRouteVisible(false);
cancelScrub();
unawaited(session.close());
pages.dispose();
}
super.dispose();
}
void notifyImmersive() => onImmersiveChanged?.call(immersive);
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
Future<void> setFullscreen(bool value) async {
if (fullscreen == value || !isMounted()) return;
@@ -194,7 +325,7 @@ class PlayerLogic extends ChangeNotifier {
} catch (_) {
if (value && isMounted()) {
fullscreen = false;
notify();
notify();
notifyImmersive();
}
}
@@ -228,7 +359,7 @@ class PlayerLogic extends ChangeNotifier {
session.setAppResumed(true);
} else {
pipPreparing = false;
notify();
notify();
notifyImmersive();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)),
@@ -250,23 +381,18 @@ class PlayerLogic extends ChangeNotifier {
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
if (player is! VideoPlayerPort) return;
final drama = queue.entries
.firstWhere((e) => e.episode.id == episode.id)
.drama;
try {
await pendingWrite;
final saved = await history.positionFor(
demoProfileId,
drama.id,
episode.id,
);
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
if (resumePoint == null) await loadResumePoint();
final saved = resumePositionFor(episode.id);
if (saved != null) {
await player.controller.seekTo(
clampResumePosition(saved, player.controller.value.duration),
);
}
} catch (_) {
// A local preference failure does not prevent playback.
// 续看位置取不到不影响播放:从头播。
}
}
@@ -289,7 +415,13 @@ class PlayerLogic extends ChangeNotifier {
if (player.controller.value.isCompleted && !completionProgressSaved) {
completionProgressSaved = true;
unawaited(
queueProgress(drama, episode, player.controller.value.duration),
queueProgress(
drama,
episode,
player.controller.value.duration,
duration: player.controller.value.duration,
finished: true,
),
);
}
if (!completionHandled &&
@@ -299,11 +431,7 @@ class PlayerLogic extends ChangeNotifier {
completionHandled = true;
unawaited(advanceAfterEnd(player, index));
}
final now = DateTime.now();
if (now.difference(lastProgressWrite) < const Duration(seconds: 5)) {
return;
}
lastProgressWrite = now;
if (!shouldReportProgress(DateTime.now())) return;
unawaited(
queueProgress(drama, episode, player.controller.value.position),
);
@@ -329,17 +457,30 @@ class PlayerLogic extends ChangeNotifier {
curve: Curves.easeOutCubic,
);
} catch (_) {
// A category change can detach the old page controller mid-animation.
// The page can leave the tree before the animation finishes.
}
}
Future<void> queueProgress(Drama drama, Episode episode, Duration position) {
/// 进度上报:节流由调用方控制(10 秒),切集/退出/切后台都会补一次。
Future<void> queueProgress(
Drama drama,
Episode episode,
Duration position, {
Duration? duration,
bool finished = false,
}) {
pendingWrite = pendingWrite
.then(
(_) => history.save(demoProfileId, drama.id, episode.id, position),
(_) => history.save(
episodeId: episode.id,
position: position,
duration: duration,
finished: finished,
),
)
.catchError((Object _) {
// Playback continues if local history cannot be stored.
// 进度上报失败不影响播放(下次节流点会再报一次)。
return null;
});
return pendingWrite;
}
@@ -354,7 +495,12 @@ class PlayerLogic extends ChangeNotifier {
!player.controller.value.isInitialized) {
return pendingWrite;
}
return queueProgress(drama, episode, player.controller.value.position);
return queueProgress(
drama,
episode,
player.controller.value.position,
duration: player.controller.value.duration,
);
}
Future<void> selectEpisode(int index) async {
@@ -363,6 +509,10 @@ class PlayerLogic extends ChangeNotifier {
// Capture the old position synchronously, persist without blocking a swipe.
unawaited(saveCurrentProgress());
if (request != pageRequest || !isMounted()) return;
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
final ready = await ensureTicket(index);
if (request != pageRequest || !isMounted()) return;
if (!ready && ticketError != null) return;
await session.select(index, preloadIndex: nextVisibleIndex(index));
}
@@ -373,49 +523,6 @@ class PlayerLogic extends ChangeNotifier {
: null;
}
void shiftCategory(int offset) {
if (!homeMode) return;
final next = category.index + offset;
if (next < 0) {
onMenu?.call();
return;
}
if (next >= HomeFeedCategory.values.length) return;
unawaited(selectCategory(HomeFeedCategory.values[next]));
}
Future<void> selectCategory(HomeFeedCategory next) async {
if (!homeMode || next == category) return;
final request = ++categoryRequest;
++pageRequest;
session.setRouteVisible(false);
cancelScrub();
await saveCurrentProgress();
if (!isMounted() || request != categoryRequest) return;
final oldPages = pages;
final indices = homeSections.indicesFor(next);
category = next;
visibleIndices = indices;
pages = PageController();
notify();
WidgetsBinding.instance.addPostFrameCallback((_) => oldPages.dispose());
if (indices.isEmpty) return;
onDramaChanged?.call(queue.entryAt(indices.first).drama);
await session.select(
indices.first,
preloadIndex: nextVisibleIndex(indices.first),
);
if (isMounted() && request == categoryRequest) {
if (stoppedAtFeedEnd) {
stoppedAtFeedEnd = false;
session.setUserWantsPlay(true);
}
session.setRouteVisible(active);
}
}
void beginScrub(VideoPlayerPort player) {
if (session.active != player) return;
scrubbingPlayer = player;
@@ -428,7 +535,11 @@ class PlayerLogic extends ChangeNotifier {
session.setOverlayOpen(false);
}
Future<void> seekTo(BuildContext context, VideoPlayerPort player, Duration position) async {
Future<void> seekTo(
BuildContext context,
VideoPlayerPort player,
Duration position,
) async {
if (scrubbingPlayer != player || session.active != player) return;
try {
await session.playbackSettled;
@@ -466,12 +577,31 @@ class PlayerLogic extends ChangeNotifier {
if (isMounted()) session.setRouteVisible(active);
}
Future<void> openSeriesAt(
BuildContext context,
Drama drama,
int index,
) async {
session.setRouteVisible(false);
await session.playbackSettled;
await saveCurrentProgress();
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(drama: drama, initialIndex: index),
),
);
if (isMounted()) session.setRouteVisible(active);
}
Future<void> showEpisodes(BuildContext context) async {
if (feedDramas != null) {
await openDetail(context);
return;
}
final l10n = AppLocalizations.of(context)!;
final entry = queue.entryAt(session.currentIndex);
final drama = await _withFullEpisodes(context, entry.drama);
if (!context.mounted) return;
final homeFeed = feedDramas != null;
final episodes = drama.episodes;
final currentNumber = entry.episode.number;
session.setOverlayOpen(true);
await session.playbackSettled;
if (!context.mounted) {
@@ -481,49 +611,57 @@ class PlayerLogic extends ChangeNotifier {
try {
await showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF202024),
backgroundColor: const Color(0xFF16171C),
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.6,
height: MediaQuery.sizeOf(sheetContext).height * 0.46,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Text(
l10n.episodes,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Text(
localizedDramaTitle(l10n, drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFB7B3B8),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
Expanded(
child: ListView.builder(
itemCount: queue.length,
itemBuilder: (context, index) {
final episode = queue.entryAt(index).episode;
return ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFF3A3030),
child: Text('${episode.number}'),
child: GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.45,
),
title: Text(l10n.episodeLabel(episode.number)),
subtitle: Text(l10n.freeTestMedia),
trailing: const Icon(Icons.play_arrow_rounded),
onTap: () {
Navigator.of(sheetContext).pop();
unawaited(
pages.animateToPage(
index,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
},
);
},
itemCount: episodes.length,
itemBuilder: (_, index) => _episodeCell(
context,
sheetContext,
drama,
episodes[index],
index,
homeFeed,
currentNumber,
),
),
),
],
@@ -536,6 +674,68 @@ class PlayerLogic extends ChangeNotifier {
}
}
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
/// 取不到(或页面外没有目录来源)就退回卡片本身。
Future<Drama> _withFullEpisodes(BuildContext context, Drama drama) async {
if (drama.detailLoaded) return drama;
final CatalogController catalog;
try {
catalog = context.read<CatalogController>();
} catch (_) {
return drama;
}
try {
return await catalog.requireDetail(drama);
} catch (_) {
return drama;
}
}
Widget _episodeCell(
BuildContext pageContext,
BuildContext sheetContext,
Drama drama,
Episode episode,
int index,
bool homeFeed,
int currentNumber,
) {
final selected = episode.number == currentNumber;
return Material(
color: selected ? const Color(0xFF2F7BFF) : const Color(0xFF2A2C33),
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
Navigator.of(sheetContext).pop();
if (homeFeed) {
if (episode.number == currentNumber) return;
unawaited(openSeriesAt(pageContext, drama, index));
return;
}
if (index == session.currentIndex) return;
unawaited(
pages.animateToPage(
index,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
},
child: Center(
child: Text(
'${episode.number}',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
Future<void> showMy(BuildContext context, {int initialSection = 0}) async {
if (embedded) {
onMy?.call();
@@ -576,7 +776,10 @@ class PlayerLogic extends ChangeNotifier {
await showDiscover(context, focusSearch: true);
}
Future<void> showDiscover(BuildContext context, {bool focusSearch = false}) async {
Future<void> showDiscover(
BuildContext context, {
bool focusSearch = false,
}) async {
if (embedded) {
onDiscover?.call();
return;
@@ -603,7 +806,8 @@ class PlayerLogic extends ChangeNotifier {
await openDetail(context);
} else if (action == DiscoverPageAction.my ||
action == DiscoverPageAction.watchlist) {
await showMy(context,
await showMy(
context,
initialSection: action == DiscoverPageAction.watchlist ? 1 : 0,
);
} else {
@@ -663,5 +867,4 @@ class PlayerLogic extends ChangeNotifier {
session.setOverlayOpen(false);
}
}
}
File diff suppressed because it is too large. Load diff
+19 -23
View File
@@ -24,7 +24,6 @@ class SeekProgressBar extends StatefulWidget {
this.onScrubStart,
this.onSeek,
this.showTimes = true,
this.compactIdle = false,
});
final ValueListenable<VideoPlayerValue>? valueListenable;
@@ -32,7 +31,6 @@ class SeekProgressBar extends StatefulWidget {
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
final bool showTimes;
final bool compactIdle;
@override
State<SeekProgressBar> createState() => _SeekProgressBarState();
@@ -101,20 +99,32 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.showTimes && scrubbing)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${_format(previewPosition)} / ${_format(video.duration)}',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
),
SizedBox(
height: widget.compactIdle && !scrubbing ? 20 : 36,
height: scrubbing ? 48 : 44,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: widget.compactIdle && !scrubbing ? 2 : 3,
activeTrackColor: const Color(0xFFFF5C37),
trackHeight: scrubbing ? 4 : 3,
activeTrackColor: const Color(0xFF2F7BFF),
inactiveTrackColor: Colors.white30,
thumbColor: Colors.white,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: widget.compactIdle && !scrubbing ? 0 : 6,
),
overlayShape: RoundSliderOverlayShape(
overlayRadius: widget.compactIdle && !scrubbing ? 0 : 11,
enabledThumbRadius: scrubbing ? 8 : 6,
),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
),
child: Semantics(
label: widget.semanticsLabel,
@@ -136,20 +146,6 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
),
),
),
if (widget.showTimes && (!widget.compactIdle || scrubbing))
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_format(previewPosition),
style: const TextStyle(fontSize: 12, color: Colors.white),
),
Text(
_format(video.duration),
style: const TextStyle(fontSize: 12, color: Colors.white),
),
],
),
],
);
}
+119 -80
View File
@@ -6,7 +6,7 @@ packages:
description:
name: archive
sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.5.2"
args:
@@ -14,7 +14,7 @@ packages:
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
async:
@@ -22,7 +22,7 @@ packages:
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.1"
boolean_selector:
@@ -30,7 +30,7 @@ packages:
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
characters:
@@ -38,7 +38,7 @@ packages:
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
checked_yaml:
@@ -46,7 +46,7 @@ packages:
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.4"
cli_util:
@@ -54,23 +54,23 @@ packages:
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
version: "1.1.3"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
csslib:
@@ -78,7 +78,7 @@ packages:
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.2"
cupertino_icons:
@@ -86,7 +86,7 @@ packages:
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.9"
dio:
@@ -94,7 +94,7 @@ packages:
description:
name: dio
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.11.1"
dio_web_adapter:
@@ -102,7 +102,7 @@ packages:
description:
name: dio_web_adapter
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.2"
fake_async:
@@ -110,7 +110,7 @@ packages:
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.3"
ffi:
@@ -118,7 +118,7 @@ packages:
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
file:
@@ -126,7 +126,7 @@ packages:
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
flutter:
@@ -134,12 +134,17 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.14.4"
flutter_lints:
@@ -147,7 +152,7 @@ packages:
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.0.0"
flutter_localizations:
@@ -165,12 +170,17 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
html:
dependency: transitive
description:
name: html
sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.15.7"
http_parser:
@@ -178,7 +188,7 @@ packages:
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
image:
@@ -186,23 +196,28 @@ packages:
description:
name: image
sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.12.0"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.20.2"
version: "0.20.3"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.12.0"
leak_tracker:
@@ -210,7 +225,7 @@ packages:
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
@@ -218,7 +233,7 @@ packages:
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.10"
leak_tracker_testing:
@@ -226,7 +241,7 @@ packages:
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.2"
lints:
@@ -234,39 +249,39 @@ packages:
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.19"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.18.0"
version: "1.19.0"
mime:
dependency: transitive
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.0"
nested:
@@ -274,7 +289,7 @@ packages:
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
path:
@@ -282,7 +297,7 @@ packages:
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider_linux:
@@ -290,7 +305,7 @@ packages:
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
@@ -298,7 +313,7 @@ packages:
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.3"
path_provider_windows:
@@ -306,7 +321,7 @@ packages:
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
platform:
@@ -314,7 +329,7 @@ packages:
description:
name: platform
sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.0"
plugin_platform_interface:
@@ -322,7 +337,7 @@ packages:
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
posix:
@@ -330,15 +345,23 @@ packages:
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.5.2"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.6"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.5+1"
shared_preferences:
@@ -346,7 +369,7 @@ packages:
description:
name: shared_preferences
sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.6"
shared_preferences_android:
@@ -354,7 +377,7 @@ packages:
description:
name: shared_preferences_android
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.28"
shared_preferences_foundation:
@@ -362,7 +385,7 @@ packages:
description:
name: shared_preferences_foundation
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.7"
shared_preferences_linux:
@@ -370,7 +393,7 @@ packages:
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
@@ -378,7 +401,7 @@ packages:
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
shared_preferences_web:
@@ -386,7 +409,7 @@ packages:
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
shared_preferences_windows:
@@ -394,7 +417,7 @@ packages:
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
sky_engine:
@@ -407,23 +430,23 @@ packages:
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.12.1"
version: "1.12.2"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
string_scanner:
@@ -431,47 +454,55 @@ packages:
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.3.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.11"
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
version: "2.4.3"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "07ae1e03f2d4c8e2e9a75ee678f90acbc4506d4458a9c628512d119a1eee5754"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.14.1"
video_player_android:
@@ -479,7 +510,7 @@ packages:
description:
name: video_player_android
sha256: d27054dea34d748a44f06d433a57005d2aac69944485b0abbaed3303dbcfa3f1
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.12.2"
video_player_avfoundation:
@@ -487,7 +518,7 @@ packages:
description:
name: video_player_avfoundation
sha256: "4f8a845173377ab250a50f9b9dfe4a43500cc76a7c49dd8410d02d8fc29dfd8b"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.12.1"
video_player_platform_interface:
@@ -495,7 +526,7 @@ packages:
description:
name: video_player_platform_interface
sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.9.0"
video_player_web:
@@ -503,31 +534,39 @@ packages:
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78"
url: "https://pub.dev"
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.4.0"
version: "15.3.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
yaml:
@@ -535,7 +574,7 @@ packages:
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.4"
sdks:
+13 -12
View File
@@ -1,7 +1,7 @@
# Author: fruit.com.cn
name: nontonki
description: "Nontonki short-drama playback prototype."
name: fruitshorts
description: "FruitShorts short-drama playback app (Indonesia first)."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -36,10 +36,11 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# 网络层用 dio(统一拦截器/超时/错误映射);纯 Dart 无原生插件,仍然不引代码生成与图片缓存库
dio: ^5.7.0
provider: ^6.1.5+1
video_player: ^2.14.0
shared_preferences: ^2.5.5
dio: ^5.7.0
flutter_localizations:
sdk: flutter
@@ -47,6 +48,11 @@ dev_dependencies:
flutter_test:
sdk: flutter
# 设备侧集成测试:真机/模拟器进程里真实发请求,验证"设备能不能连到开发机后端"
# (红线第 10 条:设备验证必须设备侧取证,不能用开发机 curl 充数)
integration_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
@@ -62,13 +68,8 @@ dev_dependencies:
# The following section is specific to Flutter packages.
flutter:
generate: true
assets:
- assets/media/demo_clip.mp4
- assets/images/demo_poster.jpg
- assets/images/stitch_signal_poster_clean.png
- assets/images/stitch_city_poster_clean.png
- assets/images/stitch_vengeance_poster_clean.png
- assets/images/stitch_rain_scene.jpg
# 封面与视频全部由后端下发(/files/**),本地不再打包演示素材:
# 原先 assets/media 下的演示视频有 316MB,会把安装包撑到无法接受的大小。
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
@@ -109,7 +110,7 @@ flutter:
flutter_launcher_icons:
android: true
ios: true
image_path: assets/icon/nontonki.png
image_path: assets/icon/fruitshorts.png
adaptive_icon_background: "#D74A7D"
adaptive_icon_foreground: assets/icon/nontonki.png
adaptive_icon_foreground: assets/icon/fruitshorts.png
remove_alpha_ios: true
+168
View File
@@ -0,0 +1,168 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import '../support/fake_http_adapter.dart';
/// 用假的 dio adapter 覆盖四条关键路径:注册成功、401 → 刷新 → 重试、刷新失败 → 清会话、登出。
///
/// 网络层从 http 换成 dio 后,这里的**断言口径一条没变**,只是注入方式从
/// `http.testing.MockClient` 换成 `FakeHttpAdapter`。
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
String tokenBody({
required String accessToken,
required String refreshToken,
String uid = 'U1',
}) {
return jsonEncode({
'code': 0,
'message': 'ok',
'data': {
'accessToken': accessToken,
'refreshToken': refreshToken,
'expiresIn': 7200,
'user': {'uid': uid, 'nickname': 'Tester', 'avatarUrl': ''},
},
});
}
ApiClient clientWith(
AuthController Function() auth, {
required FakeHttpAdapter adapter,
}) {
final store = AuthSessionStore();
return ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
accessTokenReader: () => auth().accessToken,
tokenRefresher: () => auth().refresh(),
onUnauthorized: () => auth().clear(),
deviceIdReader: store.deviceId,
);
}
test('should_store_session_and_expose_signed_in_state_when_register_succeeds', () async {
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
expect(options.uri.path, '/app/auth/register');
expect(options.headers['X-Device-Id'], isNotEmpty);
expect(options.headers.containsKey('Authorization'), isFalse);
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
expect(auth.isSignedIn, isFalse);
await auth.register(email: 'a@b.com', password: 'abcd1234');
expect(auth.isSignedIn, isTrue);
expect(auth.accessToken, 'a1');
expect(auth.user?.nickname, 'Tester');
// 落盘:新建一个 store 也能读回来
expect((await AuthSessionStore().read())?.refreshToken, 'r1');
});
test('should_refresh_once_and_retry_with_new_token_when_response_code_is_401', () async {
var calls = 0;
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
calls++;
if (options.uri.path == '/app/auth/refresh') {
return FakeHttpResponse(tokenBody(accessToken: 'a2', refreshToken: 'r2'));
}
if (options.uri.path != '/app/me') {
// 注册等其它请求:直接发第一版令牌
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
}
final token = options.headers['Authorization'];
if (token == 'Bearer a1') {
return FakeHttpResponse(
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
);
}
expect(token, 'Bearer a2');
return FakeHttpResponse(
jsonEncode({
'code': 0,
'message': 'ok',
'data': {'hit': true},
}),
);
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
await auth.register(email: 'a@b.com', password: 'abcd1234');
calls = 0;
final api = clientWith(() => auth, adapter: adapter);
final data = await api.get('/app/me');
expect((data as Map)['hit'], isTrue);
expect(calls, 3); // 原请求(401) + 刷新 + 重试
expect(auth.accessToken, 'a2');
});
test('should_clear_session_when_refresh_itself_fails', () async {
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
if (options.uri.path == '/app/auth/refresh') {
return FakeHttpResponse(
jsonEncode({'code': 401, 'message': '登录已过期', 'data': null}),
);
}
if (options.uri.path == '/app/auth/register') {
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
}
return FakeHttpResponse(
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
);
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
await auth.register(email: 'a@b.com', password: 'abcd1234');
final api = clientWith(() => auth, adapter: adapter);
await expectLater(api.get('/app/me'), throwsA(isA<ApiException>()));
expect(auth.isSignedIn, isFalse);
expect(await AuthSessionStore().read(), isNull);
});
test('should_clear_local_session_when_sign_out_hits_server_error', () async {
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
if (options.uri.path == '/app/auth/logout') {
return FakeHttpResponse('{}', 500);
}
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
await auth.register(email: 'a@b.com', password: 'abcd1234');
await auth.signOut();
expect(auth.isSignedIn, isFalse);
expect(await AuthSessionStore().read(), isNull);
});
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/auth/auth_view.dart';
import '../support/fake_http_adapter.dart';
/// 登录/注册页的可读证据:校验提示 + 登录/注册切换 + 未登录时不会去发请求。
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
Future<void> pumpAuthPage(WidgetTester tester, {bool signUp = false}) async {
final auth = AuthController(
AuthApi(
// 这个测试不联网:请求一律 500,正好用于验证"本地校验没过就不发请求"
ApiClient(
baseUrl: 'http://127.0.0.1:1',
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
),
),
AuthSessionStore(),
);
await tester.pumpWidget(
ChangeNotifierProvider<AuthController>.value(
value: auth,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: AuthPage(signUpMode: signUp),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', (tester) async {
await pumpAuthPage(tester);
expect(find.text('Welcome back'), findsOneWidget);
await tester.tap(find.byType(FilledButton));
await tester.pumpAndSettle();
expect(find.text('Enter your email'), findsOneWidget);
expect(find.text('Welcome back'), findsOneWidget);
});
testWidgets('should_reject_weak_password_locally_when_only_letters_are_typed', (tester) async {
await pumpAuthPage(tester);
await tester.enterText(find.byType(TextField).first, 'a@b.com');
await tester.enterText(find.byType(TextField).last, 'abcdefgh');
await tester.tap(find.byType(FilledButton));
await tester.pumpAndSettle();
expect(find.text('Password needs both letters and numbers'), findsOneWidget);
});
testWidgets('should_flip_between_sign_in_and_sign_up_when_switch_link_is_tapped', (tester) async {
await pumpAuthPage(tester);
expect(find.text('Welcome back'), findsOneWidget);
await tester.tap(find.text('New here? Create an account'));
await tester.pumpAndSettle();
expect(find.text('Create your account'), findsOneWidget);
expect(find.text('Already have an account? Sign in'), findsOneWidget);
});
}
+82 -25
View File
@@ -1,41 +1,98 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
import 'package:nontonki/core/data/models/drama.dart';
import 'package:nontonki/core/data/services/catalog_controller.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
void main() {
test(
'empty catalog is distinct from a failed load, and retry recovers',
() async {
final repo = ScriptedCatalogRepository();
final controller = CatalogController(repo);
await controller.load();
expect(controller.dramas, isEmpty);
expect(controller.error, isNull);
test('should_distinguish_empty_catalog_from_load_failure_when_retry_succeeds', () async {
final repository = ScriptedCatalogRepository();
final controller = CatalogController(repository);
repo.fail = true;
await controller.load();
expect(controller.error, isNotNull);
await controller.load();
expect(controller.dramas, isEmpty);
expect(controller.error, isNull);
repository.fail = true;
await controller.load();
expect(controller.error, isNotNull);
repository.fail = false;
repository.feed = HomeFeed(
sections: [
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
],
);
await controller.load();
expect(controller.error, isNull);
expect(controller.dramas, hasLength(1));
expect(controller.sections.single.title, '热门推荐');
});
test('should_request_drama_detail_once_when_it_is_loaded_on_demand', () async {
final repository = ScriptedCatalogRepository();
final controller = CatalogController(repository);
repository.feed = HomeFeed(
sections: [
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
],
);
await controller.load();
final card = controller.dramas.single;
expect(card.episodes, isEmpty);
final detail = await controller.requireDetail(card);
expect(detail.episodes, hasLength(2));
expect(repository.detailCalls, 1);
// 第二次已经是详情,直接复用,不再请求
await controller.requireDetail(detail);
expect(repository.detailCalls, 1);
expect(controller.dramas.single.episodes, hasLength(2));
});
repo.fail = false;
repo.items = await DemoCatalogRepository().fetchDramas();
await controller.load();
expect(controller.error, isNull);
expect(controller.dramas, hasLength(3));
expect(controller.dramas.first.episodes.length, 3);
},
);
}
Drama _card(String id) => Drama(
id: id,
title: 'Drama $id',
synopsis: '简介 $id',
posterUrl: '/files/$id.jpg',
);
Drama _detail(String id) => Drama(
id: id,
title: 'Drama $id',
synopsis: '简介 $id',
posterUrl: '/files/$id.jpg',
detailLoaded: true,
episodes: const [
Episode(id: '1', number: 1, title: '第一集', isFree: true),
Episode(id: '2', number: 2, title: '第二集', isFree: false),
],
);
class ScriptedCatalogRepository implements CatalogRepository {
bool fail = false;
List<Drama> items = const [];
HomeFeed feed = const HomeFeed();
int detailCalls = 0;
@override
Future<List<Drama>> fetchDramas() async {
Future<HomeFeed> fetchHomeFeed() async {
if (fail) throw StateError('offline');
return items;
return feed;
}
@override
Future<HomeFeed> fetchDiscoverFeed() async => feed;
@override
Future<Drama> fetchDrama(String dramaId) async {
detailCalls++;
return _detail(dramaId);
}
}
-28
View File
@@ -1,28 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('all mock entries reuse one bundled local demo video', () async {
final dramas = await DemoCatalogRepository().fetchDramas();
expect(dramas, hasLength(3));
expect(dramas.every((drama) => drama.episodes.length == 3), isTrue);
final paths = {
for (final drama in dramas)
for (final episode in drama.episodes) episode.assetPath,
};
expect(paths, {'assets/media/demo_clip.mp4'});
expect(
dramas.every(
(drama) => drama.episodes.every((episode) => episode.isFree),
),
isTrue,
);
final bytes = await rootBundle.load(paths.single);
expect(bytes.lengthInBytes, greaterThan(1000000));
});
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
void main() {
test('should_carry_one_preview_and_total_count_when_list_card_is_parsed', () {
final drama = Drama.fromCardJson(const {
'id': 5,
'title': '闪婚后',
'synopsis': 'synopsis',
'cover': '/files/cover.jpg',
'category': '都市情感',
'genre': '都市情感',
'totalEpisodes': 77,
'isHot': true,
'viewCount': 3100000,
'preview': {
'id': 9,
'number': 1,
'title': '第 1 集',
'isFree': true,
'playUrl': '/files/episode/9.mp4',
},
});
expect(drama.detailLoaded, isFalse);
expect(drama.episodes, hasLength(1));
expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4');
expect(drama.episodeCount, 77);
expect(drama.isHot, isTrue);
});
test('should_have_no_episodes_when_list_card_has_no_playable_preview', () {
final drama = Drama.fromCardJson(const {
'id': 6,
'title': '替嫁新娘',
'totalEpisodes': 12,
});
expect(drama.episodes, isEmpty);
expect(drama.episodeCount, 12);
});
test('should_count_shipped_episodes_when_detail_response_is_parsed', () {
final drama = Drama.fromDetailJson(const {
'id': 5,
'title': '闪婚后',
'synopsis': 'synopsis',
'cover': '/files/cover.jpg',
'totalEpisodes': 77,
'episodes': [
{'id': 9, 'number': 1, 'title': '第 1 集', 'isFree': true},
{'id': 10, 'number': 2, 'title': '第 2 集', 'isFree': false},
],
});
expect(drama.detailLoaded, isTrue);
expect(drama.episodeCount, 77);
expect(drama.episodes.first.id, '9');
});
}
-24
View File
@@ -1,24 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/pages/home/home_feed_sections.dart';
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
void main() {
test(
'home categories filter demo dramas without changing their media',
() async {
final dramas = await DemoCatalogRepository().fetchDramas();
final sections = HomeFeedSections(dramas);
expect(sections.indicesFor(HomeFeedCategory.recommended), [0, 1, 2]);
expect(sections.indicesFor(HomeFeedCategory.animated), [0]);
expect(sections.indicesFor(HomeFeedCategory.liveAction), [1, 2]);
expect(sections.indicesFor(HomeFeedCategory.following), isEmpty);
expect(
sections.dramaAt(HomeFeedCategory.liveAction, 0).title,
'City Lights & Velvet Nights',
);
},
);
}
+48
View File
@@ -0,0 +1,48 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
/// id-ID 文案接入的可读证据:设备语言是 `id` 时真的拿到印尼语、`id` 在支持列表里、
/// 并且品牌文案里不再有 demo 标记。
void main() {
test('should_return_indonesian_copy_when_locale_is_id', () async {
final l10n = await AppLocalizations.delegate.load(const Locale('id'));
expect(l10n.homeTab, 'Beranda');
expect(l10n.watchNow, 'Tonton gratis');
expect(l10n.watchlist, 'Daftar Tonton');
expect(l10n.coins, 'Koin');
expect(l10n.signIn, 'Masuk');
});
test('should_include_id_when_supported_locales_are_read', () {
final codes = AppLocalizations.supportedLocales
.map((locale) => locale.languageCode)
.toList();
expect(codes, containsAll(<String>['en', 'zh', 'id']));
});
test('should_drop_demo_marker_when_brand_copy_is_loaded', () async {
for (final locale in const [Locale('en'), Locale('zh'), Locale('id')]) {
final l10n = await AppLocalizations.delegate.load(locale);
expect(l10n.appName, 'FruitShorts', reason: '$locale appName');
expect(l10n.brandMark, 'FRUITSHORTS', reason: '$locale brandMark');
expect(l10n.shareFooter, 'FruitShorts', reason: '$locale shareFooter');
expect(l10n.brandMark.toLowerCase(), isNot(contains('demo')));
expect(l10n.shareFooter.toLowerCase(), isNot(contains('demo')));
}
});
test('should_keep_placeholders_when_indonesian_copy_renders', () async {
final l10n = await AppLocalizations.delegate.load(const Locale('id'));
expect(l10n.episodeLabel(3), contains('3'));
expect(l10n.episodeProgress(2, 10), contains('2'));
expect(l10n.episodeProgress(2, 10), contains('10'));
});
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/locale_controller.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
test('should_keep_device_locale_when_no_choice_is_stored', () async {
final controller = LocaleController();
var notifications = 0;
controller.addListener(() => notifications++);
await controller.load();
expect(controller.locale, isNull);
expect(notifications, 1);
});
test('should_restore_chosen_language_when_app_restarts', () async {
final first = LocaleController();
await first.select('zh');
expect(first.locale, const Locale('zh'));
final reopened = LocaleController();
await reopened.load();
expect(reopened.locale, const Locale('zh'));
});
test('should_clear_stored_chinese_preference_when_english_is_selected', () async {
await LocaleController().select('zh');
final reopened = LocaleController();
await reopened.load();
await reopened.select('en');
final third = LocaleController();
await third.load();
expect(third.locale, const Locale('en'));
});
test('should_clear_locale_when_code_is_unsupported', () async {
final controller = LocaleController();
await controller.select('zh');
expect(controller.locale, const Locale('zh'));
await controller.select('tlh');
expect(controller.locale, isNull);
});
test('should_resolve_to_device_locale_when_stored_code_is_unsupported', () async {
await SharedPreferencesAsync().setString('ui.locale', 'fr');
final controller = LocaleController();
await controller.load();
expect(controller.locale, isNull);
});
test('should_survive_load_and_select_when_preference_store_is_broken', () async {
SharedPreferencesAsyncPlatform.instance = _BrokenPreferences();
final controller = LocaleController();
await controller.load();
expect(controller.locale, isNull);
await controller.select('zh');
expect(controller.locale, const Locale('zh'));
});
}
/// Stands in for a platform whose preference store is unavailable.
base class _BrokenPreferences extends InMemorySharedPreferencesAsync {
_BrokenPreferences() : super.empty();
@override
Future<String?> getString(String key, SharedPreferencesOptions options) =>
Future<String?>.error(StateError('preferences unavailable'));
@override
Future<bool> setString(
String key,
String value,
SharedPreferencesOptions options,
) => Future<bool>.error(StateError('preferences unavailable'));
}
@@ -0,0 +1,132 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('test/pip_channel');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
AndroidPictureInPicture pip() => AndroidPictureInPicture(channel: channel);
/// Simulates a native call arriving on the channel.
Future<void> sendToDart(String method, Object? arguments) => messenger
.handlePlatformMessage(
channel.name,
const StandardMethodCodec().encodeMethodCall(
MethodCall(method, arguments),
),
(_) {},
);
tearDown(() {
debugDefaultTargetPlatformOverride = null;
channel.setMethodCallHandler(null);
});
group('platform support', () {
test('should_support_only_android_when_target_is_checked', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(pip().isPlatformSupported, isTrue);
for (final platform in <TargetPlatform>[
TargetPlatform.iOS,
TargetPlatform.macOS,
TargetPlatform.linux,
TargetPlatform.windows,
TargetPlatform.fuchsia,
]) {
debugDefaultTargetPlatformOverride = platform;
expect(
pip().isPlatformSupported,
isFalse,
reason: '$platform must not claim picture in picture support',
);
}
});
});
group('enter', () {
test('should_refuse_without_touching_channel_when_target_is_not_android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
expect(await pip().enter(), isFalse);
expect(calls, isEmpty);
});
test('should_count_as_refused_when_native_answer_is_missing', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
messenger.setMockMethodCallHandler(channel, (call) async => null);
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
expect(await pip().enter(), isFalse);
});
test('should_report_entered_when_native_call_succeeds', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
expect(await pip().enter(), isTrue);
expect(calls.single.method, 'enter');
expect(calls.single.arguments, isNull);
});
});
group('state listener', () {
test('should_deliver_pip_state_changes_in_order_when_native_events_arrive', () async {
final received = <bool>[];
pip().setStateListener(received.add);
await sendToDart('onPipChanged', true);
await sendToDart('onPipChanged', false);
expect(received, [true, false]);
});
test('should_treat_only_literal_true_as_enabled_when_native_value_arrives', () async {
final received = <bool>[];
pip().setStateListener(received.add);
await sendToDart('onPipChanged', null);
await sendToDart('onPipChanged', 'yes');
await sendToDart('onPipChanged', 1);
expect(received, [false, false, false]);
});
test('should_ignore_unrelated_native_methods_when_events_arrive', () async {
final received = <bool>[];
pip().setStateListener(received.add);
await sendToDart('somethingElse', true);
await sendToDart('onPipChanged', true);
expect(received, [true]);
});
test('should_silence_later_callbacks_when_listener_is_cleared', () async {
final received = <bool>[];
final observer = pip()..setStateListener(received.add);
await sendToDart('onPipChanged', true);
observer.setStateListener(null);
await sendToDart('onPipChanged', true);
expect(received, [true]);
});
});
}
+107
View File
@@ -0,0 +1,107 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import '../support/fake_http_adapter.dart';
/// 网络层换 dio 之后,把「对外契约」逐条钉住:解包、错误码映射、请求头、查询参数、跳过重试。
void main() {
String envelope(Object? data, {int code = 0, String message = 'ok'}) {
return jsonEncode({'code': code, 'message': message, 'data': data});
}
test('should_return_data_field_when_envelope_code_is_zero', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope({'id': 7})),
);
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
final data = await client.get('/app/dramas/7');
expect((data as Map)['id'], 7);
});
test('should_throw_api_exception_with_server_message_when_envelope_code_is_not_zero', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
envelope(null, code: 40001, message: '邮箱格式不正确'),
),
);
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
await expectLater(
client.post('/app/auth/register', body: {'target': 'bad'}),
throwsA(
isA<ApiException>()
.having((e) => e.code, 'code', 40001)
.having((e) => e.message, 'message', '邮箱格式不正确'),
),
);
});
test('should_map_to_api_exception_with_http_status_when_body_has_no_envelope', () async {
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse('oops', 502));
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
await expectLater(
client.get('/app/home/feed'),
throwsA(
isA<ApiException>().having((e) => e.code, 'code', 502),
),
);
});
test('should_send_bearer_token_and_device_header_when_both_are_available', () async {
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null)));
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
accessTokenReader: () => 'token-1',
deviceIdReader: () async => 'device-9',
);
await client.get('/app/me');
final request = adapter.requests.single;
expect(request.headers['Authorization'], 'Bearer token-1');
expect(request.headers['X-Device-Id'], 'device-9');
expect(request.headers['Accept'], 'application/json');
});
test('should_put_query_map_into_request_uri_when_query_is_given', () async {
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null)));
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
await client.get('/app/search', query: {'keyword': '短剧', 'page': '2'});
final uri = adapter.requests.single.uri;
expect(uri.path, '/app/search');
expect(uri.queryParameters['keyword'], '短剧');
expect(uri.queryParameters['page'], '2');
});
test('should_skip_refresh_when_call_opts_out_of_auth_retry', () async {
var refreshed = false;
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope(null, code: 401, message: '登录已过期')),
);
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
tokenRefresher: () async {
refreshed = true;
return true;
},
);
await expectLater(
client.post('/app/auth/refresh', body: {'refreshToken': 'r1'}, skipAuthRetry: true),
throwsA(isA<ApiException>().having((e) => e.code, 'code', 401)),
);
expect(refreshed, isFalse);
expect(adapter.requests, hasLength(1));
});
}
+103
View File
@@ -0,0 +1,103 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/auth_session.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/json_numbers.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart';
/// 后端契约(FruitServer/docs/api-phase1.md §0):`Long`/`long` 字段以字符串下发。
/// 这里钉住客户端在"收到字符串形式的 id / 计数"时照样能解析,不抛异常。
void main() {
group('jsonToInt', () {
test('should_parse_int_when_value_is_number', () {
expect(jsonToInt(12345), 12345);
expect(jsonToInt(12.9), 12);
});
test('should_parse_int_when_value_is_numeric_string', () {
expect(jsonToInt('2111012753262120962'), 2111012753262120962);
expect(jsonToInt(' 42 '), 42);
});
test('should_fall_back_when_value_is_missing_or_invalid', () {
expect(jsonToInt(null), 0);
expect(jsonToInt(''), 0);
expect(jsonToInt('abc'), 0);
expect(jsonToInt(null, fallback: 7), 7);
});
});
group('drama model', () {
test('should_parse_string_ids_when_backend_sends_ids_as_string', () {
final drama = Drama.fromCardJson({
'id': '2111012753262120962',
'title': '闪婚后,傅先生每天都在撒糖',
'totalEpisodes': 72,
'viewCount': '3100000',
'isHot': true,
});
expect(drama.id, '2111012753262120962');
expect(drama.title, '闪婚后,傅先生每天都在撒糖');
expect(drama.totalEpisodes, 72);
// viewCount 后端是 Long → 字符串
expect(drama.viewCount, 3100000);
});
test('should_parse_view_count_when_backend_sends_number', () {
final drama = Drama.fromCardJson({'id': 5, 'title': 'T', 'viewCount': 1200});
expect(drama.id, '5');
expect(drama.viewCount, 1200);
});
test('should_parse_detail_payload_when_ids_are_strings', () {
final drama = Drama.fromDetailJson({
'id': '2111012753262120962',
'title': 'T',
'totalEpisodes': 12,
'freeEpisodes': 3,
'viewCount': '99',
'episodes': [
{'id': '2111012753262120963', 'number': 1, 'title': 'E1', 'isFree': true},
],
});
expect(drama.id, '2111012753262120962');
expect(drama.freeEpisodes, 3);
expect(drama.viewCount, 99);
expect(drama.episodes.first.id, '2111012753262120963');
expect(drama.episodes.first.number, 1);
});
});
group('auth session', () {
test('should_parse_expires_in_when_backend_sends_string', () {
final session = AuthSession.fromJson({
'accessToken': 'a',
'refreshToken': 'r',
'expiresIn': '7200',
'user': {'uid': 'u-1', 'nickname': 'n'},
});
expect(session.expiresIn, 7200);
expect(session.user.uid, 'u-1');
});
});
group('play ticket', () {
test('should_parse_episode_id_when_backend_sends_string', () {
final ticket = PlayTicket.fromJson({
'episodeId': '2111012753262120963',
'accessible': false,
'accessType': PlayTicket.coinRequired,
'unlockCoin': 30,
});
expect(ticket.episodeId, '2111012753262120963');
expect(ticket.unlockCoin, 30);
expect(ticket.needsUnlock, isTrue);
});
});
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('should_turn_cube_and_keep_title_on_poster_when_carousel_pages', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CoverCarousel(
items: [
for (final title in ['The Signal', 'City Lights', 'Vengeance'])
CoverItem(
title: title,
subtitle: 'Mock',
image: '/files/poster.png',
onOpen: _noop,
),
],
),
),
),
);
await tester.pump();
expect(find.text('The Signal'), findsOneWidget);
expect(find.text('Mock'), findsOneWidget);
final box = tester.getRect(find.byType(CoverCarousel));
final gesture = await tester.startGesture(Offset(box.right - 40, box.center.dy));
await gesture.moveBy(const Offset(-500, 0));
await tester.pump();
final turned = tester.widgetList<Transform>(find.byType(Transform)).any(
(transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15,
);
expect(turned, isTrue);
await gesture.moveBy(const Offset(-200, 0));
await gesture.up();
await tester.pump();
await tester.pump(const Duration(milliseconds: 450));
expect(find.text('City Lights'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
void _noop() {}
+344
View File
@@ -0,0 +1,344 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/discover/discover_logic.dart';
import '../support/fake_watch_history.dart';
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/poster.png',
episodes: [
for (final number in numbers)
Episode(
id: '$id-$number',
number: number,
title: 'Episode $number',
videoUrl: 'https://example.test/clip.mp4',
isFree: true,
),
],
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('should_resolve_resume_position_of_episode_one_when_constructed', () async {
final history = FakeWatchHistory()..saved = const Duration(seconds: 4);
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
expect(await logic.position, const Duration(seconds: 4));
expect(history.reads, ['signal']);
expect(logic.category, 0);
expect(logic.searchOpen, isFalse);
logic.dispose();
});
test('should_reread_resume_position_when_drama_is_swapped', () async {
final history = FakeWatchHistory();
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
logic.drama = _drama('city');
logic.refreshPosition();
await logic.position;
expect(history.reads, ['signal', 'city']);
logic.dispose();
});
test('should_resume_from_last_watch_when_list_card_has_no_episodes', () async {
final history = FakeWatchHistory()
..saved = const Duration(seconds: 9)
..lastEpisode = 'signal-2';
final logic = DiscoverLogic(
history: history,
drama: _drama('signal', numbers: const []),
);
expect(await logic.position, const Duration(seconds: 9));
expect(history.reads, ['signal']);
logic.dispose();
});
test('should_have_no_resume_position_when_server_has_no_record', () async {
final history = FakeWatchHistory();
final logic = DiscoverLogic(
history: history,
drama: _drama('signal', numbers: const []),
);
expect(await logic.position, isNull);
// 续看位置改为按剧问服务端:即使卡片没有剧集也会读一次
expect(history.reads, ['signal']);
logic.dispose();
});
test('should_store_index_and_notify_once_when_category_is_selected', () {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
var notifications = 0;
logic.addListener(() => notifications++);
logic.selectCategory(3);
expect(logic.category, 3);
expect(notifications, 1);
logic.dispose();
});
test('should_show_search_when_flag_is_on_or_text_is_typed', () {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
expect(logic.showSearch, isFalse);
logic.search.text = 'vengeance';
logic.onSearchChanged();
expect(logic.showSearch, isTrue);
logic.search.clear();
expect(logic.showSearch, isFalse);
logic.searchOpen = true;
expect(logic.showSearch, isTrue);
logic.dispose();
});
test('should_match_by_id_and_tolerate_missing_id_when_looking_up_drama', () {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
final catalog = [_drama('signal'), _drama('city')];
expect(logic.dramaFor('city', catalog)?.id, 'city');
expect(logic.dramaFor('missing', catalog), isNull);
expect(logic.dramaFor(null, catalog), isNull);
expect(logic.dramaFor('city', const []), isNull);
logic.dispose();
});
test('should_adopt_drama_only_when_page_is_active', () {
final history = FakeWatchHistory();
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
void sync(
Drama next, {
required bool active,
required bool wasActive,
required bool dramaChanged,
int searchRequest = 0,
int previousSearchRequest = 0,
}) => logic.syncDrama(
active: active,
wasActive: wasActive,
nextDrama: next,
dramaChanged: dramaChanged,
searchRequest: searchRequest,
previousSearchRequest: previousSearchRequest,
isActive: () => active,
);
// An inactive page keeps showing its own drama.
sync(_drama('city'), active: false, wasActive: true, dramaChanged: true);
expect(logic.drama.id, 'signal');
// Becoming active adopts the incoming drama.
sync(_drama('city'), active: true, wasActive: false, dramaChanged: false);
expect(logic.drama.id, 'city');
// Staying active with a changed drama re-reads history.
history.reads.clear();
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true);
expect(logic.drama.id, 'vengeance');
expect(history.reads, ['vengeance']);
// Staying active with no change leaves both drama and history alone.
history.reads.clear();
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
expect(logic.drama.id, 'vengeance');
expect(history.reads, isEmpty);
logic.dispose();
});
testWidgets('should_open_and_focus_field_when_search_is_requested_and_page_is_active', (
tester,
) async {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TextField(
controller: logic.search,
focusNode: logic.searchFocus,
),
),
),
);
logic.syncDrama(
active: true,
wasActive: true,
nextDrama: logic.drama,
dramaChanged: false,
searchRequest: 1,
previousSearchRequest: 0,
isActive: () => true,
);
expect(logic.searchOpen, isTrue);
await tester.pumpAndSettle();
expect(logic.searchFocus.hasFocus, isTrue);
await tester.pumpWidget(const SizedBox.shrink());
logic.dispose();
});
testWidgets('should_keep_search_field_closed_when_page_is_hidden', (
tester,
) async {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TextField(
controller: logic.search,
focusNode: logic.searchFocus,
),
),
),
);
// The search request branch is gated on `active`, so a page that is not on
// screen must neither reveal the field nor pull focus away from the visible tab.
logic.syncDrama(
active: false,
wasActive: false,
nextDrama: logic.drama,
dramaChanged: false,
searchRequest: 1,
previousSearchRequest: 0,
isActive: () => false,
);
expect(logic.searchOpen, isFalse);
await tester.pumpAndSettle();
expect(logic.searchFocus.hasFocus, isFalse);
await tester.pumpWidget(const SizedBox.shrink());
logic.dispose();
});
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', (
tester,
) async {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
final forwarded = <DiscoverPageAction>[];
final popped = Completer<DiscoverPageAction?>();
late BuildContext pageContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => TextButton(
onPressed: () => Navigator.of(context)
.push<DiscoverPageAction>(
MaterialPageRoute<DiscoverPageAction>(
builder: (inner) {
pageContext = inner;
return const SizedBox.shrink();
},
),
)
.then(popped.complete),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
logic.act(
pageContext,
DiscoverPageAction.home,
embedded: true,
onAction: forwarded.add,
);
expect(forwarded, [DiscoverPageAction.home]);
expect(Navigator.of(pageContext).canPop(), isTrue);
logic.act(pageContext, DiscoverPageAction.watchlist, embedded: false);
await tester.pumpAndSettle();
await expectLater(popped.future, completion(DiscoverPageAction.watchlist));
logic.dispose();
});
testWidgets('should_show_artwork_notice_when_card_has_no_playable_media', (
tester,
) async {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (inner) {
context = inner;
return const Scaffold(body: SizedBox.shrink());
},
),
),
);
logic.openCard(context, null, embedded: false);
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(context)!;
expect(find.text(l10n.artworkOnlyNotice), findsOneWidget);
logic.dispose();
});
testWidgets('should_forward_chosen_drama_when_card_is_embedded', (tester) async {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
late BuildContext context;
Drama? selected;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (inner) {
context = inner;
return const SizedBox.shrink();
},
),
),
);
logic.openCard(
context,
_drama('city'),
embedded: true,
onDramaSelected: (drama) => selected = drama,
);
expect(selected?.id, 'city');
logic.dispose();
});
test('should_do_nothing_when_notifying_after_dispose', () {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
var notifications = 0;
logic.addListener(() => notifications++);
logic.dispose();
logic.notify();
expect(notifications, 0);
});
}
@@ -0,0 +1,101 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart';
import '../support/fake_watch_history.dart';
/// 详情页逻辑依赖:票据仓库 + 收藏控制器(测试里都是内存实现)。
DramaDetailLogic _logic(Drama drama, FakeWatchHistory history) =>
DramaDetailLogic(
drama: drama,
history: history,
playback: FakePlaybackRepository(),
favorites: FavoriteController(InMemoryFavoriteRepository()),
);
Drama _card(String id) => Drama(
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/$id.jpg',
totalEpisodes: 3,
episodes: [
Episode(
id: '$id-1',
number: 1,
title: '第 1 集',
isFree: true,
videoUrl: '/files/$id-1.mp4',
),
],
);
Drama _detail(String id) => Drama(
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/$id.jpg',
totalEpisodes: 3,
detailLoaded: true,
episodes: [
for (var number = 1; number <= 3; number++)
Episode(
id: '$id-$number',
number: number,
title: '第 $number 集',
isFree: true,
videoUrl: '/files/$id-$number.mp4',
),
],
);
void main() {
test('should_upgrade_to_full_episode_list_when_detail_loads_for_list_card', () async {
var calls = 0;
final logic = _logic(_card('signal'), FakeWatchHistory());
await logic.load(() async {
calls++;
return _detail('signal');
});
expect(calls, 1);
expect(logic.loading, isFalse);
expect(logic.failed, isFalse);
expect(logic.drama.episodes, hasLength(3));
logic.dispose();
});
test('should_keep_card_and_report_reason_when_load_fails', () async {
final logic = _logic(_card('signal'), FakeWatchHistory());
await logic.load(() async => throw const ApiException(500, '服务异常'));
expect(logic.failed, isTrue);
expect(logic.failureMessage, '服务异常');
expect(logic.drama.episodes, hasLength(1));
await logic.load(() async => throw StateError('offline'));
expect(logic.failed, isTrue);
expect(logic.failureMessage, isNull);
logic.dispose();
});
test('should_skip_request_when_detail_is_already_loaded', () async {
var calls = 0;
final logic = _logic(_detail('signal'), FakeWatchHistory());
await logic.load(() async {
calls++;
return _detail('signal');
});
expect(calls, 0);
expect(logic.drama.episodes, hasLength(3));
logic.dispose();
});
}
@@ -1,13 +1,13 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/services/engagement_controller.dart';
import 'package:nontonki/core/data/repositories/engagement_repository.dart';
import 'package:fruitshorts/core/data/services/engagement_controller.dart';
import 'package:fruitshorts/core/data/repositories/engagement_repository.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
void main() {
test('saved, liked and comments are kept by drama id', () async {
test('should_keep_saved_liked_and_comments_by_drama_id_when_toggled', () async {
final repository = InMemoryEngagementRepository();
final first = EngagementController(repository);
await first.load();
@@ -30,7 +30,7 @@ void main() {
expect(reopened.isSaved('signal'), isFalse);
});
test('local engagement survives a new repository instance', () async {
test('should_survive_new_repository_instance_when_engagement_is_local', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final first = EngagementController(SharedPreferencesEngagementRepository());
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more