commit b1f6ce4bf96a2fb11642e6aa78826c3c09681e0e Author: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Tue Oct 13 16:23:08 2026 +0800 提交 Nontonki 短剧原型,准备推送到远程仓库。 Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5450a36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Local demo media and internal planning are absent from the public repo. +/assets/ +/server/ +/docs/ +/AGENTS.md + +# Local SDK state, native caches, and credentials. +/android/.kotlin/ +/android/local.properties +/android/key.properties +*.keystore +*.jks +.env +.env.* +!.env.example diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..954d671 --- /dev/null +++ b/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: android + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: ios + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2bc86e --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +一个使用 Flutter 开发的短剧播放 App 原型,面向海外用户,重点探索沉浸式刷剧、找剧和连续观看的移动端体验 + +## 主要功能 + +- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。 +- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。 +- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。 +- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。 +- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。 + +## 技术实现 + +项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。 + +## 当前说明 + +现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d87f43 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,33 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be82512 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,11 @@ +/.gradle +/captures/ +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..fe0a8a3 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.stormg.lumadrama" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.stormg.lumadrama" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // 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") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..674882b --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt new file mode 100644 index 0000000..70a2097 --- /dev/null +++ b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt @@ -0,0 +1,78 @@ +package com.stormg.lumadrama + +import android.content.Intent +import android.app.PictureInPictureParams +import android.content.res.Configuration +import android.os.Build +import android.util.Rational +import android.content.pm.PackageManager +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +class MainActivity : FlutterActivity() { + private val shareRequestCode = 9041 + private var pendingShare: MethodChannel.Result? = null + private var pipChannel: MethodChannel? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + pipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/pip") + pipChannel?.setMethodCallHandler { call, result -> + if (call.method != "enter") { + result.notImplemented() + return@setMethodCallHandler + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + !packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { + result.success(false) + return@setMethodCallHandler + } + try { + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(9, 16)) + .build() + result.success(enterPictureInPictureMode(params)) + } catch (_: Exception) { + result.success(false) + } + } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/share") + .setMethodCallHandler { call, result -> + if (call.method != "shareText") { + result.notImplemented() + return@setMethodCallHandler + } + val text = call.argument("text") + if (text.isNullOrBlank() || pendingShare != null) { + result.error("share_unavailable", "Share sheet is unavailable", null) + return@setMethodCallHandler + } + try { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + pendingShare = result + startActivityForResult(Intent.createChooser(intent, null), shareRequestCode) + } catch (error: Exception) { + pendingShare = null + result.error("share_failed", error.message, null) + } + } + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + pipChannel?.invokeMethod("onPipChanged", isInPictureInPictureMode) + } + + @Deprecated("Used for the system share chooser result") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == shareRequestCode) { + pendingShare?.success(null) + pendingShare = null + } + } +} diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 0000000..2f9eca7 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a775df4 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-hdpi/splash_icon.png b/android/app/src/main/res/drawable-hdpi/splash_icon.png new file mode 100644 index 0000000..1b55cc7 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png new file mode 100644 index 0000000..d55419e Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..689d998 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash_icon.png b/android/app/src/main/res/drawable-mdpi/splash_icon.png new file mode 100644 index 0000000..34804ef Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..63781f3 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png new file mode 100644 index 0000000..49cf10d Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7d8eb37 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash_icon.png b/android/app/src/main/res/drawable-xhdpi/splash_icon.png new file mode 100644 index 0000000..af935a6 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 0000000..09b431e Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c1c47be Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash_icon.png b/android/app/src/main/res/drawable-xxhdpi/splash_icon.png new file mode 100644 index 0000000..78ec1ce Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 0000000..391fa1a Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4583ed4 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash_icon.png b/android/app/src/main/res/drawable-xxxhdpi/splash_icon.png new file mode 100644 index 0000000..8fde7d4 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..63781f3 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..4216099 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..1807efe Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..7b131e3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..4835758 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..24c7b41 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..1fb1734 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,9 @@ + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..6cee8d1 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,9 @@ + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..651387c --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FF000000 + #D74A7D + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..aec9973 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6833b0c --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,647 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = GFYRZJW8KX; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = GFYRZJW8KX; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = GFYRZJW8KX; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6e4e5dd --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,34 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + let channel = FlutterMethodChannel( + name: "com.stormg.lumadrama/share", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "shareText", + let arguments = call.arguments as? [String: Any], + let text = arguments["text"] as? String, + !text.isEmpty, + let controller = self?.window?.rootViewController else { + result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil)) + return + } + let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil) + activity.popoverPresentationController?.sourceView = controller.view + activity.completionWithItemsHandler = { _, _, _, _ in result(nil) } + controller.present(activity, animated: true) + } + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d0d98aa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..4395a4f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..0c177eb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..cd7eb5e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6280968 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..9e3a54a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..0bda915 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..41f2351 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..cd7eb5e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..23865da Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..54ea594 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..e408931 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..b35d3d8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..7d5894e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..72ce271 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..54ea594 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..db3cbb9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..4216099 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..4835758 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..3d76f45 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..4e8ed1f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..43a5317 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..1244d69 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..073ff0a Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..20d201f Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..75967df --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..724c986 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,69 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nontonki + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Nontonki + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..15338f2 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..ba4b636 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'core/config/locale_controller.dart'; +import 'core/data/repositories/catalog_repository.dart'; +import 'core/data/repositories/engagement_repository.dart'; +import 'core/data/services/catalog_controller.dart'; +import 'core/data/services/engagement_controller.dart'; +import 'core/data/services/playback_session.dart'; +import 'core/data/services/video_player_port.dart'; +import 'l10n/app_localizations.dart'; +import 'pages/home/home_view.dart'; + +class LumaDramaApp extends StatelessWidget { + const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new}); + + final PlayerFactory playerFactory; + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + Provider(create: (_) => PlaybackCoordinator()), + Provider(create: (_) => playerFactory), + ChangeNotifierProvider( + create: (_) => CatalogController(DemoCatalogRepository())..load(), + ), + ChangeNotifierProvider(create: (_) => LocaleController()..load()), + ChangeNotifierProvider( + create: (_) => + EngagementController(SharedPreferencesEngagementRepository()) + ..load(), + ), + ], + child: Consumer( + builder: (context, locale, _) => MaterialApp( + onGenerateTitle: (context) => AppLocalizations.of(context)!.appName, + locale: locale.locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + localeResolutionCallback: (deviceLocale, supported) { + final resolved = locale.locale ?? deviceLocale; + if (resolved == null) return supported.first; + for (final item in supported) { + if (item.languageCode == resolved.languageCode) return item; + } + return supported.first; + }, + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.system, + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6D4AFF), + ), + scaffoldBackgroundColor: const Color(0xFFF8F7FC), + ), + darkTheme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF9B86FF), + brightness: Brightness.dark, + ), + scaffoldBackgroundColor: const Color(0xFF101019), + ), + home: const HomePage(), + ), + ), + ); + } +} diff --git a/lib/core/config/locale_controller.dart b/lib/core/config/locale_controller.dart new file mode 100644 index 0000000..4def6f4 --- /dev/null +++ b/lib/core/config/locale_controller.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class LocaleController extends ChangeNotifier { + LocaleController({SharedPreferencesAsync? preferences}) + : _preferences = preferences ?? SharedPreferencesAsync(); + + final SharedPreferencesAsync _preferences; + Locale? _locale; + Locale? get locale => _locale; + + Future load() async { + try { + final code = await _preferences.getString('ui.locale'); + _locale = switch (code) { + 'en' => const Locale('en'), + 'zh' => const Locale('zh'), + _ => null, + }; + notifyListeners(); + } catch (_) { + // The system locale remains usable if preferences are unavailable. + } + } + + Future select(String code) async { + _locale = switch (code) { + 'en' => const Locale('en'), + 'zh' => const Locale('zh'), + _ => null, + }; + notifyListeners(); + try { + await _preferences.setString('ui.locale', code); + } catch (_) { + // Preference persistence is best effort for this prototype. + } + } +} diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart new file mode 100644 index 0000000..8a90835 --- /dev/null +++ b/lib/core/data/models/drama.dart @@ -0,0 +1,35 @@ +enum DramaFormat { animated, liveAction } + +class Episode { + const Episode({ + required this.id, + required this.number, + required this.title, + required this.assetPath, + required this.isFree, + }); + + final String id; + final int number; + final String title; + final String assetPath; + final bool isFree; +} + +class Drama { + const Drama({ + required this.id, + required this.title, + required this.synopsis, + required this.posterAsset, + required this.episodes, + this.format = DramaFormat.liveAction, + }); + + final String id; + final String title; + final String synopsis; + final String posterAsset; + final List episodes; + final DramaFormat format; +} diff --git a/lib/core/data/models/engagement_snapshot.dart b/lib/core/data/models/engagement_snapshot.dart new file mode 100644 index 0000000..daaaada --- /dev/null +++ b/lib/core/data/models/engagement_snapshot.dart @@ -0,0 +1,11 @@ +class EngagementSnapshot { + const EngagementSnapshot({ + this.savedDramaIds = const {}, + this.likedDramaIds = const {}, + this.commentsByDrama = const {}, + }); + + final Set savedDramaIds; + final Set likedDramaIds; + final Map> commentsByDrama; +} diff --git a/lib/core/data/models/player_port.dart b/lib/core/data/models/player_port.dart new file mode 100644 index 0000000..dbc8cb8 --- /dev/null +++ b/lib/core/data/models/player_port.dart @@ -0,0 +1,6 @@ +abstract interface class PlayerPort { + Future initialize(); + Future play(); + Future pause(); + Future dispose(); +} diff --git a/lib/core/data/repositories/catalog_repository.dart b/lib/core/data/repositories/catalog_repository.dart new file mode 100644 index 0000000..e5ee180 --- /dev/null +++ b/lib/core/data/repositories/catalog_repository.dart @@ -0,0 +1,104 @@ +import '../models/drama.dart'; + +abstract interface class CatalogRepository { + Future> fetchDramas(); +} + +class DemoCatalogRepository implements CatalogRepository { + @override + Future> 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, + ), + ], + ), + ]; +} diff --git a/lib/core/data/repositories/engagement_repository.dart b/lib/core/data/repositories/engagement_repository.dart new file mode 100644 index 0000000..ec5c2d8 --- /dev/null +++ b/lib/core/data/repositories/engagement_repository.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/engagement_snapshot.dart'; + +abstract interface class EngagementRepository { + Future load(); + Future save(EngagementSnapshot snapshot); +} + +class SharedPreferencesEngagementRepository implements EngagementRepository { + SharedPreferencesEngagementRepository({SharedPreferencesAsync? preferences}) + : _preferences = preferences ?? SharedPreferencesAsync(); + + static const _key = 'engagement.v1.demo-local-v1'; + final SharedPreferencesAsync _preferences; + + @override + Future load() async { + final raw = await _preferences.getString(_key); + if (raw == null) return const EngagementSnapshot(); + try { + final json = jsonDecode(raw); + if (json is! Map) return const EngagementSnapshot(); + Set ids(Object? value) => + value is List ? value.whereType().toSet() : {}; + final comments = >{}; + final rawComments = json['comments']; + if (rawComments is Map) { + for (final entry in rawComments.entries) { + if (entry.value is List) { + comments[entry.key] = (entry.value as List) + .whereType() + .toList(); + } + } + } + return EngagementSnapshot( + savedDramaIds: ids(json['saved']), + likedDramaIds: ids(json['liked']), + commentsByDrama: comments, + ); + } catch (_) { + return const EngagementSnapshot(); + } + } + + @override + Future save(EngagementSnapshot snapshot) => _preferences.setString( + _key, + jsonEncode({ + 'saved': snapshot.savedDramaIds.toList(), + 'liked': snapshot.likedDramaIds.toList(), + 'comments': snapshot.commentsByDrama, + }), + ); +} + +class InMemoryEngagementRepository implements EngagementRepository { + EngagementSnapshot snapshot = const EngagementSnapshot(); + + @override + Future load() async => snapshot; + + @override + Future save(EngagementSnapshot value) async => snapshot = value; +} diff --git a/lib/core/data/repositories/watch_history_repository.dart b/lib/core/data/repositories/watch_history_repository.dart new file mode 100644 index 0000000..746500c --- /dev/null +++ b/lib/core/data/repositories/watch_history_repository.dart @@ -0,0 +1,69 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +const demoProfileId = 'demo-local-v1'; + +abstract interface class WatchHistoryRepository { + Future positionFor( + String profileId, + String dramaId, + String episodeId, + ); + Future lastEpisodeId(String profileId, String dramaId); + Future save( + String profileId, + String dramaId, + String episodeId, + Duration position, + ); +} + +class SharedPreferencesWatchHistoryRepository + implements WatchHistoryRepository { + SharedPreferencesWatchHistoryRepository({SharedPreferencesAsync? preferences}) + : _preferences = preferences ?? SharedPreferencesAsync(); + + final SharedPreferencesAsync _preferences; + + String _positionKey(String profile, String drama, String episode) => + 'watch.v1.$profile.$drama.$episode.positionMs'; + String _lastKey(String profile, String drama) => + 'watch.v1.$profile.$drama.lastEpisode'; + + @override + Future positionFor( + String profileId, + String dramaId, + String episodeId, + ) async { + final milliseconds = await _preferences.getInt( + _positionKey(profileId, dramaId, episodeId), + ); + return milliseconds == null ? null : Duration(milliseconds: milliseconds); + } + + @override + Future lastEpisodeId(String profileId, String dramaId) => + _preferences.getString(_lastKey(profileId, dramaId)); + + @override + Future 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); + } +} + +Duration clampResumePosition(Duration saved, Duration duration) { + if (duration <= const Duration(milliseconds: 500)) return Duration.zero; + if (saved <= Duration.zero) return Duration.zero; + final lastUsefulPosition = duration - const Duration(milliseconds: 500); + if (saved >= lastUsefulPosition) return Duration.zero; + return saved; +} diff --git a/lib/core/data/services/android_picture_in_picture.dart b/lib/core/data/services/android_picture_in_picture.dart new file mode 100644 index 0000000..12caca5 --- /dev/null +++ b/lib/core/data/services/android_picture_in_picture.dart @@ -0,0 +1,30 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Native Android activity PiP; the Flutter playback session keeps the player. +class AndroidPictureInPicture { + AndroidPictureInPicture({MethodChannel? channel}) + : _channel = channel ?? const MethodChannel('com.stormg.lumadrama/pip'); + + final MethodChannel _channel; + + bool get isPlatformSupported => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + + void setStateListener(ValueChanged? listener) { + _channel.setMethodCallHandler( + listener == null + ? null + : (call) async { + if (call.method == 'onPipChanged') { + listener(call.arguments == true); + } + }, + ); + } + + Future enter() async { + if (!isPlatformSupported) return false; + return await _channel.invokeMethod('enter') ?? false; + } +} diff --git a/lib/core/data/services/catalog_controller.dart b/lib/core/data/services/catalog_controller.dart new file mode 100644 index 0000000..c2e1b70 --- /dev/null +++ b/lib/core/data/services/catalog_controller.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; +import '../models/drama.dart'; +import '../repositories/catalog_repository.dart'; + +class CatalogController extends ChangeNotifier { + CatalogController(this.repository); + final CatalogRepository repository; + + List dramas = const []; + bool loading = false; + String? error; + + Future load() async { + loading = true; + error = null; + notifyListeners(); + try { + dramas = await repository.fetchDramas(); + } catch (_) { + error = 'The catalog could not be loaded.'; + } finally { + loading = false; + notifyListeners(); + } + } +} diff --git a/lib/core/data/services/engagement_controller.dart b/lib/core/data/services/engagement_controller.dart new file mode 100644 index 0000000..b82af4e --- /dev/null +++ b/lib/core/data/services/engagement_controller.dart @@ -0,0 +1,78 @@ +import 'package:flutter/foundation.dart'; + +import '../models/engagement_snapshot.dart'; +import '../repositories/engagement_repository.dart'; + +/// Local demo interactions, indexed by drama ID. No public/social backend. +class EngagementController extends ChangeNotifier { + EngagementController(this._repository); + + final EngagementRepository _repository; + final Set _saved = {}; + final Set _liked = {}; + final Map> _comments = {}; + Future _loadFuture = Future.value(); + Future _pendingSave = Future.value(); + + bool isSaved(String dramaId) => _saved.contains(dramaId); + bool isLiked(String dramaId) => _liked.contains(dramaId); + List commentsFor(String dramaId) => + List.unmodifiable(_comments[dramaId] ?? const []); + + Future load() => _loadFuture = _loadFromRepository(); + + Future _loadFromRepository() async { + try { + final snapshot = await _repository.load(); + _saved.addAll(snapshot.savedDramaIds); + _liked.addAll(snapshot.likedDramaIds); + for (final entry in snapshot.commentsByDrama.entries) { + _comments[entry.key] = List.of(entry.value); + } + notifyListeners(); + } catch (_) { + // Local preferences are best effort for this demo. + } + } + + Future toggleSaved(String dramaId) async { + await _loadFuture; + if (!_saved.add(dramaId)) _saved.remove(dramaId); + notifyListeners(); + await _persist(); + } + + Future toggleLiked(String dramaId) async { + await _loadFuture; + if (!_liked.add(dramaId)) _liked.remove(dramaId); + notifyListeners(); + await _persist(); + } + + Future addComment(String dramaId, String text) async { + await _loadFuture; + final clean = text.trim(); + if (clean.isEmpty) return; + (_comments[dramaId] ??= []).add( + clean.length > 300 ? clean.substring(0, 300) : clean, + ); + notifyListeners(); + await _persist(); + } + + Future _persist() { + final snapshot = EngagementSnapshot( + savedDramaIds: Set.of(_saved), + likedDramaIds: Set.of(_liked), + commentsByDrama: { + for (final entry in _comments.entries) entry.key: List.of(entry.value), + }, + ); + _pendingSave = _pendingSave + .then((_) => _repository.save(snapshot)) + .catchError((Object _) { + // Keep the current in-memory state if local persistence fails. + }); + return _pendingSave; + } +} diff --git a/lib/core/data/services/platform_share_service.dart b/lib/core/data/services/platform_share_service.dart new file mode 100644 index 0000000..e8074f0 --- /dev/null +++ b/lib/core/data/services/platform_share_service.dart @@ -0,0 +1,15 @@ +import 'package:flutter/services.dart'; + +class PlatformShareService { + const PlatformShareService(); + + static const _channel = MethodChannel('com.stormg.lumadrama/share'); + + Future shareDrama({ + required String title, + required String synopsis, + required String footer, + }) => _channel.invokeMethod('shareText', { + 'text': '$title\n$synopsis\n$footer', + }); +} diff --git a/lib/core/data/services/playback_queue.dart b/lib/core/data/services/playback_queue.dart new file mode 100644 index 0000000..4490686 --- /dev/null +++ b/lib/core/data/services/playback_queue.dart @@ -0,0 +1,28 @@ +import '../models/drama.dart'; + +/// A player session has one navigation meaning: browse dramas or browse episodes. +class PlaybackQueue { + PlaybackQueue.home(List dramas) + : entries = List.unmodifiable( + dramas + .where((drama) => drama.episodes.isNotEmpty) + .map((drama) => PlaybackEntry(drama, drama.episodes.first)), + ); + + PlaybackQueue.series(Drama drama) + : entries = List.unmodifiable( + drama.episodes.map((episode) => PlaybackEntry(drama, episode)), + ); + + final List entries; + + int get length => entries.length; + PlaybackEntry entryAt(int index) => entries[index]; + int? nextIndex(int index) => index + 1 < entries.length ? index + 1 : null; +} + +class PlaybackEntry { + const PlaybackEntry(this.drama, this.episode); + final Drama drama; + final Episode episode; +} diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart new file mode 100644 index 0000000..bc2d864 --- /dev/null +++ b/lib/core/data/services/playback_session.dart @@ -0,0 +1,360 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import '../models/drama.dart'; +import '../models/player_port.dart'; + +typedef PlayerFactory = PlayerPort Function(Episode episode); +typedef PlayerPreparation = + Future Function(Episode episode, PlayerPort player); + +/// Shared by routes: the previous session releases all native players before +/// another session allocates. The app therefore has at most two, not two per page. +class PlaybackCoordinator { + PlaybackSession? _owner; + Future _handoff = Future.value(); + + Future _acquire(PlaybackSession session, bool Function() valid) { + final result = _handoff.then((_) async { + if (!valid()) return false; + if (_owner != session) { + final previous = _owner; + await previous?._releasePlayers(); + // A failed native disposal cannot be treated as a free decoder slot. + if (previous != null && previous.livePlayerCount != 0) return false; + _owner = null; + if (!valid()) return false; + _owner = session; + } + return true; + }); + _handoff = result.then((_) {}); + return result; + } + + void _forget(PlaybackSession session) { + if (_owner == session && session.livePlayerCount == 0) _owner = null; + } +} + +/// Owns current + one prepared successor. All allocation, playback and disposal +/// operations are serialized; initialization can run while current plays. +class PlaybackSession extends ChangeNotifier { + PlaybackSession({ + required this.episodes, + required this.factory, + required this.canPlay, + this.prepare, + this.activate, + PlaybackCoordinator? coordinator, + }) : _coordinator = coordinator ?? PlaybackCoordinator(); + + final List episodes; + final PlayerFactory factory; + final bool Function(Episode) canPlay; + + /// Runs once before readiness, also for silent preloads (e.g. resume seek). + final PlayerPreparation? prepare; + + /// Runs only when a slot becomes current (e.g. progress listeners). + final PlayerPreparation? activate; + final PlaybackCoordinator _coordinator; + final Set<_PlayerSlot> _live = {}; + _PlayerSlot? _active; + _PlayerSlot? _next; + Future _operations = Future.value(); + Future _preloadTask = Future.value(); + int _generation = 0; + int _currentIndex = 0; + int? _nextIndex; + bool _selected = false; + bool _loading = false; + bool _closed = false; + bool _appResumed = true; + bool _routeVisible = true; + bool _overlayOpen = false; + bool _userWantsPlay = true; + String? _error; + + int get currentIndex => _currentIndex; + Episode get currentEpisode => episodes[_currentIndex]; + PlayerPort? get active => + _active?.index == _currentIndex ? _active?.player : null; + bool get loading => _loading; + bool get userWantsPlay => _userWantsPlay; + bool get canAutoAdvance => _mayPlay && !_loading && active != null; + bool get isLocked => !canPlay(currentEpisode); + String? get error => _error; + // Includes initialization and asynchronous disposal, until native release ends. + int get livePlayerCount => _live.length; + int? get preloadedIndex => _next?.prepared == true ? _next?.index : null; + Future get playbackSettled => _operations; + Future get preloadSettled => _preloadTask; + + bool get _visible => !_closed && _appResumed && _routeVisible; + bool get _mayPlay => _visible && !_overlayOpen && _userWantsPlay; + + /// Exposes only prepared slots so the adjacent page can mount its video texture + /// during a swipe without starting audio or registering history listeners. + PlayerPort? preparedPlayerAt(int index) { + for (final slot in [_active, _next]) { + if (slot?.index == index && slot!.prepared) return slot.player; + } + return null; + } + + Future _enqueue(Future Function() operation) { + final task = _operations.then((_) => operation()); + // A plugin failure must not poison subsequent transitions or cleanup. + _operations = task.catchError((Object error) { + if (!_closed) { + _error = error.toString(); + _loading = false; + _notify(); + } + }); + return _operations; + } + + _PlayerSlot _create(int index) { + if (_live.length >= 2) throw StateError('Playback resource limit reached'); + final slot = _PlayerSlot(index, factory(episodes[index])); + _live.add(slot); + slot.ready = () async { + try { + await slot.player.initialize(); + if (slot.retiring || _closed) return; + await prepare?.call(episodes[index], slot.player); + if (!slot.retiring && !_closed) slot.prepared = true; + } catch (error) { + slot.failure = error; + } + }(); + return slot; + } + + Future _retire(_PlayerSlot? slot) async { + if (slot == null || !_live.contains(slot)) return; + slot.retiring = true; + await slot.ready; + try { + await slot.player.pause(); + } finally { + await slot.player.dispose(); + _live.remove(slot); + } + } + + /// preloadIndex comes from the visible queue, so filtered categories and series + /// use exactly the same mechanism without warming an unrelated episode. + Future select(int index, {int? preloadIndex}) async { + if (_closed || index < 0 || index >= episodes.length) return; + final request = ++_generation; + _selected = true; + _currentIndex = index; + _nextIndex = preloadIndex; + _error = null; + _loading = preparedPlayerAt(index) == null && !isLocked; + _notify(); + if (!_visible) return; + final acquired = await _coordinator._acquire( + this, + () => _visible && request == _generation, + ); + if (!acquired) { + if (!_closed && request == _generation && _visible) { + _loading = false; + _error = 'Previous playback resources could not be released'; + _notify(); + } + return; + } + + _PlayerSlot? target; + await _enqueue(() async { + if (_closed || request != _generation) return; + final old = _active; + if (old != null && + old.index == index && + old.failure == null && + !isLocked) { + target = old; + return; + } + if (old != null) await old.player.pause(); + final warm = _next; + _active = null; + _next = null; + final hit = warm?.index == index && warm?.failure == null && !isLocked; + if (hit) { + target = _active = warm; + _loading = !warm!.prepared; + _notify(); + // Start a ready successor before awaiting slow disposal of the old one. + if (warm.prepared) await _start(warm, request); + } + await _retire(old); + if (!hit) await _retire(warm); + if (_closed || request != _generation || isLocked) return; + if (target == null) { + target = _active = _create(index); + _loading = true; + _notify(); + } + }); + final slot = target; + if (slot == null) return; + await slot.ready; + if (_closed || request != _generation || _active != slot) return; + await _enqueue(() => _start(slot, request)); + if (!_closed && request == _generation) _schedulePreload(); + } + + Future _start(_PlayerSlot slot, int request) async { + bool valid() => !_closed && request == _generation && _active == slot; + if (!valid()) return; + if (slot.failure != null) { + _error = slot.failure.toString(); + _loading = false; + _notify(); + return; + } + if (!slot.prepared) return; + if (!slot.activated) { + await activate?.call(episodes[slot.index], slot.player); + if (!valid()) return; + slot.activated = true; + } + _loading = false; + _notify(); + await _applyPlayback(slot); + } + + Future _applyPlayback(_PlayerSlot slot) async { + if (_closed || _active != slot || !slot.activated) return; + final shouldPlay = _mayPlay && slot.index == _currentIndex && !isLocked; + if (shouldPlay == slot.playing) return; + if (shouldPlay) { + await slot.player.play(); + } else { + await slot.player.pause(); + } + slot.playing = shouldPlay; + } + + void _schedulePreload() { + final request = _generation; + final index = _nextIndex; + _preloadTask = () async { + _PlayerSlot? candidate; + await _enqueue(() async { + if (_closed || request != _generation) return; + final allowed = + index != null && + index >= 0 && + index < episodes.length && + index != _currentIndex && + canPlay(episodes[index]); + if (!allowed || _next?.index != index) { + final stale = _next; + _next = null; + await _retire(stale); + } + if (!_visible || + _overlayOpen || + request != _generation || + _active?.prepared != true || + !allowed) { + return; + } + try { + candidate = _next ??= _create(index); + } catch (_) { + // Optional allocation failure must not interrupt the playing item. + } + }); + final slot = candidate; + if (slot == null) return; + await slot.ready; + if (_closed || _next != slot) return; + if (slot.failure != null) { + // Optional work fails silently. A later explicit selection retries. + await _enqueue(() async { + if (_next != slot) return; + _next = null; + await _retire(slot); + }); + } else { + _notify(); + } + }(); + } + + void setAppResumed(bool value) { + _appResumed = value; + _syncPlayback(); + } + + void setRouteVisible(bool value) { + _routeVisible = value; + _syncPlayback(); + } + + void setOverlayOpen(bool value) { + _overlayOpen = value; + _syncPlayback(); + } + + void setUserWantsPlay(bool value) { + _userWantsPlay = value; + _syncPlayback(); + _notify(); + } + + void _syncPlayback() { + if (_closed) return; + if (_visible && _selected && active == null) { + unawaited(select(_currentIndex, preloadIndex: _nextIndex)); + return; + } + final slot = _active; + if (slot != null) unawaited(_enqueue(() => _applyPlayback(slot))); + if (_visible && !_overlayOpen && !_loading) _schedulePreload(); + } + + Future _releasePlayers() { + _generation++; + return _enqueue(() async { + _active = _next = null; + _loading = false; + _notify(); + for (final slot in _live.toList()) { + await _retire(slot); + } + }); + } + + void _notify() { + if (!_closed) notifyListeners(); + } + + Future close() async { + if (_closed) return; + _closed = true; + await _releasePlayers(); + _coordinator._forget(this); + super.dispose(); + } +} + +class _PlayerSlot { + _PlayerSlot(this.index, this.player); + final int index; + final PlayerPort player; + late final Future ready; + bool prepared = false; + bool activated = false; + bool playing = false; + bool retiring = false; + Object? failure; +} diff --git a/lib/core/data/services/video_player_port.dart b/lib/core/data/services/video_player_port.dart new file mode 100644 index 0000000..b334db5 --- /dev/null +++ b/lib/core/data/services/video_player_port.dart @@ -0,0 +1,48 @@ +import 'package:video_player/video_player.dart'; + +import '../models/drama.dart'; +import '../models/player_port.dart'; + +class VideoPlayerPort implements PlayerPort { + VideoPlayerPort( + Episode episode, { + Future? closedCaptionFile, + }) : controller = VideoPlayerController.asset( + episode.assetPath, + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false), + ); + + final VideoPlayerController controller; + Future? _initialization; + bool _disposed = false; + + @override + Future initialize() { + return _initialization ??= controller.initialize().then((_) async { + if (!_disposed) await controller.setLooping(false); + }); + } + + @override + Future play() async { + if (!_disposed && controller.value.isInitialized) await controller.play(); + } + + @override + Future pause() async { + if (!_disposed && controller.value.isInitialized) await controller.pause(); + } + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + try { + await _initialization; + } catch (_) { + // The native controller still needs disposal after failed initialization. + } + await controller.dispose(); + } +} diff --git a/lib/core/formatting/localized_content.dart b/lib/core/formatting/localized_content.dart new file mode 100644 index 0000000..e651a3d --- /dev/null +++ b/lib/core/formatting/localized_content.dart @@ -0,0 +1,27 @@ +import '../../l10n/app_localizations.dart'; +import '../data/models/drama.dart'; + +String localizedDramaTitle(AppLocalizations l10n, Drama drama) => + switch (drama.id) { + 'the-signal-demo' => l10n.dramaSignalTitle, + 'city-lights-demo' => l10n.dramaCityTitle, + 'vengeance-demo' => l10n.dramaVengeanceTitle, + _ => drama.title, + }; + +String localizedDramaSynopsis(AppLocalizations l10n, Drama drama) => + switch (drama.id) { + 'the-signal-demo' => l10n.dramaSignalSynopsis, + 'city-lights-demo' => l10n.dramaCitySynopsis, + 'vengeance-demo' => l10n.dramaVengeanceSynopsis, + _ => drama.synopsis, + }; + +String localizedEpisodeTitle(AppLocalizations l10n, int number) => + switch (number) { + 1 => l10n.episodeOneTitle, + 2 => l10n.episodeTwoTitle, + 3 => l10n.episodeThreeTitle, + 4 => l10n.episodeFourTitle, + _ => l10n.episodeLabel(number), + }; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..4559e95 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,134 @@ +{ + "@@locale": "en", + "appName": "Nontonki", + "demo": "DEMO", + "homeHeadline": "Watch now", + "homeSubtitle": "Free short drama preview.", + "mediaNotice": "User-provided local demo video · Free preview", + "featuredDemo": "FEATURED · TECHNICAL DEMO", + "demoTitle": "The Signal", + "demoSynopsis": "A short local video preview. More episodes can be added when media is available.", + "homeTab": "Home", + "openQuickMenu": "Open quick menu", + "quickAccess": "Quick access", + "currentSeries": "Current series", + "discoverTab": "Discover", + "searchDiscover": "Search dramas, actors, genres...", + "featured": "Featured", + "suspense": "Suspense", + "romance": "Romance", + "actionGenre": "Action", + "filter": "Filter", + "topCharts": "Top Charts", + "newReleases": "New Releases", + "trendingMicroDramas": "Trending Micro-Dramas", + "seeAll": "See All", + "artworkOnly": "Artwork", + "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", + "videoPending": "Video pending", + "watchNow": "Watch free", + "noSearchResults": "No matching dramas.", + "demoCatalogNotice": "Three demo dramas share the same local video.", + "myTab": "My", + "localViewer": "Local Viewer", + "localDemoProfile": "Local demo profile", + "continueWatching": "Continue Watching", + "watchedPercent": "Watched {percent}%", + "@watchedPercent": {"placeholders": {"percent": {"type": "int"}}}, + "resume": "Resume", + "history": "History", + "watchlist": "Watchlist", + "likedTab": "Liked", + "all": "All", + "inProgress": "In Progress", + "completed": "Completed", + "noSavedShows": "No shows here yet.", + "preferences": "Preferences", + "appLanguage": "App Language", + "followingTab": "Following", + "animatedTab": "Animated", + "recommendedTab": "Recommended", + "noCategoryDramas": "No dramas in this category yet.", + "trendingTab": "Trending", + "liveActionTab": "Live Action", + "search": "Search", + "free": "Free", + "mute": "Mute", + "unmute": "Unmute", + "like": "Like", + "save": "Save", + "comments": "Comments", + "share": "Share", + "localOnlyComments": "Only visible on this device", + "noComments": "No comments yet.", + "writeComment": "Write a comment", + "postComment": "Post", + "shareFailed": "Could not open the share sheet.", + "nowPlaying": "Playing now", + "watchFullscreen": "Watch in landscape", + "exitFullscreen": "Exit fullscreen", + "pictureInPicture": "Picture in picture", + "pipUnavailable": "Picture in picture is unavailable on this device", + "originalTag": "Original", + "dramaTag": "Short drama", + "episodeLabel": "Episode {number}", + "@episodeLabel": {"placeholders": {"number": {"type": "int"}}}, + "exploreSeries": "Explore series", + "episodeCount": "{count} EPISODES", + "@episodeCount": {"placeholders": {"count": {"type": "int"}}}, + "technicalDemoCount": "FREE PREVIEW · {count} EPISODES", + "@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}}, + "episodes": "Episodes", + "watchEpisodeOne": "Watch episode 1", + "continueEpisode": "Continue episode {number}", + "@continueEpisode": {"placeholders": {"number": {"type": "int"}}}, + "freeTestMedia": "Same demo clip · free", + "lockedStoreUnavailable": "Locked · store unavailable", + "freeTestClip": "Free test clip", + "locked": "Locked", + "freeSamplesNotice": "Two free samples · store setup pending", + "demoTestMedia": "FREE · SAME DEMO CLIP", + "episodeProgress": "EPISODE {number} / {total}", + "@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}}, + "testVideoNotice": "Self-generated technical test video", + "lockedTitle": "Episode {number} is locked", + "@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.", + "noStories": "No stories available yet.", + "retry": "Retry", + "backToSeries": "Back to series", + "nextEpisode": "Next episode", + "pause": "Pause", + "play": "Play", + "seekVideo": "Seek video", + "seekFailed": "Could not change playback position.", + "captions": "Captions", + "captionsOff": "Captions off", + "englishCaptions": "English captions", + "chineseCaptions": "Chinese captions", + "language": "Language", + "systemLanguage": "System", + "english": "English", + "simplifiedChinese": "简体中文", + "episodeOneTitle": "First Light", + "episodeTwoTitle": "A New Frequency", + "episodeThreeTitle": "Crossing Over", + "episodeFourTitle": "After the Echo", + "dramaSignalTitle": "The Signal", + "dramaSignalSynopsis": "A demo series for vertical playback. Every episode reuses one local clip.", + "dramaCityTitle": "City Lights & Velvet Nights", + "dramaCitySynopsis": "A demo romance series. The same local video is used to try switching and resume.", + "dramaVengeanceTitle": "Vengeance Rising", + "dramaVengeanceSynopsis": "A demo action series. The same local video is used to try switching and resume.", + "dramaEchoesTitle": "Echoes of Fate", + "genreSuspenseMystery": "Suspense · Mystery", + "genreUrbanRomance": "Urban Romance", + "genreActionThriller": "Action · Thriller", + "genreDramaMystery": "Drama · Mystery", + "continueWatchingHeading": "CONTINUE WATCHING", + "preferencesHeading": "PREFERENCES", + "brandMark": "NONTONKI · DEMO", + "shareFooter": "Nontonki demo" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..3b7f19a --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,878 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('zh'), + ]; + + /// No description provided for @appName. + /// + /// In en, this message translates to: + /// **'Nontonki'** + String get appName; + + /// No description provided for @demo. + /// + /// In en, this message translates to: + /// **'DEMO'** + String get demo; + + /// No description provided for @homeHeadline. + /// + /// In en, this message translates to: + /// **'Watch now'** + String get homeHeadline; + + /// No description provided for @homeSubtitle. + /// + /// In en, this message translates to: + /// **'Free short drama preview.'** + String get homeSubtitle; + + /// No description provided for @mediaNotice. + /// + /// In en, this message translates to: + /// **'User-provided local demo video · Free preview'** + String get mediaNotice; + + /// No description provided for @featuredDemo. + /// + /// In en, this message translates to: + /// **'FEATURED · TECHNICAL DEMO'** + String get featuredDemo; + + /// No description provided for @demoTitle. + /// + /// In en, this message translates to: + /// **'The Signal'** + String get demoTitle; + + /// No description provided for @demoSynopsis. + /// + /// In en, this message translates to: + /// **'A short local video preview. More episodes can be added when media is available.'** + String get demoSynopsis; + + /// No description provided for @homeTab. + /// + /// In en, this message translates to: + /// **'Home'** + String get homeTab; + + /// No description provided for @openQuickMenu. + /// + /// In en, this message translates to: + /// **'Open quick menu'** + String get openQuickMenu; + + /// No description provided for @quickAccess. + /// + /// In en, this message translates to: + /// **'Quick access'** + String get quickAccess; + + /// No description provided for @currentSeries. + /// + /// In en, this message translates to: + /// **'Current series'** + String get currentSeries; + + /// No description provided for @discoverTab. + /// + /// In en, this message translates to: + /// **'Discover'** + String get discoverTab; + + /// No description provided for @searchDiscover. + /// + /// In en, this message translates to: + /// **'Search dramas, actors, genres...'** + String get searchDiscover; + + /// No description provided for @featured. + /// + /// In en, this message translates to: + /// **'Featured'** + String get featured; + + /// No description provided for @suspense. + /// + /// In en, this message translates to: + /// **'Suspense'** + String get suspense; + + /// No description provided for @romance. + /// + /// In en, this message translates to: + /// **'Romance'** + String get romance; + + /// No description provided for @actionGenre. + /// + /// In en, this message translates to: + /// **'Action'** + String get actionGenre; + + /// No description provided for @filter. + /// + /// In en, this message translates to: + /// **'Filter'** + String get filter; + + /// No description provided for @topCharts. + /// + /// In en, this message translates to: + /// **'Top Charts'** + String get topCharts; + + /// No description provided for @newReleases. + /// + /// In en, this message translates to: + /// **'New Releases'** + String get newReleases; + + /// No description provided for @trendingMicroDramas. + /// + /// In en, this message translates to: + /// **'Trending Micro-Dramas'** + String get trendingMicroDramas; + + /// No description provided for @seeAll. + /// + /// In en, this message translates to: + /// **'See All'** + String get seeAll; + + /// No description provided for @artworkOnly. + /// + /// In en, this message translates to: + /// **'Artwork'** + String get artworkOnly; + + /// No description provided for @artworkOnlyNotice. + /// + /// In en, this message translates to: + /// **'This is a visual catalog item. No video is available yet.'** + String get artworkOnlyNotice; + + /// No description provided for @videoPending. + /// + /// In en, this message translates to: + /// **'Video pending'** + String get videoPending; + + /// No description provided for @watchNow. + /// + /// In en, this message translates to: + /// **'Watch free'** + String get watchNow; + + /// No description provided for @noSearchResults. + /// + /// In en, this message translates to: + /// **'No matching dramas.'** + String get noSearchResults; + + /// No description provided for @demoCatalogNotice. + /// + /// In en, this message translates to: + /// **'Three demo dramas share the same local video.'** + String get demoCatalogNotice; + + /// No description provided for @myTab. + /// + /// In en, this message translates to: + /// **'My'** + String get myTab; + + /// No description provided for @localViewer. + /// + /// In en, this message translates to: + /// **'Local Viewer'** + String get localViewer; + + /// No description provided for @localDemoProfile. + /// + /// In en, this message translates to: + /// **'Local demo profile'** + String get localDemoProfile; + + /// No description provided for @continueWatching. + /// + /// In en, this message translates to: + /// **'Continue Watching'** + String get continueWatching; + + /// No description provided for @watchedPercent. + /// + /// In en, this message translates to: + /// **'Watched {percent}%'** + String watchedPercent(int percent); + + /// No description provided for @resume. + /// + /// In en, this message translates to: + /// **'Resume'** + String get resume; + + /// No description provided for @history. + /// + /// In en, this message translates to: + /// **'History'** + String get history; + + /// No description provided for @watchlist. + /// + /// In en, this message translates to: + /// **'Watchlist'** + String get watchlist; + + /// No description provided for @likedTab. + /// + /// In en, this message translates to: + /// **'Liked'** + String get likedTab; + + /// No description provided for @all. + /// + /// In en, this message translates to: + /// **'All'** + String get all; + + /// No description provided for @inProgress. + /// + /// In en, this message translates to: + /// **'In Progress'** + String get inProgress; + + /// No description provided for @completed. + /// + /// In en, this message translates to: + /// **'Completed'** + String get completed; + + /// No description provided for @noSavedShows. + /// + /// In en, this message translates to: + /// **'No shows here yet.'** + String get noSavedShows; + + /// No description provided for @preferences. + /// + /// In en, this message translates to: + /// **'Preferences'** + String get preferences; + + /// No description provided for @appLanguage. + /// + /// In en, this message translates to: + /// **'App Language'** + String get appLanguage; + + /// No description provided for @followingTab. + /// + /// In en, this message translates to: + /// **'Following'** + String get followingTab; + + /// No description provided for @animatedTab. + /// + /// In en, this message translates to: + /// **'Animated'** + String get animatedTab; + + /// No description provided for @recommendedTab. + /// + /// In en, this message translates to: + /// **'Recommended'** + String get recommendedTab; + + /// No description provided for @noCategoryDramas. + /// + /// In en, this message translates to: + /// **'No dramas in this category yet.'** + String get noCategoryDramas; + + /// No description provided for @trendingTab. + /// + /// In en, this message translates to: + /// **'Trending'** + String get trendingTab; + + /// No description provided for @liveActionTab. + /// + /// In en, this message translates to: + /// **'Live Action'** + String get liveActionTab; + + /// No description provided for @search. + /// + /// In en, this message translates to: + /// **'Search'** + String get search; + + /// No description provided for @free. + /// + /// In en, this message translates to: + /// **'Free'** + String get free; + + /// No description provided for @mute. + /// + /// In en, this message translates to: + /// **'Mute'** + String get mute; + + /// No description provided for @unmute. + /// + /// In en, this message translates to: + /// **'Unmute'** + String get unmute; + + /// No description provided for @like. + /// + /// In en, this message translates to: + /// **'Like'** + String get like; + + /// No description provided for @save. + /// + /// In en, this message translates to: + /// **'Save'** + String get save; + + /// No description provided for @comments. + /// + /// In en, this message translates to: + /// **'Comments'** + String get comments; + + /// No description provided for @share. + /// + /// In en, this message translates to: + /// **'Share'** + String get share; + + /// No description provided for @localOnlyComments. + /// + /// In en, this message translates to: + /// **'Only visible on this device'** + String get localOnlyComments; + + /// No description provided for @noComments. + /// + /// In en, this message translates to: + /// **'No comments yet.'** + String get noComments; + + /// No description provided for @writeComment. + /// + /// In en, this message translates to: + /// **'Write a comment'** + String get writeComment; + + /// No description provided for @postComment. + /// + /// In en, this message translates to: + /// **'Post'** + String get postComment; + + /// No description provided for @shareFailed. + /// + /// In en, this message translates to: + /// **'Could not open the share sheet.'** + String get shareFailed; + + /// No description provided for @nowPlaying. + /// + /// In en, this message translates to: + /// **'Playing now'** + String get nowPlaying; + + /// No description provided for @watchFullscreen. + /// + /// In en, this message translates to: + /// **'Watch in landscape'** + String get watchFullscreen; + + /// No description provided for @exitFullscreen. + /// + /// In en, this message translates to: + /// **'Exit fullscreen'** + String get exitFullscreen; + + /// No description provided for @pictureInPicture. + /// + /// In en, this message translates to: + /// **'Picture in picture'** + String get pictureInPicture; + + /// No description provided for @pipUnavailable. + /// + /// In en, this message translates to: + /// **'Picture in picture is unavailable on this device'** + String get pipUnavailable; + + /// No description provided for @originalTag. + /// + /// In en, this message translates to: + /// **'Original'** + String get originalTag; + + /// No description provided for @dramaTag. + /// + /// In en, this message translates to: + /// **'Short drama'** + String get dramaTag; + + /// No description provided for @episodeLabel. + /// + /// In en, this message translates to: + /// **'Episode {number}'** + String episodeLabel(int number); + + /// No description provided for @exploreSeries. + /// + /// In en, this message translates to: + /// **'Explore series'** + String get exploreSeries; + + /// No description provided for @episodeCount. + /// + /// In en, this message translates to: + /// **'{count} EPISODES'** + String episodeCount(int count); + + /// No description provided for @technicalDemoCount. + /// + /// In en, this message translates to: + /// **'FREE PREVIEW · {count} EPISODES'** + String technicalDemoCount(int count); + + /// No description provided for @episodes. + /// + /// In en, this message translates to: + /// **'Episodes'** + String get episodes; + + /// No description provided for @watchEpisodeOne. + /// + /// In en, this message translates to: + /// **'Watch episode 1'** + String get watchEpisodeOne; + + /// No description provided for @continueEpisode. + /// + /// In en, this message translates to: + /// **'Continue episode {number}'** + String continueEpisode(int number); + + /// No description provided for @freeTestMedia. + /// + /// In en, this message translates to: + /// **'Same demo clip · free'** + String get freeTestMedia; + + /// No description provided for @lockedStoreUnavailable. + /// + /// In en, this message translates to: + /// **'Locked · store unavailable'** + String get lockedStoreUnavailable; + + /// No description provided for @freeTestClip. + /// + /// In en, this message translates to: + /// **'Free test clip'** + String get freeTestClip; + + /// No description provided for @locked. + /// + /// In en, this message translates to: + /// **'Locked'** + String get locked; + + /// No description provided for @freeSamplesNotice. + /// + /// In en, this message translates to: + /// **'Two free samples · store setup pending'** + String get freeSamplesNotice; + + /// No description provided for @demoTestMedia. + /// + /// In en, this message translates to: + /// **'FREE · SAME DEMO CLIP'** + String get demoTestMedia; + + /// No description provided for @episodeProgress. + /// + /// In en, this message translates to: + /// **'EPISODE {number} / {total}'** + String episodeProgress(int number, int total); + + /// No description provided for @testVideoNotice. + /// + /// In en, this message translates to: + /// **'Self-generated technical test video'** + String get testVideoNotice; + + /// No description provided for @lockedTitle. + /// + /// In en, this message translates to: + /// **'Episode {number} is locked'** + String lockedTitle(int number); + + /// No description provided for @lockedBody. + /// + /// In en, this message translates to: + /// **'Unlocking will be available after store and verification setup. This demo does not process payments.'** + String get lockedBody; + + /// No description provided for @playbackError. + /// + /// In en, this message translates to: + /// **'This clip could not be played.'** + String get playbackError; + + /// No description provided for @catalogError. + /// + /// In en, this message translates to: + /// **'The catalog could not be loaded.'** + String get catalogError; + + /// No description provided for @noStories. + /// + /// In en, this message translates to: + /// **'No stories available yet.'** + String get noStories; + + /// No description provided for @retry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get retry; + + /// No description provided for @backToSeries. + /// + /// In en, this message translates to: + /// **'Back to series'** + String get backToSeries; + + /// No description provided for @nextEpisode. + /// + /// In en, this message translates to: + /// **'Next episode'** + String get nextEpisode; + + /// No description provided for @pause. + /// + /// In en, this message translates to: + /// **'Pause'** + String get pause; + + /// No description provided for @play. + /// + /// In en, this message translates to: + /// **'Play'** + String get play; + + /// No description provided for @seekVideo. + /// + /// In en, this message translates to: + /// **'Seek video'** + String get seekVideo; + + /// No description provided for @seekFailed. + /// + /// In en, this message translates to: + /// **'Could not change playback position.'** + String get seekFailed; + + /// No description provided for @captions. + /// + /// In en, this message translates to: + /// **'Captions'** + String get captions; + + /// No description provided for @captionsOff. + /// + /// In en, this message translates to: + /// **'Captions off'** + String get captionsOff; + + /// No description provided for @englishCaptions. + /// + /// In en, this message translates to: + /// **'English captions'** + String get englishCaptions; + + /// No description provided for @chineseCaptions. + /// + /// In en, this message translates to: + /// **'Chinese captions'** + String get chineseCaptions; + + /// No description provided for @language. + /// + /// In en, this message translates to: + /// **'Language'** + String get language; + + /// No description provided for @systemLanguage. + /// + /// In en, this message translates to: + /// **'System'** + String get systemLanguage; + + /// No description provided for @english. + /// + /// In en, this message translates to: + /// **'English'** + String get english; + + /// No description provided for @simplifiedChinese. + /// + /// In en, this message translates to: + /// **'简体中文'** + String get simplifiedChinese; + + /// No description provided for @episodeOneTitle. + /// + /// In en, this message translates to: + /// **'First Light'** + String get episodeOneTitle; + + /// No description provided for @episodeTwoTitle. + /// + /// In en, this message translates to: + /// **'A New Frequency'** + String get episodeTwoTitle; + + /// No description provided for @episodeThreeTitle. + /// + /// In en, this message translates to: + /// **'Crossing Over'** + String get episodeThreeTitle; + + /// No description provided for @episodeFourTitle. + /// + /// In en, this message translates to: + /// **'After the Echo'** + String get episodeFourTitle; + + /// No description provided for @dramaSignalTitle. + /// + /// In en, this message translates to: + /// **'The Signal'** + String get dramaSignalTitle; + + /// No description provided for @dramaSignalSynopsis. + /// + /// In en, this message translates to: + /// **'A demo series for vertical playback. Every episode reuses one local clip.'** + String get dramaSignalSynopsis; + + /// No description provided for @dramaCityTitle. + /// + /// In en, this message translates to: + /// **'City Lights & Velvet Nights'** + String get dramaCityTitle; + + /// No description provided for @dramaCitySynopsis. + /// + /// In en, this message translates to: + /// **'A demo romance series. The same local video is used to try switching and resume.'** + String get dramaCitySynopsis; + + /// No description provided for @dramaVengeanceTitle. + /// + /// In en, this message translates to: + /// **'Vengeance Rising'** + String get dramaVengeanceTitle; + + /// No description provided for @dramaVengeanceSynopsis. + /// + /// In en, this message translates to: + /// **'A demo action series. The same local video is used to try switching and resume.'** + String get dramaVengeanceSynopsis; + + /// No description provided for @dramaEchoesTitle. + /// + /// In en, this message translates to: + /// **'Echoes of Fate'** + String get dramaEchoesTitle; + + /// No description provided for @genreSuspenseMystery. + /// + /// In en, this message translates to: + /// **'Suspense · Mystery'** + String get genreSuspenseMystery; + + /// No description provided for @genreUrbanRomance. + /// + /// In en, this message translates to: + /// **'Urban Romance'** + String get genreUrbanRomance; + + /// No description provided for @genreActionThriller. + /// + /// In en, this message translates to: + /// **'Action · Thriller'** + String get genreActionThriller; + + /// No description provided for @genreDramaMystery. + /// + /// In en, this message translates to: + /// **'Drama · Mystery'** + String get genreDramaMystery; + + /// No description provided for @continueWatchingHeading. + /// + /// In en, this message translates to: + /// **'CONTINUE WATCHING'** + String get continueWatchingHeading; + + /// No description provided for @preferencesHeading. + /// + /// In en, this message translates to: + /// **'PREFERENCES'** + String get preferencesHeading; + + /// No description provided for @brandMark. + /// + /// In en, this message translates to: + /// **'NONTONKI · DEMO'** + String get brandMark; + + /// No description provided for @shareFooter. + /// + /// In en, this message translates to: + /// **'Nontonki demo'** + String get shareFooter; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'zh'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..ae2a353 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,404 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appName => 'Nontonki'; + + @override + String get demo => 'DEMO'; + + @override + String get homeHeadline => 'Watch now'; + + @override + String get homeSubtitle => 'Free short drama preview.'; + + @override + String get mediaNotice => 'User-provided local demo video · Free preview'; + + @override + String get featuredDemo => 'FEATURED · TECHNICAL DEMO'; + + @override + String get demoTitle => 'The Signal'; + + @override + String get demoSynopsis => + 'A short local video preview. More episodes can be added when media is available.'; + + @override + String get homeTab => 'Home'; + + @override + String get openQuickMenu => 'Open quick menu'; + + @override + String get quickAccess => 'Quick access'; + + @override + String get currentSeries => 'Current series'; + + @override + String get discoverTab => 'Discover'; + + @override + String get searchDiscover => 'Search dramas, actors, genres...'; + + @override + String get featured => 'Featured'; + + @override + String get suspense => 'Suspense'; + + @override + String get romance => 'Romance'; + + @override + String get actionGenre => 'Action'; + + @override + String get filter => 'Filter'; + + @override + String get topCharts => 'Top Charts'; + + @override + String get newReleases => 'New Releases'; + + @override + String get trendingMicroDramas => 'Trending Micro-Dramas'; + + @override + String get seeAll => 'See All'; + + @override + String get artworkOnly => 'Artwork'; + + @override + String get artworkOnlyNotice => + 'This is a visual catalog item. No video is available yet.'; + + @override + String get videoPending => 'Video pending'; + + @override + String get watchNow => 'Watch free'; + + @override + String get noSearchResults => 'No matching dramas.'; + + @override + String get demoCatalogNotice => + 'Three demo dramas share the same local video.'; + + @override + String get myTab => 'My'; + + @override + String get localViewer => 'Local Viewer'; + + @override + String get localDemoProfile => 'Local demo profile'; + + @override + String get continueWatching => 'Continue Watching'; + + @override + String watchedPercent(int percent) { + return 'Watched $percent%'; + } + + @override + String get resume => 'Resume'; + + @override + String get history => 'History'; + + @override + String get watchlist => 'Watchlist'; + + @override + String get likedTab => 'Liked'; + + @override + String get all => 'All'; + + @override + String get inProgress => 'In Progress'; + + @override + String get completed => 'Completed'; + + @override + String get noSavedShows => 'No shows here yet.'; + + @override + String get preferences => 'Preferences'; + + @override + String get appLanguage => 'App Language'; + + @override + String get followingTab => 'Following'; + + @override + String get animatedTab => 'Animated'; + + @override + String get recommendedTab => 'Recommended'; + + @override + String get noCategoryDramas => 'No dramas in this category yet.'; + + @override + String get trendingTab => 'Trending'; + + @override + String get liveActionTab => 'Live Action'; + + @override + String get search => 'Search'; + + @override + String get free => 'Free'; + + @override + String get mute => 'Mute'; + + @override + String get unmute => 'Unmute'; + + @override + String get like => 'Like'; + + @override + String get save => 'Save'; + + @override + String get comments => 'Comments'; + + @override + String get share => 'Share'; + + @override + String get localOnlyComments => 'Only visible on this device'; + + @override + String get noComments => 'No comments yet.'; + + @override + String get writeComment => 'Write a comment'; + + @override + String get postComment => 'Post'; + + @override + String get shareFailed => 'Could not open the share sheet.'; + + @override + String get nowPlaying => 'Playing now'; + + @override + String get watchFullscreen => 'Watch in landscape'; + + @override + String get exitFullscreen => 'Exit fullscreen'; + + @override + String get pictureInPicture => 'Picture in picture'; + + @override + String get pipUnavailable => + 'Picture in picture is unavailable on this device'; + + @override + String get originalTag => 'Original'; + + @override + String get dramaTag => 'Short drama'; + + @override + String episodeLabel(int number) { + return 'Episode $number'; + } + + @override + String get exploreSeries => 'Explore series'; + + @override + String episodeCount(int count) { + return '$count EPISODES'; + } + + @override + String technicalDemoCount(int count) { + return 'FREE PREVIEW · $count EPISODES'; + } + + @override + String get episodes => 'Episodes'; + + @override + String get watchEpisodeOne => 'Watch episode 1'; + + @override + String continueEpisode(int number) { + return 'Continue episode $number'; + } + + @override + String get freeTestMedia => 'Same demo clip · free'; + + @override + String get lockedStoreUnavailable => 'Locked · store unavailable'; + + @override + String get freeTestClip => 'Free test clip'; + + @override + String get locked => 'Locked'; + + @override + String get freeSamplesNotice => 'Two free samples · store setup pending'; + + @override + String get demoTestMedia => 'FREE · SAME DEMO CLIP'; + + @override + String episodeProgress(int number, int total) { + return 'EPISODE $number / $total'; + } + + @override + String get testVideoNotice => 'Self-generated technical test video'; + + @override + String lockedTitle(int number) { + return 'Episode $number is locked'; + } + + @override + String get lockedBody => + 'Unlocking will be available after store and verification setup. This demo does not process payments.'; + + @override + String get playbackError => 'This clip could not be played.'; + + @override + String get catalogError => 'The catalog could not be loaded.'; + + @override + String get noStories => 'No stories available yet.'; + + @override + String get retry => 'Retry'; + + @override + String get backToSeries => 'Back to series'; + + @override + String get nextEpisode => 'Next episode'; + + @override + String get pause => 'Pause'; + + @override + String get play => 'Play'; + + @override + String get seekVideo => 'Seek video'; + + @override + String get seekFailed => 'Could not change playback position.'; + + @override + String get captions => 'Captions'; + + @override + String get captionsOff => 'Captions off'; + + @override + String get englishCaptions => 'English captions'; + + @override + String get chineseCaptions => 'Chinese captions'; + + @override + String get language => 'Language'; + + @override + String get systemLanguage => 'System'; + + @override + String get english => 'English'; + + @override + String get simplifiedChinese => '简体中文'; + + @override + String get episodeOneTitle => 'First Light'; + + @override + String get episodeTwoTitle => 'A New Frequency'; + + @override + String get episodeThreeTitle => 'Crossing Over'; + + @override + String get episodeFourTitle => 'After the Echo'; + + @override + String get dramaSignalTitle => 'The Signal'; + + @override + String get dramaSignalSynopsis => + 'A demo series for vertical playback. Every episode reuses one local clip.'; + + @override + String get dramaCityTitle => 'City Lights & Velvet Nights'; + + @override + String get dramaCitySynopsis => + 'A demo romance series. The same local video is used to try switching and resume.'; + + @override + String get dramaVengeanceTitle => 'Vengeance Rising'; + + @override + String get dramaVengeanceSynopsis => + 'A demo action series. The same local video is used to try switching and resume.'; + + @override + String get dramaEchoesTitle => 'Echoes of Fate'; + + @override + String get genreSuspenseMystery => 'Suspense · Mystery'; + + @override + String get genreUrbanRomance => 'Urban Romance'; + + @override + String get genreActionThriller => 'Action · Thriller'; + + @override + String get genreDramaMystery => 'Drama · Mystery'; + + @override + String get continueWatchingHeading => 'CONTINUE WATCHING'; + + @override + String get preferencesHeading => 'PREFERENCES'; + + @override + String get brandMark => 'NONTONKI · DEMO'; + + @override + String get shareFooter => 'Nontonki demo'; +} diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart new file mode 100644 index 0000000..f61dc49 --- /dev/null +++ b/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,396 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get appName => 'Nontonki'; + + @override + String get demo => '演示'; + + @override + String get homeHeadline => '即刻观看'; + + @override + String get homeSubtitle => '免费短剧预览。'; + + @override + String get mediaNotice => '用户提供的本地演示视频 · 免费观看'; + + @override + String get featuredDemo => '精选 · 技术演示'; + + @override + String get demoTitle => '信号'; + + @override + String get demoSynopsis => '使用本地视频进行播放预览,后续有素材再增加剧集。'; + + @override + String get homeTab => '首页'; + + @override + String get openQuickMenu => '打开快捷菜单'; + + @override + String get quickAccess => '快捷入口'; + + @override + String get currentSeries => '当前短剧'; + + @override + String get discoverTab => '找剧'; + + @override + String get searchDiscover => '搜索短剧、演员、类型…'; + + @override + String get featured => '精选'; + + @override + String get suspense => '悬疑'; + + @override + String get romance => '都市爱情'; + + @override + String get actionGenre => '动作'; + + @override + String get filter => '筛选'; + + @override + String get topCharts => '排行榜'; + + @override + String get newReleases => '新剧'; + + @override + String get trendingMicroDramas => '热门短剧'; + + @override + String get seeAll => '查看全部'; + + @override + String get artworkOnly => '海报展示'; + + @override + String get artworkOnlyNotice => '这张海报仅用于目录展示,暂时没有可播放视频。'; + + @override + String get videoPending => '视频待提供'; + + @override + String get watchNow => '免费观看'; + + @override + String get noSearchResults => '没有匹配的短剧。'; + + @override + String get demoCatalogNotice => '三部演示短剧共用同一条本地视频。'; + + @override + String get myTab => '我的'; + + @override + String get localViewer => '本地观众'; + + @override + String get localDemoProfile => '本地演示档案'; + + @override + String get continueWatching => '继续观看'; + + @override + String watchedPercent(int percent) { + return '已观看 $percent%'; + } + + @override + String get resume => '继续播放'; + + @override + String get history => '历史'; + + @override + String get watchlist => '追剧'; + + @override + String get likedTab => '喜欢'; + + @override + String get all => '全部'; + + @override + String get inProgress => '未看完'; + + @override + String get completed => '已看完'; + + @override + String get noSavedShows => '这里还没有剧集。'; + + @override + String get preferences => '偏好设置'; + + @override + String get appLanguage => '应用语言'; + + @override + String get followingTab => '关注'; + + @override + String get animatedTab => '漫剧'; + + @override + String get recommendedTab => '推荐'; + + @override + String get noCategoryDramas => '这个分类暂时没有剧集。'; + + @override + String get trendingTab => '热门'; + + @override + String get liveActionTab => '真人剧'; + + @override + String get search => '搜索'; + + @override + String get free => '免费'; + + @override + String get mute => '静音'; + + @override + String get unmute => '取消静音'; + + @override + String get like => '喜欢'; + + @override + String get save => '收藏'; + + @override + String get comments => '评论'; + + @override + String get share => '分享'; + + @override + String get localOnlyComments => '仅在本机可见'; + + @override + String get noComments => '还没有评论。'; + + @override + String get writeComment => '写评论'; + + @override + String get postComment => '发布'; + + @override + String get shareFailed => '无法打开系统分享面板。'; + + @override + String get nowPlaying => '正在播放'; + + @override + String get watchFullscreen => '横屏全屏观看'; + + @override + String get exitFullscreen => '退出全屏'; + + @override + String get pictureInPicture => '画中画'; + + @override + String get pipUnavailable => '此设备无法使用画中画'; + + @override + String get originalTag => '原创'; + + @override + String get dramaTag => '短剧'; + + @override + String episodeLabel(int number) { + return '第 $number 集'; + } + + @override + String get exploreSeries => '查看剧集'; + + @override + String episodeCount(int count) { + return '$count 集'; + } + + @override + String technicalDemoCount(int count) { + return '免费试看 · $count 集'; + } + + @override + String get episodes => '选集'; + + @override + String get watchEpisodeOne => '观看第 1 集'; + + @override + String continueEpisode(int number) { + return '续看第 $number 集'; + } + + @override + String get freeTestMedia => '复用同一演示视频 · 免费'; + + @override + String get lockedStoreUnavailable => '已锁定 · 商店未配置'; + + @override + String get freeTestClip => '免费测试片'; + + @override + String get locked => '已锁定'; + + @override + String get freeSamplesNotice => '前两集免费 · 商店配置待完成'; + + @override + String get demoTestMedia => '免费 · 同一演示视频'; + + @override + String episodeProgress(int number, int total) { + return '第 $number / $total 集'; + } + + @override + String get testVideoNotice => '自行生成的技术测试视频'; + + @override + String lockedTitle(int number) { + return '第 $number 集已锁定'; + } + + @override + String get lockedBody => '完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。'; + + @override + String get playbackError => '此视频暂时无法播放。'; + + @override + String get catalogError => '剧目列表加载失败。'; + + @override + String get noStories => '暂无剧目。'; + + @override + String get retry => '重试'; + + @override + String get backToSeries => '返回剧集详情'; + + @override + String get nextEpisode => '下一集'; + + @override + String get pause => '暂停'; + + @override + String get play => '播放'; + + @override + String get seekVideo => '拖动播放进度'; + + @override + String get seekFailed => '无法调整播放进度。'; + + @override + String get captions => '字幕'; + + @override + String get captionsOff => '关闭字幕'; + + @override + String get englishCaptions => '英文字幕'; + + @override + String get chineseCaptions => '简体中文字幕'; + + @override + String get language => '界面语言'; + + @override + String get systemLanguage => '跟随系统'; + + @override + String get english => 'English'; + + @override + String get simplifiedChinese => '简体中文'; + + @override + String get episodeOneTitle => '初见微光'; + + @override + String get episodeTwoTitle => '新的频率'; + + @override + String get episodeThreeTitle => '越过边界'; + + @override + String get episodeFourTitle => '回声之后'; + + @override + String get dramaSignalTitle => '信号'; + + @override + String get dramaSignalSynopsis => '一部竖屏连播的演示短剧,每一集都使用同一段本地视频。'; + + @override + String get dramaCityTitle => '都市灯火与丝绒夜'; + + @override + String get dramaCitySynopsis => '一部都市爱情演示短剧,用同一段本地视频体验切换和续看。'; + + @override + String get dramaVengeanceTitle => '复仇崛起'; + + @override + String get dramaVengeanceSynopsis => '一部动作演示短剧,用同一段本地视频体验切换和续看。'; + + @override + String get dramaEchoesTitle => '命运回声'; + + @override + String get genreSuspenseMystery => '悬疑 · 推理'; + + @override + String get genreUrbanRomance => '都市爱情'; + + @override + String get genreActionThriller => '动作 · 惊悚'; + + @override + String get genreDramaMystery => '剧情 · 悬疑'; + + @override + String get continueWatchingHeading => '继续观看'; + + @override + String get preferencesHeading => '偏好设置'; + + @override + String get brandMark => 'NONTONKI · 演示'; + + @override + String get shareFooter => 'Nontonki 演示'; +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb new file mode 100644 index 0000000..0f1d215 --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,129 @@ +{ + "@@locale": "zh", + "appName": "Nontonki", + "demo": "演示", + "homeHeadline": "即刻观看", + "homeSubtitle": "免费短剧预览。", + "mediaNotice": "用户提供的本地演示视频 · 免费观看", + "featuredDemo": "精选 · 技术演示", + "demoTitle": "信号", + "demoSynopsis": "使用本地视频进行播放预览,后续有素材再增加剧集。", + "homeTab": "首页", + "openQuickMenu": "打开快捷菜单", + "quickAccess": "快捷入口", + "currentSeries": "当前短剧", + "discoverTab": "找剧", + "searchDiscover": "搜索短剧、演员、类型…", + "featured": "精选", + "suspense": "悬疑", + "romance": "都市爱情", + "actionGenre": "动作", + "filter": "筛选", + "topCharts": "排行榜", + "newReleases": "新剧", + "trendingMicroDramas": "热门短剧", + "seeAll": "查看全部", + "artworkOnly": "海报展示", + "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", + "videoPending": "视频待提供", + "watchNow": "免费观看", + "noSearchResults": "没有匹配的短剧。", + "demoCatalogNotice": "三部演示短剧共用同一条本地视频。", + "myTab": "我的", + "localViewer": "本地观众", + "localDemoProfile": "本地演示档案", + "continueWatching": "继续观看", + "watchedPercent": "已观看 {percent}%", + "@watchedPercent": {"placeholders": {"percent": {"type": "int"}}}, + "resume": "继续播放", + "history": "历史", + "watchlist": "追剧", + "likedTab": "喜欢", + "all": "全部", + "inProgress": "未看完", + "completed": "已看完", + "noSavedShows": "这里还没有剧集。", + "preferences": "偏好设置", + "appLanguage": "应用语言", + "followingTab": "关注", + "animatedTab": "漫剧", + "recommendedTab": "推荐", + "noCategoryDramas": "这个分类暂时没有剧集。", + "trendingTab": "热门", + "liveActionTab": "真人剧", + "search": "搜索", + "free": "免费", + "mute": "静音", + "unmute": "取消静音", + "like": "喜欢", + "save": "收藏", + "comments": "评论", + "share": "分享", + "localOnlyComments": "仅在本机可见", + "noComments": "还没有评论。", + "writeComment": "写评论", + "postComment": "发布", + "shareFailed": "无法打开系统分享面板。", + "nowPlaying": "正在播放", + "watchFullscreen": "横屏全屏观看", + "exitFullscreen": "退出全屏", + "pictureInPicture": "画中画", + "pipUnavailable": "此设备无法使用画中画", + "originalTag": "原创", + "dramaTag": "短剧", + "episodeLabel": "第 {number} 集", + "@episodeLabel": {"placeholders": {"number": {"type": "int"}}}, + "exploreSeries": "查看剧集", + "episodeCount": "{count} 集", + "technicalDemoCount": "免费试看 · {count} 集", + "episodes": "选集", + "watchEpisodeOne": "观看第 1 集", + "continueEpisode": "续看第 {number} 集", + "freeTestMedia": "复用同一演示视频 · 免费", + "lockedStoreUnavailable": "已锁定 · 商店未配置", + "freeTestClip": "免费测试片", + "locked": "已锁定", + "freeSamplesNotice": "前两集免费 · 商店配置待完成", + "demoTestMedia": "免费 · 同一演示视频", + "episodeProgress": "第 {number} / {total} 集", + "testVideoNotice": "自行生成的技术测试视频", + "lockedTitle": "第 {number} 集已锁定", + "lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。", + "playbackError": "此视频暂时无法播放。", + "catalogError": "剧目列表加载失败。", + "noStories": "暂无剧目。", + "retry": "重试", + "backToSeries": "返回剧集详情", + "nextEpisode": "下一集", + "pause": "暂停", + "play": "播放", + "seekVideo": "拖动播放进度", + "seekFailed": "无法调整播放进度。", + "captions": "字幕", + "captionsOff": "关闭字幕", + "englishCaptions": "英文字幕", + "chineseCaptions": "简体中文字幕", + "language": "界面语言", + "systemLanguage": "跟随系统", + "english": "English", + "simplifiedChinese": "简体中文", + "episodeOneTitle": "初见微光", + "episodeTwoTitle": "新的频率", + "episodeThreeTitle": "越过边界", + "episodeFourTitle": "回声之后", + "dramaSignalTitle": "信号", + "dramaSignalSynopsis": "一部竖屏连播的演示短剧,每一集都使用同一段本地视频。", + "dramaCityTitle": "都市灯火与丝绒夜", + "dramaCitySynopsis": "一部都市爱情演示短剧,用同一段本地视频体验切换和续看。", + "dramaVengeanceTitle": "复仇崛起", + "dramaVengeanceSynopsis": "一部动作演示短剧,用同一段本地视频体验切换和续看。", + "dramaEchoesTitle": "命运回声", + "genreSuspenseMystery": "悬疑 · 推理", + "genreUrbanRomance": "都市爱情", + "genreActionThriller": "动作 · 惊悚", + "genreDramaMystery": "剧情 · 悬疑", + "continueWatchingHeading": "继续观看", + "preferencesHeading": "偏好设置", + "brandMark": "NONTONKI · 演示", + "shareFooter": "Nontonki 演示" +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..6a6836d --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'app.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + runApp(const LumaDramaApp()); +} diff --git a/lib/pages/discover/discover_binding.dart b/lib/pages/discover/discover_binding.dart new file mode 100644 index 0000000..d17cf00 --- /dev/null +++ b/lib/pages/discover/discover_binding.dart @@ -0,0 +1,13 @@ +import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import 'discover_logic.dart'; + +class DiscoverBinding { + DiscoverLogic create({ + required Drama drama, + WatchHistoryRepository? history, + }) => DiscoverLogic( + drama: drama, + history: history ?? SharedPreferencesWatchHistoryRepository(), + ); +} diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart new file mode 100644 index 0000000..39f6591 --- /dev/null +++ b/lib/pages/discover/discover_logic.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; + +class DiscoverLogic { + DiscoverLogic({required this.history, required this.drama}) { + refreshPosition(); + } + + final WatchHistoryRepository history; + Drama drama; + late Future position; + final TextEditingController search = TextEditingController(); + final FocusNode searchFocus = FocusNode(); + final ScrollController scroll = ScrollController(); + int category = 0; + + void refreshPosition() { + position = history.positionFor( + demoProfileId, + drama.id, + drama.episodes.first.id, + ); + } + + void dispose() { + search.dispose(); + searchFocus.dispose(); + scroll.dispose(); + } +} diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart new file mode 100644 index 0000000..0c7740b --- /dev/null +++ b/lib/pages/discover/discover_view.dart @@ -0,0 +1,731 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/formatting/localized_content.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../l10n/app_localizations.dart'; +import 'discover_binding.dart'; +import 'discover_logic.dart'; + +enum DiscoverPageAction { home, my, watchlist, detail, resume } + +class DiscoverPage extends StatefulWidget { + const DiscoverPage({ + super.key, + required this.drama, + required this.duration, + this.focusSearch = false, + this.history, + this.embedded = false, + this.active = true, + this.searchRequest = 0, + this.onAction, + this.dramas, + this.onDramaSelected, + }); + + final Drama drama; + final Duration duration; + final bool focusSearch; + final WatchHistoryRepository? history; + final bool embedded; + final bool active; + final int searchRequest; + final ValueChanged? onAction; + final List? dramas; + final ValueChanged? onDramaSelected; + + @override + State createState() => _DiscoverPageState(); +} + +class _DiscoverPageState extends State { + late final DiscoverLogic logic; + + @override + void initState() { + super.initState(); + logic = DiscoverBinding().create( + drama: widget.drama, + history: widget.history, + ); + } + + @override + void didUpdateWidget(covariant DiscoverPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.active && + (!oldWidget.active || oldWidget.drama.id != widget.drama.id)) { + logic.drama = widget.drama; + setState(logic.refreshPosition); + } + if (widget.active && widget.searchRequest != oldWidget.searchRequest) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.active) logic.searchFocus.requestFocus(); + }); + } + } + + void _act(DiscoverPageAction action) { + if (widget.embedded) { + widget.onAction?.call(action); + } else { + Navigator.pop(context, action); + } + } + + Drama? _dramaFor(_ShowcaseCard card) { + for (final drama in widget.dramas ?? [logic.drama]) { + if (drama.id == card.dramaId) return drama; + } + return null; + } + + void _openCard(_ShowcaseCard card) { + final drama = _dramaFor(card); + if (drama == null) { + _unavailable(); + } else if (widget.embedded && widget.onDramaSelected != null) { + widget.onDramaSelected!(drama); + } else { + _act(DiscoverPageAction.detail); + } + } + + @override + void dispose() { + logic.dispose(); + super.dispose(); + } + + void _unavailable() { + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.artworkOnlyNotice))); + } + + void _catalogInfo() { + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice))); + } + + void _showFilters() { + final l10n = AppLocalizations.of(context)!; + final labels = [ + l10n.featured, + l10n.suspense, + l10n.romance, + l10n.actionGenre, + ]; + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF202126), + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(l10n.filter)), + for (var index = 0; index < labels.length; index++) + ListTile( + title: Text(labels[index]), + trailing: index == logic.category + ? const Icon(Icons.check_rounded, color: _orange) + : null, + onTap: () { + setState(() => logic.category = index); + Navigator.pop(sheetContext); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final cards = _cardsFor(l10n, logic.category, logic.search.text); + final content = SafeArea( + bottom: !widget.embedded, + child: Column( + children: [ + _topBar(l10n), + Expanded( + child: ListView( + controller: logic.scroll, + padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), + children: [ + _searchBar(l10n), + const SizedBox(height: 12), + _categories(l10n), + const SizedBox(height: 15), + _continueCard(l10n), + const SizedBox(height: 15), + _quickActions(l10n), + const SizedBox(height: 20), + _sectionTitle(l10n), + const SizedBox(height: 13), + if (cards.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + l10n.noSearchResults, + style: const TextStyle(color: Color(0xFFE6E1E3)), + ), + ) + else + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: cards.length, + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 14, + childAspectRatio: 0.59, + ), + itemBuilder: (context, index) => + _posterCard(l10n, cards[index]), + ), + ], + ), + ), + if (!widget.embedded) _bottomNav(l10n), + ], + ), + ); + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Theme( + data: ThemeData.dark(useMaterial3: true), + child: widget.embedded + ? content + : Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content), + ), + ); + } + + Widget _topBar(AppLocalizations l10n) => SizedBox( + height: 51, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + 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: () => _act(DiscoverPageAction.my), + 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: (_) => setState(() {}), + 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: _showFilters, + 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: (_) => setState(() => logic.category = 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( + 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: () => _act(DiscoverPageAction.resume), + 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, _showFilters), + (Icons.bar_chart_rounded, l10n.topCharts, _catalogInfo), + (Icons.auto_awesome_rounded, l10n.newReleases, _catalogInfo), + ( + Icons.bookmark_outline_rounded, + l10n.watchlist, + () { + _act(DiscoverPageAction.watchlist); + }, + ), + ]; + return Row( + children: [ + for (var index = 0; index < actions.length; index++) ...[ + Expanded( + child: InkWell( + onTap: actions[index].$3, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 77, + padding: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: const Color(0xFF202126), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: const Color(0xFF333036), + child: Icon( + actions[index].$1, + size: 19, + color: index == 1 + ? const Color(0xFFFFC15E) + : const Color(0xFFFFAB96), + ), + ), + const SizedBox(height: 7), + Text( + actions[index].$2, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), + if (index < actions.length - 1) const SizedBox(width: 7), + ], + ], + ); + } + + Widget _sectionTitle(AppLocalizations l10n) => Row( + children: [ + Container(width: 3, height: 20, color: _orange), + const SizedBox(width: 7), + Expanded( + child: Text( + l10n.trendingMicroDramas, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800), + ), + ), + TextButton( + onPressed: () => setState(() => logic.category = 0), + child: Text( + l10n.seeAll, + style: const TextStyle(color: Color(0xFFFFB3A1)), + ), + ), + ], + ); + + Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) { + final playable = _dramaFor(card) != null; + return InkWell( + onTap: () => _openCard(card), + borderRadius: BorderRadius.circular(11), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Stack( + fit: StackFit.expand, + children: [ + Image.asset(card.image, fit: BoxFit.cover), + const Positioned( + left: 0, + right: 0, + bottom: 0, + height: 37, + child: ColoredBox(color: Color(0xFF101116)), + ), + Positioned( + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: playable ? _orange : const Color(0xDD37343B), + borderRadius: BorderRadius.circular(7), + ), + child: Text( + playable ? l10n.free : l10n.artworkOnly, + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + Positioned( + bottom: 10, + left: 8, + right: 8, + child: Text( + playable ? l10n.freeTestMedia : l10n.videoPending, + style: const TextStyle( + color: Color(0xFFFFC789), + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 6), + Text( + card.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 3), + Text( + card.genre, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 11), + ), + ], + ), + ); + } + + Widget _bottomNav(AppLocalizations l10n) => Container( + height: 63, + decoration: const BoxDecoration( + color: Color(0xFF0D0D11), + border: Border(top: BorderSide(color: Color(0xFF242329))), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _DiscoverNavItem( + icon: Icons.play_circle_outline_rounded, + label: l10n.homeTab, + onTap: () => Navigator.pop(context, DiscoverPageAction.home), + ), + _DiscoverNavItem( + icon: Icons.explore_outlined, + label: l10n.discoverTab, + selected: true, + onTap: () {}, + ), + _DiscoverNavItem( + icon: Icons.video_library_outlined, + label: l10n.myTab, + onTap: () => Navigator.pop(context, DiscoverPageAction.my), + ), + ], + ), + ); +} + +class _DiscoverNavItem extends StatelessWidget { + const _DiscoverNavItem({ + required this.icon, + required this.label, + required this.onTap, + this.selected = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool selected; + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + child: SizedBox( + width: 72, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 21, + color: selected ? _orange : const Color(0xFFCAC4C7), + ), + Text( + label, + style: TextStyle( + color: selected ? _orange : const Color(0xFFCAC4C7), + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); +} + +class _ShowcaseCard { + const _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; +} + +List<_ShowcaseCard> _cardsFor( + AppLocalizations l10n, + int category, + String query, +) { + final cards = [ + _ShowcaseCard( + title: l10n.dramaSignalTitle, + genre: l10n.genreSuspenseMystery, + image: 'assets/images/stitch_signal_poster_clean.png', + category: 1, + dramaId: 'the-signal-demo', + ), + _ShowcaseCard( + title: l10n.dramaCityTitle, + genre: l10n.genreUrbanRomance, + image: 'assets/images/stitch_city_poster_clean.png', + category: 2, + dramaId: 'city-lights-demo', + ), + _ShowcaseCard( + title: l10n.dramaVengeanceTitle, + genre: l10n.genreActionThriller, + image: 'assets/images/stitch_vengeance_poster_clean.png', + category: 3, + dramaId: 'vengeance-demo', + ), + _ShowcaseCard( + title: l10n.dramaEchoesTitle, + genre: l10n.genreDramaMystery, + image: 'assets/images/stitch_rain_scene.jpg', + category: 1, + ), + ]; + final normalized = query.trim().toLowerCase(); + return cards.where((card) { + final categoryMatches = category == 0 || card.category == category; + final queryMatches = + normalized.isEmpty || + card.title.toLowerCase().contains(normalized) || + card.genre.toLowerCase().contains(normalized); + return categoryMatches && queryMatches; + }).toList(); +} + +const _orange = Color(0xFFFF5B3D); diff --git a/lib/pages/drama_detail/drama_detail_binding.dart b/lib/pages/drama_detail/drama_detail_binding.dart new file mode 100644 index 0000000..0a3f649 --- /dev/null +++ b/lib/pages/drama_detail/drama_detail_binding.dart @@ -0,0 +1,13 @@ +import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import 'drama_detail_logic.dart'; + +class DramaDetailBinding { + DramaDetailLogic create({ + required Drama drama, + WatchHistoryRepository? history, + }) => DramaDetailLogic( + drama: drama, + history: history ?? SharedPreferencesWatchHistoryRepository(), + ); +} diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart new file mode 100644 index 0000000..8acb22b --- /dev/null +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -0,0 +1,19 @@ +import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; + +class DramaDetailLogic { + DramaDetailLogic({required this.drama, required this.history}); + + final Drama drama; + final WatchHistoryRepository history; + int? continueIndex; + + Future loadContinue() async { + final id = await history.lastEpisodeId(demoProfileId, drama.id); + if (id == null) return; + final index = drama.episodes.indexWhere((episode) => episode.id == id); + if (index >= 0 && drama.episodes[index].isFree) { + continueIndex = index; + } + } +} diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart new file mode 100644 index 0000000..39a5c0b --- /dev/null +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/formatting/localized_content.dart'; +import '../../l10n/app_localizations.dart'; +import '../player/player_view.dart'; +import 'drama_detail_binding.dart'; +import 'drama_detail_logic.dart'; + +class DramaDetailPage extends StatefulWidget { + const DramaDetailPage({super.key, required this.drama}); + final Drama drama; + + @override + State createState() => _DramaDetailPageState(); +} + +class _DramaDetailPageState extends State { + late final DramaDetailLogic logic; + + @override + void initState() { + super.initState(); + logic = DramaDetailBinding().create(drama: widget.drama); + _loadContinue(); + } + + Future _loadContinue() async { + try { + await logic.loadContinue(); + if (mounted) setState(() {}); + } catch (_) { + // Keep the first episode available if preferences cannot be read. + } + } + + Future _openPlayer(BuildContext context, int index) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage(drama: logic.drama, initialIndex: index), + ), + ); + await _loadContinue(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final drama = logic.drama; + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 300, + pinned: true, + backgroundColor: const Color(0xFF111111), + foregroundColor: Colors.white, + flexibleSpace: FlexibleSpaceBar( + background: Image.asset(drama.posterAsset, fit: BoxFit.cover), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 40), + sliver: SliverList( + delegate: SliverChildListDelegate([ + Text( + l10n.technicalDemoCount(drama.episodes.length), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + letterSpacing: 1.4, + color: const Color(0xFF9278EA), + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + localizedDramaTitle(l10n, drama), + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 10), + Text( + localizedDramaSynopsis(l10n, drama), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: () => _openPlayer(context, logic.continueIndex ?? 0), + icon: const Icon(Icons.play_arrow_rounded), + label: Text( + logic.continueIndex == null + ? l10n.watchEpisodeOne + : l10n.continueEpisode(logic.continueIndex! + 1), + ), + ), + const SizedBox(height: 30), + Text( + l10n.episodes, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ]), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 40), + sliver: SliverList.builder( + itemCount: drama.episodes.length, + itemBuilder: (context, index) { + final episode = drama.episodes[index]; + return Card( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + minTileHeight: 76, + leading: CircleAvatar( + backgroundColor: const Color( + 0xFFFF6B45, + ).withValues(alpha: 0.18), + child: Text('${episode.number}'), + ), + title: Text(l10n.episodeLabel(episode.number)), + subtitle: Text(l10n.freeTestMedia), + trailing: const Icon(Icons.play_circle_outline_rounded), + onTap: () => _openPlayer(context, index), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/home/home_binding.dart b/lib/pages/home/home_binding.dart new file mode 100644 index 0000000..072c675 --- /dev/null +++ b/lib/pages/home/home_binding.dart @@ -0,0 +1,6 @@ +import '../../core/data/models/drama.dart'; +import 'home_logic.dart'; + +class HomeBinding { + HomeLogic create(List dramas) => HomeLogic(dramas); +} diff --git a/lib/pages/home/home_feed_sections.dart b/lib/pages/home/home_feed_sections.dart new file mode 100644 index 0000000..74559dc --- /dev/null +++ b/lib/pages/home/home_feed_sections.dart @@ -0,0 +1,30 @@ +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 dramas; + final Set followedDramaIds; + + List 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]]; +} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart new file mode 100644 index 0000000..646bd07 --- /dev/null +++ b/lib/pages/home/home_logic.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import '../../core/data/models/drama.dart'; + +class HomeLogic { + HomeLogic(List dramas) : currentDrama = dramas.first; + + final scaffoldKey = GlobalKey(); + Drama currentDrama; + int tab = 0; + int searchRequest = 0; + int sectionRequest = 0; + int resumeRequest = 0; + int backRequest = 0; + int mySection = 0; + bool drawerOpen = false; + bool detailOpen = false; + bool videoImmersive = false; + + void select( + int nextTab, { + bool search = false, + bool watchlist = false, + bool resume = false, + int? mySection, + }) { + FocusManager.instance.primaryFocus?.unfocus(); + tab = nextTab; + if (search) searchRequest++; + if (watchlist || mySection != null) { + this.mySection = mySection ?? 1; + sectionRequest++; + } + if (resume) resumeRequest++; + } +} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart new file mode 100644 index 0000000..7fc7337 --- /dev/null +++ b/lib/pages/home/home_view.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/services/catalog_controller.dart'; +import '../../l10n/app_localizations.dart'; +import '../discover/discover_view.dart'; +import '../drama_detail/drama_detail_view.dart'; +import '../mine/mine_view.dart'; +import '../player/player_view.dart'; +import 'home_binding.dart'; +import 'home_logic.dart'; +import 'widgets/home_quick_drawer.dart'; + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + final catalog = context.watch(); + final l10n = AppLocalizations.of(context)!; + return Theme( + data: ThemeData.dark(useMaterial3: true), + child: _buildHome(context, catalog, l10n), + ); + } + + Widget _buildHome( + BuildContext context, + CatalogController catalog, + AppLocalizations l10n, + ) { + + if (catalog.loading) { + return const Scaffold( + backgroundColor: Colors.black, + body: Center(child: CircularProgressIndicator()), + ); + } + if (catalog.error != null || catalog.dramas.isEmpty) { + return Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(catalog.error == null ? l10n.noStories : l10n.catalogError), + const SizedBox(height: 12), + FilledButton(onPressed: catalog.load, child: Text(l10n.retry)), + ], + ), + ), + ); + } + + return _HomeTabs( + key: ValueKey(catalog.dramas.first.id), + dramas: catalog.dramas, + ); + } +} + +/// The three primary destinations share a single route and keep their state. +class _HomeTabs extends StatefulWidget { + const _HomeTabs({super.key, required this.dramas}); + + final List dramas; + + @override + State<_HomeTabs> createState() => _HomeTabsState(); +} + +class _HomeTabsState extends State<_HomeTabs> { + late final HomeLogic logic; + + @override + void initState() { + super.initState(); + logic = HomeBinding().create(widget.dramas); + } + + void _select( + int tab, { + bool search = false, + bool watchlist = false, + bool resume = false, + int? mySection, + }) { + setState( + () => logic.select( + tab, + search: search, + watchlist: watchlist, + resume: resume, + mySection: mySection, + ), + ); + } + + void _openMenu() { + setState(() => logic.drawerOpen = true); + logic.scaffoldKey.currentState?.openDrawer(); + } + + void _selectFromMenu(int tab, {int? mySection}) { + logic.scaffoldKey.currentState?.closeDrawer(); + _select(tab, mySection: mySection); + } + + Future _openCurrentSeries() async { + setState(() => logic.detailOpen = true); + logic.scaffoldKey.currentState?.closeDrawer(); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + try { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DramaDetailPage(drama: logic.currentDrama), + ), + ); + } finally { + if (mounted) setState(() => logic.detailOpen = false); + } + } + + void _discoverAction(DiscoverPageAction action) { + switch (action) { + case DiscoverPageAction.home: + _select(0); + case DiscoverPageAction.my: + _select(2); + case DiscoverPageAction.watchlist: + _select(2, watchlist: true); + case DiscoverPageAction.resume: + _select(0, resume: true); + case DiscoverPageAction.detail: + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DramaDetailPage(drama: logic.currentDrama), + ), + ); + } + } + + void _myAction(MyPageAction action) { + switch (action) { + case MyPageAction.home: + _select(0); + case MyPageAction.discover: + _select(1, search: true); + case MyPageAction.resume: + _select(0, resume: true); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (logic.drawerOpen) { + logic.scaffoldKey.currentState?.closeDrawer(); + } else if (logic.videoImmersive) { + setState(() => logic.backRequest++); + } else if (logic.tab != 0) { + _select(0); + } else { + setState(() => logic.backRequest++); + } + }, + child: Scaffold( + key: logic.scaffoldKey, + backgroundColor: const Color(0xFF0B0C10), + drawer: HomeQuickDrawer( + drama: logic.currentDrama, + onOpenSeries: _openCurrentSeries, + onDiscover: () => _selectFromMenu(1), + onHistory: () => _selectFromMenu(2, mySection: 0), + onWatchlist: () => _selectFromMenu(2, mySection: 1), + onLiked: () => _selectFromMenu(2, mySection: 2), + ), + drawerEnableOpenDragGesture: false, + onDrawerChanged: (opened) { + if (logic.drawerOpen != opened) { + setState(() => logic.drawerOpen = opened); + } + }, + body: IndexedStack( + index: logic.tab, + children: [ + PlayerPage( + drama: widget.dramas.first, + feedDramas: widget.dramas, + onDramaChanged: (drama) => + setState(() => logic.currentDrama = drama), + initialIndex: 0, + homeMode: true, + embedded: true, + active: logic.tab == 0 && !logic.drawerOpen && !logic.detailOpen, + resumeRequest: logic.resumeRequest, + backRequest: logic.backRequest, + onDiscover: () => _select(1), + onMy: () => _select(2), + onSearch: () => _select(1, search: true), + onMenu: _openMenu, + onImmersiveChanged: (value) => + setState(() => logic.videoImmersive = value), + ), + DiscoverPage( + drama: logic.currentDrama, + dramas: widget.dramas, + onDramaSelected: (drama) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DramaDetailPage(drama: drama), + ), + ), + duration: Duration.zero, + embedded: true, + active: logic.tab == 1, + searchRequest: logic.searchRequest, + onAction: _discoverAction, + ), + MyPage( + drama: logic.currentDrama, + dramas: widget.dramas, + onDramaSelected: (drama) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DramaDetailPage(drama: drama), + ), + ), + duration: Duration.zero, + initialSection: logic.mySection, + sectionRequest: logic.sectionRequest, + embedded: true, + active: logic.tab == 2, + onAction: _myAction, + ), + ], + ), + bottomNavigationBar: logic.videoImmersive + ? null + : SafeArea( + top: false, + child: Container( + key: const Key('home-tabs-bar'), + height: 63, + decoration: const BoxDecoration( + color: Color(0xFF0D0D11), + 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: () => _select(0), + ), + _TabButton( + key: const Key('tab-discover'), + icon: Icons.explore_outlined, + label: l10n.discoverTab, + selected: logic.tab == 1, + onTap: () => _select(1), + ), + _TabButton( + key: const Key('tab-my'), + icon: Icons.video_library_outlined, + label: l10n.myTab, + selected: logic.tab == 2, + onTap: () => _select(2), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TabButton extends StatelessWidget { + const _TabButton({ + super.key, + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + child: SizedBox( + width: 80, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 21, + color: selected ? const Color(0xFFFF5C37) : const Color(0xFFE6E1DE), + ), + Text( + label, + style: TextStyle( + fontSize: 10, + color: selected + ? const Color(0xFFFF5C37) + : const Color(0xFFE6E1DE), + ), + ), + ], + ), + ), + ); +} diff --git a/lib/pages/home/widgets/category_swipe_region.dart b/lib/pages/home/widgets/category_swipe_region.dart new file mode 100644 index 0000000..c641cc1 --- /dev/null +++ b/lib/pages/home/widgets/category_swipe_region.dart @@ -0,0 +1,35 @@ +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 onShift; + final Widget child; + + @override + State createState() => _CategorySwipeRegionState(); +} + +class _CategorySwipeRegionState extends State { + 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, + ); +} diff --git a/lib/pages/home/widgets/home_feed_header.dart b/lib/pages/home/widgets/home_feed_header.dart new file mode 100644 index 0000000..2998d41 --- /dev/null +++ b/lib/pages/home/widgets/home_feed_header.dart @@ -0,0 +1,170 @@ +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 onCategorySelected; + final ValueChanged 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, + ), + ), + ], + ), + ), + ], + ); +} diff --git a/lib/pages/home/widgets/home_quick_drawer.dart b/lib/pages/home/widgets/home_quick_drawer.dart new file mode 100644 index 0000000..2a60d68 --- /dev/null +++ b/lib/pages/home/widgets/home_quick_drawer.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/config/locale_controller.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../l10n/app_localizations.dart'; + +/// Shortcuts for the existing root tabs; this widget owns no navigation state. +class HomeQuickDrawer extends StatelessWidget { + const HomeQuickDrawer({ + super.key, + required this.drama, + required this.onOpenSeries, + required this.onDiscover, + required this.onHistory, + required this.onWatchlist, + required this.onLiked, + }); + + final Drama drama; + final VoidCallback onOpenSeries; + final VoidCallback onDiscover; + final VoidCallback onHistory; + final VoidCallback onWatchlist; + final VoidCallback onLiked; + + static const _panel = Color(0xFF222225); + static const _card = Color(0xFF303033); + static const _orange = Color(0xFFFF5C37); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final languageCode = Localizations.localeOf(context).languageCode; + return Drawer( + width: (MediaQuery.sizeOf(context).width * 0.83).clamp(0.0, 360.0), + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.horizontal(right: Radius.circular(20)), + ), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 24), + children: [ + Row( + children: [ + const CircleAvatar( + radius: 30, + backgroundColor: Color(0xFF4B4142), + child: Icon( + Icons.person_rounded, + size: 34, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.localViewer, + style: const TextStyle( + color: Colors.white, + fontSize: 19, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + l10n.localDemoProfile, + style: const TextStyle(color: Color(0xFFE7E2E0)), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 26), + _SectionCard( + title: l10n.currentSeries, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.asset( + drama.posterAsset, + width: 74, + height: 100, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + const SizedBox(height: 7), + Text( + l10n.episodeCount(drama.episodes.length), + style: const TextStyle(color: Color(0xFFE7E2E0)), + ), + const SizedBox(height: 5), + Text( + l10n.freeTestMedia, + maxLines: 2, + style: const TextStyle( + color: Color(0xFFFFB99E), + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + key: const Key('drawer-open-series'), + onPressed: onOpenSeries, + style: FilledButton.styleFrom( + backgroundColor: _orange, + foregroundColor: Colors.white, + ), + icon: const Icon(Icons.play_arrow_rounded), + label: Text(l10n.exploreSeries), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + _SectionCard( + title: l10n.quickAccess, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: _QuickAction( + key: const Key('drawer-discover'), + icon: Icons.explore_outlined, + label: l10n.discoverTab, + onTap: onDiscover, + ), + ), + const SizedBox(width: 9), + Expanded( + child: _QuickAction( + key: const Key('drawer-history'), + icon: Icons.history_rounded, + label: l10n.history, + onTap: onHistory, + ), + ), + ], + ), + const SizedBox(height: 9), + Row( + children: [ + Expanded( + child: _QuickAction( + key: const Key('drawer-watchlist'), + icon: Icons.bookmark_border_rounded, + label: l10n.watchlist, + onTap: onWatchlist, + ), + ), + const SizedBox(width: 9), + Expanded( + child: _QuickAction( + key: const Key('drawer-liked'), + icon: Icons.favorite_border_rounded, + label: l10n.likedTab, + onTap: onLiked, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 14), + _SectionCard( + title: l10n.appLanguage, + child: Row( + children: [ + for (final (code, label) in [ + ('en', l10n.english), + ('zh', l10n.simplifiedChinese), + ]) ...[ + Expanded( + child: ChoiceChip( + label: Text(label, maxLines: 1), + selected: languageCode == code, + onSelected: (_) => + context.read().select(code), + ), + ), + if (code == 'en') const SizedBox(width: 8), + ], + ], + ), + ), + ], + ), + ), + ); + } +} + +class _SectionCard extends StatelessWidget { + const _SectionCard({required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: HomeQuickDrawer._card, + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 14), + child, + ], + ), + ); +} + +class _QuickAction extends StatelessWidget { + const _QuickAction({ + super.key, + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Material( + color: const Color(0xFF3B3B3F), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7), + child: Column( + children: [ + Icon(icon, color: HomeQuickDrawer._orange, size: 25), + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/pages/mine/mine_binding.dart b/lib/pages/mine/mine_binding.dart new file mode 100644 index 0000000..697d61d --- /dev/null +++ b/lib/pages/mine/mine_binding.dart @@ -0,0 +1,15 @@ +import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import 'mine_logic.dart'; + +class MineBinding { + MineLogic create({ + required Drama drama, + required int initialSection, + WatchHistoryRepository? history, + }) => MineLogic( + drama: drama, + initialSection: initialSection, + history: history ?? SharedPreferencesWatchHistoryRepository(), + ); +} diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart new file mode 100644 index 0000000..42d267a --- /dev/null +++ b/lib/pages/mine/mine_logic.dart @@ -0,0 +1,26 @@ +import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; + +class MineLogic { + MineLogic({ + required this.history, + required this.drama, + required int initialSection, + }) : section = initialSection { + refreshPosition(); + } + + final WatchHistoryRepository history; + Drama drama; + late Future position; + int section; + int filter = 0; + + void refreshPosition() { + position = history.positionFor( + demoProfileId, + drama.id, + drama.episodes.first.id, + ); + } +} diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart new file mode 100644 index 0000000..4ec0152 --- /dev/null +++ b/lib/pages/mine/mine_view.dart @@ -0,0 +1,682 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/config/locale_controller.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/engagement_controller.dart'; +import '../../l10n/app_localizations.dart'; +import 'mine_binding.dart'; +import 'mine_logic.dart'; + +enum MyPageAction { home, discover, resume } + +class MyPage extends StatefulWidget { + const MyPage({ + super.key, + required this.drama, + required this.duration, + this.initialSection = 0, + this.history, + this.embedded = false, + this.active = true, + this.sectionRequest = 0, + this.onAction, + this.dramas, + this.onDramaSelected, + }); + + final Drama drama; + final Duration duration; + final int initialSection; + final WatchHistoryRepository? history; + final bool embedded; + final bool active; + final int sectionRequest; + final ValueChanged? onAction; + final List? dramas; + final ValueChanged? onDramaSelected; + + @override + State createState() => _MyPageState(); +} + +class _MyPageState extends State { + late final MineLogic logic; + + @override + void initState() { + super.initState(); + logic = MineBinding().create( + drama: widget.drama, + initialSection: widget.initialSection, + history: widget.history, + ); + } + + @override + void didUpdateWidget(covariant MyPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.active && + (!oldWidget.active || oldWidget.drama.id != widget.drama.id)) { + logic.drama = widget.drama; + setState(logic.refreshPosition); + } + if (widget.sectionRequest != oldWidget.sectionRequest) { + setState(() => logic.section = widget.initialSection); + } + } + + void _act(MyPageAction action) { + if (widget.embedded) { + widget.onAction?.call(action); + } else { + Navigator.pop(context, action); + } + } + + Future _showLanguage() async { + final l10n = AppLocalizations.of(context)!; + await showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1C1D22), + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(l10n.language)), + for (final (code, label) in [ + ('en', l10n.english), + ('zh', l10n.simplifiedChinese), + ]) + ListTile( + title: Text(label), + onTap: () { + context.read().select(code); + Navigator.pop(sheetContext); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final content = SafeArea( + bottom: !widget.embedded, + child: Column( + children: [ + _topBar(l10n), + Expanded( + child: FutureBuilder( + 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 fraction = + (position.inMilliseconds / total.inMilliseconds).clamp( + 0.0, + 1.0, + ); + return ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 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), + const SizedBox(height: 24), + Center( + child: Text( + l10n.brandMark, + style: TextStyle( + color: Color(0xFFD2CDD0), + fontSize: 10, + letterSpacing: 1.3, + ), + ), + ), + ], + ); + }, + ), + ), + if (!widget.embedded) _bottomNav(l10n), + ], + ), + ); + return Theme( + data: ThemeData.dark(useMaterial3: true), + child: widget.embedded + ? content + : Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content), + ); + } + + Widget _topBar(AppLocalizations l10n) => SizedBox( + height: 54, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + 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), + ), + const Spacer(), + IconButton( + tooltip: l10n.search, + onPressed: () => _act(MyPageAction.discover), + icon: const Icon(Icons.search_rounded, size: 23), + ), + const SizedBox(width: 6), + const CircleAvatar( + radius: 14, + backgroundColor: Color(0xFF2C2D33), + child: Icon(Icons.person_rounded, color: Colors.white, size: 19), + ), + ], + ), + ), + ); + + Widget _profile(AppLocalizations l10n) => Row( + children: [ + const CircleAvatar( + radius: 30, + backgroundColor: Color(0xFF32343A), + child: Icon(Icons.person_rounded, color: Colors.white, size: 34), + ), + const SizedBox(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, + children: [ + Row( + children: [ + const Icon(Icons.history_rounded, color: _orange, size: 17), + const SizedBox(width: 6), + Expanded( + child: Text( + l10n.continueWatching, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + 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: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _act(MyPageAction.resume), + 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), + ), + ), + ], + ), + ); + + Widget _library( + AppLocalizations l10n, + Duration position, + Duration total, + double fraction, + ) { + final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; + final filters = [l10n.all, l10n.inProgress, l10n.completed]; + final hasHistory = + logic.filter == 0 || + (logic.filter == 1 && fraction < 0.99) || + (logic.filter == 2 && fraction >= 0.99); + final engagement = context.watch(); + final markedDramas = (widget.dramas ?? [widget.drama]).where((drama) { + return logic.section == 1 + ? engagement.isSaved(drama.id) + : engagement.isLiked(drama.id); + }).toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var index = 0; index < tabs.length; index++) ...[ + InkWell( + onTap: () => setState(() => logic.section = index), + child: Text( + tabs[index], + style: TextStyle( + color: logic.section == index + ? _orange + : const Color(0xFFE4DBDD), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + if (index < tabs.length - 1) const SizedBox(width: 18), + ], + ], + ), + ), + const SizedBox(height: 17), + if (logic.section == 0) + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var index = 0; index < filters.length; index++) + Padding( + padding: const EdgeInsets.only(right: 7), + child: ChoiceChip( + label: Text(filters[index]), + selected: logic.filter == index, + onSelected: (_) => setState(() => logic.filter = index), + showCheckmark: false, + visualDensity: VisualDensity.compact, + backgroundColor: const Color(0xFF242328), + selectedColor: _orange, + labelStyle: TextStyle( + color: logic.filter == index + ? Colors.white + : const Color(0xFFD5CED0), + fontSize: 11, + fontWeight: FontWeight.w700, + ), + side: BorderSide.none, + ), + ), + ], + ), + ), + if (logic.section == 0) const SizedBox(height: 11), + if (logic.section == 0 && hasHistory) + InkWell( + onTap: () => _act(MyPageAction.resume), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: const Color(0xFF1B1C21), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _poster(width: 74, height: 103), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, widget.drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 5), + Text( + l10n.dramaTag, + style: const TextStyle( + color: Color(0xFFE6E1E3), + fontSize: 12, + ), + ), + const SizedBox(height: 32), + Text( + '${l10n.episodeLabel(1)} · ${_clock(position)} / ${_clock(total)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFFFA18D), + fontSize: 10, + ), + ), + ], + ), + ), + const CircleAvatar( + radius: 15, + backgroundColor: Color(0xFF313137), + child: Icon( + Icons.play_arrow_rounded, + color: _orange, + size: 20, + ), + ), + ], + ), + ), + ) + else if (logic.section != 0 && markedDramas.isNotEmpty) + for (final drama in markedDramas) ...[ + _markedDramaCard(drama, l10n), + const SizedBox(height: 9), + ] + else + Padding( + padding: const EdgeInsets.symmetric(vertical: 22), + child: Text( + l10n.noSavedShows, + style: const TextStyle(color: Color(0xFFE6E1E3)), + ), + ), + ], + ); + } + + Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell( + onTap: () { + if (widget.onDramaSelected != null) { + widget.onDramaSelected!(drama); + } else if (drama.id == widget.drama.id) { + _act(MyPageAction.resume); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: const Color(0xFF1B1C21), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + drama.posterAsset, + width: 74, + height: 103, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 7), + Text( + l10n.episodeCount(drama.episodes.length), + style: const TextStyle( + color: Color(0xFFE6E1E3), + fontSize: 12, + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right_rounded, color: Color(0xFFF2EEEF)), + ], + ), + ), + ); + + 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: _showLanguage, + ), + ), + ], + ); + + Widget _bottomNav(AppLocalizations l10n) => Container( + height: 63, + decoration: const BoxDecoration( + color: Color(0xFF0D0D11), + border: Border(top: BorderSide(color: Color(0xFF242329))), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _NavItem( + icon: Icons.play_circle_outline_rounded, + label: l10n.homeTab, + onTap: () => Navigator.pop(context, MyPageAction.home), + ), + _NavItem( + icon: Icons.explore_outlined, + label: l10n.discoverTab, + onTap: () => Navigator.pop(context, MyPageAction.discover), + ), + _NavItem( + icon: Icons.video_library_outlined, + label: l10n.myTab, + selected: true, + onTap: () {}, + ), + ], + ), + ); + + 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, + ), + ); +} + +class _NavItem extends StatelessWidget { + const _NavItem({ + required this.icon, + required this.label, + required this.onTap, + this.selected = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool selected; + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + child: SizedBox( + width: 72, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 21, + color: selected ? _orange : const Color(0xFFCAC4C7), + ), + Text( + label, + style: TextStyle( + color: selected ? _orange : const Color(0xFFCAC4C7), + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); +} + +String _clock(Duration duration) { + final seconds = duration.inSeconds; + return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; +} + +const _orange = Color(0xFFFF5B3D); diff --git a/lib/pages/player/player_binding.dart b/lib/pages/player/player_binding.dart new file mode 100644 index 0000000..ca3fd8e --- /dev/null +++ b/lib/pages/player/player_binding.dart @@ -0,0 +1,10 @@ +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/android_picture_in_picture.dart'; +import 'player_logic.dart'; + +class PlayerBinding { + PlayerLogic create() => PlayerLogic( + history: SharedPreferencesWatchHistoryRepository(), + pictureInPicture: AndroidPictureInPicture(), + ); +} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart new file mode 100644 index 0000000..623af68 --- /dev/null +++ b/lib/pages/player/player_logic.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/android_picture_in_picture.dart'; +import '../../core/data/services/playback_queue.dart'; +import '../../core/data/services/playback_session.dart'; +import '../../core/data/services/video_player_port.dart'; +import '../home/home_feed_sections.dart'; + +class PlayerLogic { + PlayerLogic({required this.history, required this.pictureInPicture}); + + final WatchHistoryRepository history; + final AndroidPictureInPicture pictureInPicture; + + late PageController pages; + late PlaybackQueue queue; + late HomeFeedSections homeSections; + late List visibleIndices; + HomeFeedCategory category = HomeFeedCategory.recommended; + int categoryRequest = 0; + late PlaybackSession session; + VideoPlayerPort? trackedPlayer; + Episode? trackedEpisode; + Drama? trackedDrama; + DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0); + Future pendingWrite = Future.value(); + int pageRequest = 0; + bool muted = false; + bool stoppedAtFeedEnd = false; + VideoPlayerPort? scrubbingPlayer; + bool fullscreen = false; + bool pip = false; + bool pipPreparing = false; + + bool get immersive => fullscreen || pip || pipPreparing; +} diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart new file mode 100644 index 0000000..6a95274 --- /dev/null +++ b/lib/pages/player/player_view.dart @@ -0,0 +1,1501 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/models/player_port.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/engagement_controller.dart'; +import '../../core/data/services/platform_share_service.dart'; +import '../../core/data/services/playback_queue.dart'; +import '../../core/data/services/playback_session.dart'; +import '../../core/data/services/video_player_port.dart'; +import '../../core/formatting/localized_content.dart'; +import '../../l10n/app_localizations.dart'; +import '../discover/discover_view.dart'; +import '../drama_detail/drama_detail_view.dart'; +import '../home/home_feed_sections.dart'; +import '../home/widgets/category_swipe_region.dart'; +import '../home/widgets/home_feed_header.dart'; +import '../mine/mine_view.dart'; +import 'player_binding.dart'; +import 'player_logic.dart'; +import 'widgets/drama_comments_sheet.dart'; +import 'widgets/seek_progress_bar.dart'; + +class PlayerPage extends StatefulWidget { + const PlayerPage({ + super.key, + required this.drama, + required this.initialIndex, + this.homeMode = false, + this.embedded = false, + this.active = true, + this.resumeRequest = 0, + this.backRequest = 0, + this.onDiscover, + this.onMy, + this.onSearch, + this.onMenu, + this.feedDramas, + this.onDramaChanged, + this.onImmersiveChanged, + }); + + final Drama drama; + final int initialIndex; + final bool homeMode; + final bool embedded; + final bool active; + final int resumeRequest; + final int backRequest; + final VoidCallback? onDiscover; + final VoidCallback? onMy; + final VoidCallback? onSearch; + final VoidCallback? onMenu; + final List? feedDramas; + final ValueChanged? onDramaChanged; + final ValueChanged? onImmersiveChanged; + + @override + State createState() => _PlayerPageState(); +} + +class _PlayerPageState extends State with WidgetsBindingObserver { + late final PlayerLogic logic; + + void _notifyImmersive() => widget.onImmersiveChanged?.call(logic.immersive); + + Future _setFullscreen(bool value) async { + if (logic.fullscreen == value || !mounted) return; + setState(() => logic.fullscreen = value); + _notifyImmersive(); + try { + await SystemChrome.setPreferredOrientations( + value + ? [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ] + : [DeviceOrientation.portraitUp], + ); + await SystemChrome.setEnabledSystemUIMode( + value ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, + ); + } catch (_) { + if (value && mounted) { + setState(() => logic.fullscreen = false); + _notifyImmersive(); + } + } + } + + Future _enterPip() async { + if (logic.pip || logic.pipPreparing) return; + if (logic.session.active is! VideoPlayerPort) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), + ); + return; + } + if (logic.fullscreen) await _setFullscreen(false); + setState(() => logic.pipPreparing = true); + _notifyImmersive(); + await WidgetsBinding.instance.endOfFrame; + bool entered = false; + try { + entered = await logic.pictureInPicture.enter(); + } catch (_) { + // Android reports unavailable for devices without PiP support. + } + if (!mounted) return; + if (entered) { + setState(() { + logic.pip = true; + logic.pipPreparing = false; + }); + _notifyImmersive(); + logic.session.setAppResumed(true); + } else { + setState(() => logic.pipPreparing = false); + _notifyImmersive(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), + ); + } + } + + void _onPipChanged(bool enabled) { + if (!mounted) return; + setState(() { + logic.pip = enabled; + logic.pipPreparing = false; + }); + _notifyImmersive(); + logic.session.setAppResumed( + enabled || + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed, + ); + } + + Future _preparePlayer(Episode episode, PlayerPort player) async { + if (player is! VideoPlayerPort) return; + final drama = logic.queue.entries + .firstWhere((e) => e.episode.id == episode.id) + .drama; + try { + await logic.pendingWrite; + final saved = await logic.history.positionFor( + demoProfileId, + drama.id, + episode.id, + ); + if (saved != null) { + await player.controller.seekTo( + clampResumePosition(saved, player.controller.value.duration), + ); + } + } catch (_) { + // A local preference failure does not prevent playback. + } + } + + Future _activatePlayer(Episode episode, PlayerPort player) async { + if (player is! VideoPlayerPort) return; + final index = logic.session.currentIndex; + final drama = logic.queue.entryAt(index).drama; + await player.controller.setVolume(logic.muted ? 0 : 1); + if (logic.session.active != player) return; + logic.trackedPlayer = player; + logic.trackedEpisode = episode; + logic.trackedDrama = drama; + logic.lastProgressWrite = DateTime.now(); + var completionHandled = false; + var completionProgressSaved = false; + player.controller.addListener(() { + if (logic.session.active != player || !player.controller.value.isInitialized) { + return; + } + if (player.controller.value.isCompleted && !completionProgressSaved) { + completionProgressSaved = true; + unawaited( + _queueProgress(drama, episode, player.controller.value.duration), + ); + } + if (!completionHandled && + player.controller.value.isCompleted && + logic.session.canAutoAdvance && + logic.session.currentIndex == index) { + completionHandled = true; + unawaited(_advanceAfterEnd(player, index)); + } + final now = DateTime.now(); + if (now.difference(logic.lastProgressWrite) < const Duration(seconds: 5)) { + return; + } + logic.lastProgressWrite = now; + unawaited( + _queueProgress(drama, episode, player.controller.value.position), + ); + }); + } + + Future _advanceAfterEnd(VideoPlayerPort player, int index) async { + if (!mounted || logic.session.active != player || !logic.session.canAutoAdvance) { + return; + } + final next = logic.visibleIndices.indexOf(index) + 1; + if (next <= 0 || next >= logic.visibleIndices.length) { + logic.stoppedAtFeedEnd = true; + logic.session.setUserWantsPlay(false); + return; + } + final pages = logic.pages; + try { + if (!pages.hasClients) return; + await pages.animateToPage( + next, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } catch (_) { + // A category change can detach the old page controller mid-animation. + } + } + + Future _queueProgress(Drama drama, Episode episode, Duration position) { + logic.pendingWrite = logic.pendingWrite + .then( + (_) => logic.history.save(demoProfileId, drama.id, episode.id, position), + ) + .catchError((Object _) { + // Playback continues if local history cannot be stored. + }); + return logic.pendingWrite; + } + + Future _saveCurrentProgress() { + final player = logic.trackedPlayer; + final episode = logic.trackedEpisode; + final drama = logic.trackedDrama; + if (player == null || + episode == null || + drama == null || + !player.controller.value.isInitialized) { + return logic.pendingWrite; + } + return _queueProgress(drama, episode, player.controller.value.position); + } + + Future _selectEpisode(int index) async { + final request = ++logic.pageRequest; + _cancelScrub(); + // Capture the old position synchronously, persist without blocking a swipe. + unawaited(_saveCurrentProgress()); + if (request != logic.pageRequest || !mounted) return; + await logic.session.select(index, preloadIndex: _nextVisibleIndex(index)); + } + + int? _nextVisibleIndex(int index) { + final position = logic.visibleIndices.indexOf(index); + return position >= 0 && position + 1 < logic.visibleIndices.length + ? logic.visibleIndices[position + 1] + : null; + } + + void _shiftCategory(int offset) { + if (!widget.homeMode) return; + final next = logic.category.index + offset; + if (next < 0) { + widget.onMenu?.call(); + return; + } + if (next >= HomeFeedCategory.values.length) return; + unawaited(_selectCategory(HomeFeedCategory.values[next])); + } + + Future _selectCategory(HomeFeedCategory category) async { + if (!widget.homeMode || category == logic.category) return; + final request = ++logic.categoryRequest; + ++logic.pageRequest; + logic.session.setRouteVisible(false); + _cancelScrub(); + await _saveCurrentProgress(); + if (!mounted || request != logic.categoryRequest) return; + + final oldPages = logic.pages; + final indices = logic.homeSections.indicesFor(category); + setState(() { + logic.category = category; + logic.visibleIndices = indices; + logic.pages = PageController(); + }); + WidgetsBinding.instance.addPostFrameCallback((_) => oldPages.dispose()); + + if (indices.isEmpty) return; + widget.onDramaChanged?.call(logic.queue.entryAt(indices.first).drama); + await logic.session.select( + indices.first, + preloadIndex: _nextVisibleIndex(indices.first), + ); + if (mounted && request == logic.categoryRequest) { + if (logic.stoppedAtFeedEnd) { + logic.stoppedAtFeedEnd = false; + logic.session.setUserWantsPlay(true); + } + logic.session.setRouteVisible(widget.active); + } + } + + void _beginScrub(VideoPlayerPort player) { + if (logic.session.active != player) return; + logic.scrubbingPlayer = player; + logic.session.setOverlayOpen(true); + } + + void _cancelScrub() { + if (logic.scrubbingPlayer == null) return; + logic.scrubbingPlayer = null; + logic.session.setOverlayOpen(false); + } + + Future _seekTo(VideoPlayerPort player, Duration position) async { + if (logic.scrubbingPlayer != player || logic.session.active != player) return; + try { + await logic.session.playbackSettled; + if (logic.scrubbingPlayer != player || logic.session.active != player) return; + await player.controller.seekTo(position); + if (logic.session.active == player) await _saveCurrentProgress(); + } catch (_) { + if (mounted && logic.session.active == player) { + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.seekFailed))); + } + } finally { + if (logic.scrubbingPlayer == player) _cancelScrub(); + } + } + + Future _leave() async { + await _saveCurrentProgress(); + if (mounted) Navigator.of(context).pop(); + } + + @override + void initState() { + super.initState(); + logic = PlayerBinding().create(); + WidgetsBinding.instance.addObserver(this); + logic.pictureInPicture.setStateListener(_onPipChanged); + logic.queue = widget.feedDramas == null + ? PlaybackQueue.series(widget.drama) + : PlaybackQueue.home(widget.feedDramas!); + logic.homeSections = HomeFeedSections([ + for (final entry in logic.queue.entries) entry.drama, + ]); + logic.visibleIndices = widget.homeMode + ? logic.homeSections.indicesFor(logic.category) + : List.generate(logic.queue.length, (index) => index); + logic.pages = PageController(initialPage: widget.initialIndex); + logic.session = PlaybackSession( + episodes: [for (final entry in logic.queue.entries) entry.episode], + factory: context.read(), + canPlay: (episode) => episode.isFree, + prepare: _preparePlayer, + activate: _activatePlayer, + coordinator: context.read(), + ); + if (!widget.active || logic.visibleIndices.isEmpty) { + logic.session.setRouteVisible(false); + } + unawaited(_selectEpisode(widget.initialIndex)); + } + + @override + void didUpdateWidget(covariant PlayerPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.active != widget.active) { + logic.session.setRouteVisible(widget.active && logic.visibleIndices.isNotEmpty); + if (!widget.active) { + _cancelScrub(); + unawaited(_saveCurrentProgress()); + } + } + if (oldWidget.resumeRequest != widget.resumeRequest) { + logic.session.setUserWantsPlay(true); + } + if (oldWidget.backRequest != widget.backRequest) { + final request = widget.backRequest; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.backRequest == request) { + if (logic.fullscreen) { + unawaited(_setFullscreen(false)); + } else { + _shiftCategory(-1); + } + } + }); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) unawaited(_saveCurrentProgress()); + logic.session.setAppResumed( + state == AppLifecycleState.resumed || logic.pip || logic.pipPreparing, + ); + if (state != AppLifecycleState.resumed) _cancelScrub(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + logic.pictureInPicture.setStateListener(null); + if (logic.fullscreen) { + unawaited( + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), + ); + unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); + } + unawaited(_saveCurrentProgress()); + logic.session.setRouteVisible(false); + _cancelScrub(); + unawaited(logic.session.close()); + logic.pages.dispose(); + super.dispose(); + } + + Future _openDetail() async { + logic.session.setRouteVisible(false); + await logic.session.playbackSettled; + await _saveCurrentProgress(); + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + DramaDetailPage(drama: logic.queue.entryAt(logic.session.currentIndex).drama), + ), + ); + if (mounted) logic.session.setRouteVisible(widget.active); + } + + Future _showEpisodes() async { + if (widget.feedDramas != null) { + await _openDetail(); + return; + } + final l10n = AppLocalizations.of(context)!; + logic.session.setOverlayOpen(true); + await logic.session.playbackSettled; + if (!mounted) { + logic.session.setOverlayOpen(false); + return; + } + try { + await showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF202024), + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: SizedBox( + height: MediaQuery.sizeOf(context).height * 0.6, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Text( + l10n.episodes, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: logic.queue.length, + itemBuilder: (context, index) { + final episode = logic.queue.entryAt(index).episode; + return ListTile( + leading: CircleAvatar( + backgroundColor: const Color(0xFF3A3030), + child: Text('${episode.number}'), + ), + title: Text(l10n.episodeLabel(episode.number)), + subtitle: Text(l10n.freeTestMedia), + trailing: const Icon(Icons.play_arrow_rounded), + onTap: () { + Navigator.of(sheetContext).pop(); + unawaited( + logic.pages.animateToPage( + index, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } finally { + logic.session.setOverlayOpen(false); + } + } + + Future _showMy({int initialSection = 0}) async { + if (widget.embedded) { + widget.onMy?.call(); + return; + } + logic.session.setRouteVisible(false); + await logic.session.playbackSettled; + await _saveCurrentProgress(); + if (!mounted) return; + final port = logic.session.active; + final drama = logic.queue.entryAt(logic.session.currentIndex).drama; + final duration = port is VideoPlayerPort + ? port.controller.value.duration + : Duration.zero; + final action = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MyPage( + drama: drama, + duration: duration, + initialSection: initialSection, + ), + ), + ); + if (!mounted) return; + if (action == MyPageAction.discover) { + await _showDiscover(); + } else { + logic.session.setRouteVisible(true); + } + if (action == MyPageAction.resume) logic.session.setUserWantsPlay(true); + } + + Future _showSearch() async { + if (widget.embedded) { + widget.onSearch?.call(); + return; + } + await _showDiscover(focusSearch: true); + } + + Future _showDiscover({bool focusSearch = false}) async { + if (widget.embedded) { + widget.onDiscover?.call(); + return; + } + logic.session.setRouteVisible(false); + await logic.session.playbackSettled; + await _saveCurrentProgress(); + if (!mounted) return; + final port = logic.session.active; + final duration = port is VideoPlayerPort + ? port.controller.value.duration + : Duration.zero; + final action = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DiscoverPage( + drama: widget.drama, + duration: duration, + focusSearch: focusSearch, + ), + ), + ); + if (!mounted) return; + if (action == DiscoverPageAction.detail) { + await _openDetail(); + } else if (action == DiscoverPageAction.my || + action == DiscoverPageAction.watchlist) { + await _showMy( + initialSection: action == DiscoverPageAction.watchlist ? 1 : 0, + ); + } else { + logic.session.setRouteVisible(true); + if (action == DiscoverPageAction.resume) { + logic.session.setUserWantsPlay(true); + } + } + } + + Future _toggleMute() async { + final port = logic.session.active; + if (port is! VideoPlayerPort) return; + final next = !logic.muted; + await port.controller.setVolume(next ? 0 : 1); + if (mounted) setState(() => logic.muted = next); + } + + Future _showComments(Drama drama) async { + logic.session.setOverlayOpen(true); + await logic.session.playbackSettled; + if (!mounted) { + logic.session.setOverlayOpen(false); + return; + } + try { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: const Color(0xFF232328), + showDragHandle: true, + builder: (_) => DramaCommentsSheet(drama: drama), + ); + } finally { + logic.session.setOverlayOpen(false); + } + } + + Future _shareDrama(Drama drama) async { + final l10n = AppLocalizations.of(context)!; + logic.session.setOverlayOpen(true); + await logic.session.playbackSettled; + try { + await const PlatformShareService().shareDrama( + title: localizedDramaTitle(l10n, drama), + synopsis: localizedDramaSynopsis(l10n, drama), + footer: l10n.shareFooter, + ); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.shareFailed)), + ); + } + } finally { + logic.session.setOverlayOpen(false); + } + } + + @override + Widget build(BuildContext context) { + final engagement = context.watch(); + final category = logic.category; + final indices = logic.visibleIndices; + final pages = logic.pages; + final content = AnimatedBuilder( + animation: logic.session, + builder: (context, _) => indices.isEmpty && widget.homeMode + ? _buildEmptyCategory(context) + : PageView.builder( + key: ValueKey(category), + controller: pages, + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + itemCount: indices.length, + onPageChanged: (position) { + if (category != logic.category) return; + final index = indices[position]; + widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); + unawaited(_selectEpisode(index)); + }, + itemBuilder: (context, position) => + _buildEpisode(context, indices[position], engagement), + ), + ); + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Theme( + data: ThemeData.dark(useMaterial3: true), + child: widget.embedded + ? content + : Scaffold(backgroundColor: Colors.black, body: content), + ), + ); + } + + Widget _buildEmptyCategory(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return CategorySwipeRegion( + key: const Key('home-empty-swipe'), + onShift: _shiftCategory, + child: ColoredBox( + color: const Color(0xFF111214), + child: SafeArea( + child: Column( + children: [ + HomeFeedHeader( + l10n: l10n, + category: logic.category, + onCategorySelected: (value) => + unawaited(_selectCategory(value)), + onCategoryShift: _shiftCategory, + onSearch: _showSearch, + onMy: _showMy, + onMenu: widget.onMenu, + ), + const Spacer(), + const Icon( + Icons.video_library_outlined, + size: 48, + color: Color(0xFFE6E1E3), + ), + const SizedBox(height: 12), + Text( + l10n.noCategoryDramas, + style: const TextStyle(fontSize: 16, color: Colors.white), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + l10n.demoCatalogNotice, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: Color(0xFFF3EEEA)), + ), + ), + const Spacer(), + ], + ), + ), + ), + ); + } + + Widget _buildEpisode( + BuildContext context, + int index, + EngagementController engagement, + ) { + final l10n = AppLocalizations.of(context)!; + final entry = logic.queue.entryAt(index); + final drama = entry.drama; + final episode = entry.episode; + final selected = index == logic.session.currentIndex; + // Selection changes before the prepared slot is promoted. Use its controller + // throughout the handoff instead of briefly falling back to a placeholder. + final port = + (selected ? logic.session.active : null) ?? logic.session.preparedPlayerAt(index); + final videoPort = port is VideoPlayerPort ? port : null; + final ready = videoPort?.controller.value.isInitialized ?? false; + Widget? video; + if (ready) { + video = GestureDetector( + onTap: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + child: FittedBox( + fit: logic.immersive ? BoxFit.contain : BoxFit.cover, + child: SizedBox( + width: videoPort!.controller.value.size.width, + height: videoPort.controller.value.size.height, + child: VideoPlayer( + videoPort.controller, + key: ValueKey(videoPort.controller), + ), + ), + ), + ); + if (widget.homeMode) { + video = CategorySwipeRegion( + key: selected ? const Key('home-video-swipe') : null, + onShift: _shiftCategory, + child: video, + ); + } + } + + return Stack( + fit: StackFit.expand, + children: [ + if (widget.homeMode && !ready && !logic.immersive) + CategorySwipeRegion( + key: selected ? const Key('home-video-swipe') : null, + onShift: _shiftCategory, + child: const ColoredBox(color: Colors.black), + ) + else + const ColoredBox(color: Colors.black), + ?video, + if (!logic.immersive) + const IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xD6000000), + Color(0x66000000), + Color(0x33000000), + Color(0xF0000000), + ], + stops: [0, 0.22, 0.5, 1], + ), + ), + ), + ), + if (selected && logic.session.loading) + const Center( + child: CircularProgressIndicator(color: Color(0xFFFF6B45)), + ), + if (selected && ready && !logic.session.userWantsPlay && !logic.pip) + Center( + child: IconButton.filled( + iconSize: 48, + onPressed: () => logic.session.setUserWantsPlay(true), + icon: const Icon(Icons.play_arrow_rounded), + ), + ), + if (widget.homeMode && (logic.pip || logic.pipPreparing)) + const SizedBox.expand() + else if (widget.homeMode && logic.fullscreen) + _FullscreenControls( + l10n: l10n, + controller: ready ? videoPort!.controller : null, + onClose: () => unawaited(_setFullscreen(false)), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(_enterPip()) + : null, + onScrubStart: ready ? () => _beginScrub(videoPort!) : null, + onSeek: ready ? (position) => _seekTo(videoPort!, position) : null, + ) + else if (widget.homeMode) + _StitchHomeOverlay( + l10n: l10n, + drama: drama, + episode: episode, + controller: ready ? videoPort!.controller : null, + liked: engagement.isLiked(drama.id), + saved: engagement.isSaved(drama.id), + muted: logic.muted, + category: logic.category, + onCategorySelected: (value) => unawaited(_selectCategory(value)), + onCategoryShift: _shiftCategory, + onDetail: _openDetail, + onDiscover: _showDiscover, + onEpisodes: _showEpisodes, + onMy: _showMy, + onSearch: _showSearch, + onMenu: widget.onMenu, + onMute: _toggleMute, + onLike: () => engagement.toggleLiked(drama.id), + onSave: () => engagement.toggleSaved(drama.id), + onComments: () => _showComments(drama), + onShare: () => _shareDrama(drama), + onFullscreen: () => unawaited(_setFullscreen(true)), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(_enterPip()) + : null, + onScrubStart: ready ? () => _beginScrub(videoPort!) : null, + onSeek: ready ? (position) => _seekTo(videoPort!, position) : null, + showBottomNav: !widget.embedded, + ) + else + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (!widget.homeMode) + IconButton( + onPressed: () => unawaited(_leave()), + tooltip: l10n.backToSeries, + icon: const Icon(Icons.arrow_back_rounded), + ) + else + const Icon( + Icons.play_circle_fill_rounded, + color: Color(0xFFFF6B45), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + widget.homeMode + ? l10n.appName + : localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + IconButton( + tooltip: l10n.language, + onPressed: _showMy, + icon: const Icon(Icons.language_rounded), + ), + ], + ), + if (widget.homeMode) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.homeTab, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w800, + decoration: TextDecoration.underline, + ), + ), + const SizedBox(width: 22), + TextButton( + onPressed: _openDetail, + child: Text( + l10n.discoverTab, + style: const TextStyle( + color: Color(0xFFF3EEEA), + fontSize: 15, + ), + ), + ), + ], + ), + const Spacer(), + if (selected && logic.session.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: FilledButton( + onPressed: () => unawaited(_selectEpisode(index)), + child: Text('${l10n.playbackError} ${l10n.retry}'), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.demoTestMedia, + style: const TextStyle( + color: Color(0xFFFFB199), + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + localizedDramaTitle(l10n, drama), + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w800, + shadows: [ + Shadow(blurRadius: 12, color: Colors.black), + ], + ), + ), + const SizedBox(height: 6), + Text( + localizedDramaSynopsis(l10n, drama), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFF7F4F2)), + ), + ], + ), + ), + const SizedBox(width: 8), + Column( + children: [ + IconButton.filledTonal( + tooltip: l10n.exploreSeries, + onPressed: _openDetail, + style: IconButton.styleFrom( + backgroundColor: Colors.black45, + foregroundColor: Colors.white, + ), + icon: const Icon(Icons.info_outline_rounded), + ), + const SizedBox(height: 8), + IconButton.filledTonal( + tooltip: l10n.episodes, + onPressed: _showEpisodes, + style: IconButton.styleFrom( + backgroundColor: Colors.black45, + foregroundColor: Colors.white, + ), + icon: const Icon(Icons.grid_view_rounded), + ), + const SizedBox(height: 8), + IconButton.filledTonal( + tooltip: logic.session.userWantsPlay + ? l10n.pause + : l10n.play, + style: IconButton.styleFrom( + backgroundColor: Colors.black45, + foregroundColor: Colors.white, + ), + onPressed: ready + ? () => logic.session.setUserWantsPlay( + !logic.session.userWantsPlay, + ) + : null, + icon: Icon( + logic.session.userWantsPlay + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 18), + TextButton.icon( + onPressed: _showEpisodes, + icon: const Icon(Icons.play_arrow_rounded), + label: Text( + '${l10n.episodeProgress(episode.number, drama.episodes.length)} · ${l10n.episodes}', + ), + style: TextButton.styleFrom(foregroundColor: Colors.white), + ), + SeekProgressBar( + valueListenable: ready ? videoPort!.controller : null, + semanticsLabel: l10n.seekVideo, + showTimes: false, + onScrubStart: ready ? () => _beginScrub(videoPort!) : null, + onSeek: ready + ? (position) => _seekTo(videoPort!, position) + : null, + ), + if (widget.homeMode) ...[ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _BottomAction( + icon: Icons.home_rounded, + label: l10n.homeTab, + selected: true, + onTap: () {}, + ), + _BottomAction( + icon: Icons.explore_outlined, + label: l10n.discoverTab, + onTap: _showDiscover, + ), + _BottomAction( + icon: Icons.person_outline_rounded, + label: l10n.myTab, + onTap: _showMy, + ), + ], + ), + ], + ], + ), + ), + ), + ], + ); + } +} + +class _FullscreenControls extends StatelessWidget { + const _FullscreenControls({ + required this.l10n, + required this.controller, + required this.onClose, + required this.onPip, + required this.onScrubStart, + required this.onSeek, + }); + + final AppLocalizations l10n; + final VideoPlayerController? controller; + final VoidCallback onClose; + final VoidCallback? onPip; + final VoidCallback? onScrubStart; + final Future Function(Duration)? onSeek; + + @override + Widget build(BuildContext context) => SafeArea( + child: Column( + children: [ + Row( + children: [ + IconButton( + tooltip: l10n.exitFullscreen, + onPressed: onClose, + icon: const Icon(Icons.close_fullscreen_rounded), + ), + const Spacer(), + if (onPip != null) + IconButton( + tooltip: l10n.pictureInPicture, + onPressed: onPip, + icon: const Icon(Icons.picture_in_picture_alt_outlined), + ), + ], + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SeekProgressBar( + valueListenable: controller, + semanticsLabel: l10n.seekVideo, + compactIdle: true, + onScrubStart: onScrubStart, + onSeek: onSeek, + ), + ), + ], + ), + ); +} + +class _StitchHomeOverlay extends StatelessWidget { + const _StitchHomeOverlay({ + required this.l10n, + required this.drama, + required this.episode, + required this.controller, + required this.liked, + required this.saved, + required this.muted, + required this.category, + required this.onCategorySelected, + required this.onCategoryShift, + required this.onDetail, + required this.onDiscover, + required this.onEpisodes, + required this.onMy, + required this.onSearch, + required this.onMenu, + required this.onMute, + required this.onLike, + required this.onSave, + required this.onComments, + required this.onShare, + required this.onFullscreen, + required this.onPip, + required this.onScrubStart, + required this.onSeek, + required this.showBottomNav, + }); + + final AppLocalizations l10n; + final Drama drama; + final Episode episode; + final VideoPlayerController? controller; + final bool liked; + final bool saved; + final bool muted; + final HomeFeedCategory category; + final ValueChanged onCategorySelected; + final ValueChanged onCategoryShift; + final VoidCallback onDetail; + final VoidCallback onDiscover; + final VoidCallback onEpisodes; + final VoidCallback onMy; + final VoidCallback onSearch; + final VoidCallback? onMenu; + final VoidCallback onMute; + final VoidCallback onLike; + final VoidCallback onSave; + final VoidCallback onComments; + final VoidCallback onShare; + final VoidCallback onFullscreen; + final VoidCallback? onPip; + final VoidCallback? onScrubStart; + final Future Function(Duration)? onSeek; + final bool showBottomNav; + + @override + Widget build(BuildContext context) => SafeArea( + bottom: showBottomNav, + child: Column( + children: [ + HomeFeedHeader( + l10n: l10n, + category: category, + onCategorySelected: onCategorySelected, + onCategoryShift: onCategoryShift, + onSearch: onSearch, + onMy: onMy, + onMenu: onMenu, + onMute: onMute, + muted: muted, + ), + const Spacer(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 12, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MetaChip(text: l10n.freeTestMedia, accent: true), + const SizedBox(height: 5), + Text( + localizedDramaTitle(l10n, drama), + style: const TextStyle( + color: Colors.white, + fontSize: 19, + fontWeight: FontWeight.w800, + shadows: [Shadow(blurRadius: 12, color: Colors.black)], + ), + ), + const SizedBox(height: 3), + Text( + localizedDramaSynopsis(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, + height: 1.2, + color: Colors.white, + shadows: [Shadow(blurRadius: 8, color: Colors.black)], + ), + ), + Tooltip( + message: l10n.exploreSeries, + child: TextButton( + onPressed: onDetail, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 28), + foregroundColor: const Color(0xFFFFA18B), + ), + child: Text( + l10n.exploreSeries, + style: const TextStyle(fontSize: 12), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + _RailAction( + key: const Key('rail-save'), + icon: saved + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + label: l10n.save, + selected: saved, + onTap: onSave, + ), + _RailAction( + key: const Key('rail-comments'), + icon: Icons.mode_comment_outlined, + label: l10n.comments, + onTap: onComments, + ), + _RailAction( + key: const Key('rail-like'), + icon: liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + label: l10n.like, + selected: liked, + onTap: onLike, + ), + _RailAction( + key: const Key('rail-share'), + icon: Icons.share_rounded, + label: l10n.share, + onTap: onShare, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Tooltip( + message: l10n.watchFullscreen, + child: TextButton.icon( + onPressed: onFullscreen, + icon: const Icon(Icons.screen_rotation_outlined, size: 15), + label: Text(l10n.watchFullscreen), + style: TextButton.styleFrom( + foregroundColor: Colors.white, + textStyle: const TextStyle(fontSize: 11), + visualDensity: VisualDensity.compact, + ), + ), + ), + if (onPip != null) + IconButton( + tooltip: l10n.pictureInPicture, + onPressed: onPip, + visualDensity: VisualDensity.compact, + iconSize: 17, + icon: const Icon(Icons.picture_in_picture_alt_outlined), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Tooltip( + message: l10n.episodes, + child: InkWell( + onTap: onEpisodes, + borderRadius: BorderRadius.circular(8), + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: const Color(0xE6212125), + borderRadius: BorderRadius.circular(8), + ), + child: IconTheme( + data: const IconThemeData(color: Colors.white), + child: DefaultTextStyle.merge( + style: const TextStyle(color: Colors.white), + child: Row( + children: [ + const Icon(Icons.play_circle_outline_rounded, size: 19), + const SizedBox(width: 7), + Expanded( + child: Text( + '${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + '${l10n.episodes} (${drama.episodes.length})', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const Icon(Icons.keyboard_arrow_up_rounded, size: 18), + ], + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 2), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SeekProgressBar( + valueListenable: controller, + semanticsLabel: l10n.seekVideo, + onScrubStart: onScrubStart, + onSeek: onSeek, + compactIdle: true, + ), + ), + const SizedBox(height: 2), + if (showBottomNav) + Container( + height: 62, + color: const Color(0xF20C0C0F), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _BottomAction( + icon: Icons.play_circle_outline_rounded, + label: l10n.homeTab, + selected: true, + onTap: () {}, + ), + _BottomAction( + icon: Icons.explore_outlined, + label: l10n.discoverTab, + onTap: onDiscover, + ), + _BottomAction( + icon: Icons.video_library_outlined, + label: l10n.myTab, + onTap: onMy, + ), + ], + ), + ), + ], + ), + ); +} + +class _MetaChip extends StatelessWidget { + const _MetaChip({required this.text, this.accent = false}); + final String text; + final bool accent; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xB51F1F22), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + text, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: accent ? const Color(0xFFFFC2B0) : const Color(0xFFF7F4F2), + ), + ), + ); +} + +class _RailAction extends StatelessWidget { + const _RailAction({ + super.key, + required this.icon, + required this.label, + required this.onTap, + this.selected = false, + }); + final IconData icon; + final String label; + final VoidCallback? onTap; + final bool selected; + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton.filledTonal( + tooltip: label, + onPressed: onTap, + style: IconButton.styleFrom( + backgroundColor: const Color(0xB719191B), + foregroundColor: selected ? const Color(0xFFFF6B45) : Colors.white, + ), + icon: Icon(icon, size: 22), + ), + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + shadows: [Shadow(blurRadius: 8, color: Colors.black)], + ), + ), + ], + ); +} + +class _BottomAction extends StatelessWidget { + const _BottomAction({ + required this.icon, + required this.label, + required this.onTap, + this.selected = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool selected; + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: selected ? const Color(0xFFFF6B45) : const Color(0xFFF3EEEA), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 11, + color: selected ? const Color(0xFFFF6B45) : const Color(0xFFF3EEEA), + ), + ), + ], + ), + ), + ); +} diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart new file mode 100644 index 0000000..9433b60 --- /dev/null +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/engagement_controller.dart'; +import '../../../l10n/app_localizations.dart'; + +class DramaCommentsSheet extends StatefulWidget { + const DramaCommentsSheet({super.key, required this.drama}); + + final Drama drama; + + @override + State createState() => _DramaCommentsSheetState(); +} + +class _DramaCommentsSheetState extends State { + final _input = TextEditingController(); + + @override + void dispose() { + _input.dispose(); + super.dispose(); + } + + void _post() { + final text = _input.text.trim(); + if (text.isEmpty) return; + context.read().addComment(widget.drama.id, text); + _input.clear(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final comments = context.watch().commentsFor( + widget.drama.id, + ); + final available = + MediaQuery.sizeOf(context).height - + MediaQuery.viewInsetsOf(context).bottom - + 24; + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: SizedBox( + height: available.clamp(240.0, 460.0), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.comments, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + l10n.localOnlyComments, + style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 13), + ), + const SizedBox(height: 14), + Expanded( + child: comments.isEmpty + ? Center( + child: Text( + l10n.noComments, + style: const TextStyle(color: Color(0xFFE6E1E3)), + ), + ) + : ListView.separated( + itemCount: comments.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) => ListTile( + leading: const CircleAvatar( + radius: 16, + child: Icon(Icons.person_rounded, size: 18), + ), + title: Text(comments[index]), + contentPadding: EdgeInsets.zero, + ), + ), + ), + Row( + children: [ + Expanded( + child: TextField( + key: const Key('comment-input'), + controller: _input, + maxLength: 300, + maxLines: 1, + decoration: InputDecoration( + hintText: l10n.writeComment, + counterText: '', + filled: true, + fillColor: const Color(0xFF35353A), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + ), + onSubmitted: (_) => _post(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + key: const Key('comment-post'), + tooltip: l10n.postComment, + onPressed: _post, + icon: const Icon(Icons.send_rounded), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart new file mode 100644 index 0000000..71ee09a --- /dev/null +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +/// Keeps seeks within the playable range so dragging to the edge does not +/// trigger the player's completion callback before playback resumes. +Duration seekPositionForFraction(double fraction, Duration duration) { + final total = duration.inMilliseconds; + if (total <= 0) return Duration.zero; + final lastPlayable = total > 250 ? total - 250 : total - 1; + final requested = (fraction.clamp(0.0, 1.0) * total).round(); + return Duration(milliseconds: requested.clamp(0, lastPlayable)); +} + +class SeekProgressBar extends StatefulWidget { + const SeekProgressBar({ + super.key, + required this.valueListenable, + required this.semanticsLabel, + this.onScrubStart, + this.onSeek, + this.showTimes = true, + this.compactIdle = false, + }); + + final ValueListenable? valueListenable; + final String semanticsLabel; + final VoidCallback? onScrubStart; + final Future Function(Duration)? onSeek; + final bool showTimes; + final bool compactIdle; + + @override + State createState() => _SeekProgressBarState(); +} + +class _SeekProgressBarState extends State { + double? _previewFraction; + + @override + void didUpdateWidget(covariant SeekProgressBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.valueListenable != widget.valueListenable) { + _previewFraction = null; + } + } + + Future _finish(double fraction, Duration duration) async { + final source = widget.valueListenable; + setState(() => _previewFraction = fraction); + try { + await widget.onSeek?.call(seekPositionForFraction(fraction, duration)); + } finally { + if (mounted && widget.valueListenable == source) { + setState(() => _previewFraction = null); + } + } + } + + @override + Widget build(BuildContext context) { + final source = widget.valueListenable; + if (source == null) { + return _buildTrack(context, const VideoPlayerValue.uninitialized()); + } + return ValueListenableBuilder( + valueListenable: source, + builder: (context, value, _) => _buildTrack(context, value), + ); + } + + Widget _buildTrack(BuildContext context, VideoPlayerValue video) { + final total = video.duration.inMilliseconds; + final enabled = video.isInitialized && total > 0 && widget.onSeek != null; + final played = total > 0 + ? (video.position.inMilliseconds / total).clamp(0.0, 1.0) + : 0.0; + final fraction = _previewFraction ?? played; + final previewPosition = _previewFraction == null + ? video.position + : seekPositionForFraction(fraction, video.duration); + final scrubbing = _previewFraction != null; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: widget.compactIdle && !scrubbing ? 20 : 36, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: widget.compactIdle && !scrubbing ? 2 : 3, + activeTrackColor: const Color(0xFFFF5C37), + inactiveTrackColor: Colors.white30, + thumbColor: Colors.white, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: widget.compactIdle && !scrubbing ? 0 : 6, + ), + overlayShape: RoundSliderOverlayShape( + overlayRadius: widget.compactIdle && !scrubbing ? 0 : 11, + ), + ), + child: Semantics( + label: widget.semanticsLabel, + child: Slider( + value: fraction, + onChangeStart: enabled + ? (value) { + setState(() => _previewFraction = value); + widget.onScrubStart?.call(); + } + : null, + onChanged: enabled + ? (value) => setState(() => _previewFraction = value) + : null, + onChangeEnd: enabled + ? (value) => unawaited(_finish(value, video.duration)) + : null, + ), + ), + ), + ), + 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), + ), + ], + ), + ], + ); + } + + String _format(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..ee2e934 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,503 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.5.2" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.3" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + html: + dependency: transitive + description: + name: html + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.15.7" + image: + dependency: transitive + description: + name: image + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.12.0" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.20.3" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.2" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: "direct dev" + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.12" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "07ae1e03f2d4c8e2e9a75ee678f90acbc4506d4458a9c628512d119a1eee5754" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.14.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: d27054dea34d748a44f06d433a57005d2aac69944485b0abbaed3303dbcfa3f1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.12.2" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "4f8a845173377ab250a50f9b9dfe4a43500cc76a7c49dd8410d02d8fc29dfd8b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.12.1" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.9.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..99de653 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,112 @@ +name: nontonki +description: "Nontonki short-drama playback prototype." +# 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 + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + provider: ^6.1.5+1 + video_player: ^2.14.0 + shared_preferences: ^2.5.5 + flutter_localizations: + sdk: flutter + +dev_dependencies: + flutter_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 + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + shared_preferences_platform_interface: ^2.4.2 + flutter_launcher_icons: ^0.14.4 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# 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 + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package + +flutter_launcher_icons: + android: true + ios: true + image_path: assets/icon/nontonki.png + adaptive_icon_background: "#D74A7D" + adaptive_icon_foreground: assets/icon/nontonki.png + remove_alpha_ios: true diff --git a/test/catalog/catalog_controller_test.dart b/test/catalog/catalog_controller_test.dart new file mode 100644 index 0000000..dc4ab8f --- /dev/null +++ b/test/catalog/catalog_controller_test.dart @@ -0,0 +1,39 @@ +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'; + +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); + + repo.fail = true; + await controller.load(); + expect(controller.error, isNotNull); + + 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); + }, + ); +} + +class ScriptedCatalogRepository implements CatalogRepository { + bool fail = false; + List items = const []; + + @override + Future> fetchDramas() async { + if (fail) throw StateError('offline'); + return items; + } +} diff --git a/test/catalog/demo_media_test.dart b/test/catalog/demo_media_test.dart new file mode 100644 index 0000000..b823934 --- /dev/null +++ b/test/catalog/demo_media_test.dart @@ -0,0 +1,26 @@ +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)); + }); +} diff --git a/test/catalog/home_feed_category_test.dart b/test/catalog/home_feed_category_test.dart new file mode 100644 index 0000000..f10457a --- /dev/null +++ b/test/catalog/home_feed_category_test.dart @@ -0,0 +1,22 @@ +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', + ); + }, + ); +} diff --git a/test/engagement/engagement_controller_test.dart b/test/engagement/engagement_controller_test.dart new file mode 100644 index 0000000..fddd9b2 --- /dev/null +++ b/test/engagement/engagement_controller_test.dart @@ -0,0 +1,46 @@ +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: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 { + final repository = InMemoryEngagementRepository(); + final first = EngagementController(repository); + await first.load(); + await first.toggleSaved('signal'); + await first.toggleLiked('signal'); + await first.addComment('signal', ' Great clip '); + + expect(first.isSaved('signal'), isTrue); + expect(first.isSaved('city'), isFalse); + expect(first.isLiked('city'), isFalse); + expect(first.commentsFor('signal'), ['Great clip']); + expect(first.commentsFor('city'), isEmpty); + + final reopened = EngagementController(repository); + await reopened.load(); + expect(reopened.isSaved('signal'), isTrue); + expect(reopened.isLiked('signal'), isTrue); + expect(reopened.commentsFor('signal'), ['Great clip']); + await reopened.toggleSaved('signal'); + expect(reopened.isSaved('signal'), isFalse); + }); + + test('local engagement survives a new repository instance', () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final first = EngagementController(SharedPreferencesEngagementRepository()); + await first.load(); + await first.toggleSaved('city'); + await first.addComment('city', 'Local note'); + + final reopened = EngagementController( + SharedPreferencesEngagementRepository(), + ); + await reopened.load(); + expect(reopened.isSaved('city'), isTrue); + expect(reopened.commentsFor('city'), ['Local note']); + }); +} diff --git a/test/playback/playback_preload_test.dart b/test/playback/playback_preload_test.dart new file mode 100644 index 0000000..569f263 --- /dev/null +++ b/test/playback/playback_preload_test.dart @@ -0,0 +1,344 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nontonki/core/data/models/drama.dart'; +import 'package:nontonki/core/data/services/playback_session.dart'; +import 'package:nontonki/core/data/models/player_port.dart'; + +void main() { + final episodes = List.generate( + 5, + (i) => Episode( + id: 'ep-$i', + number: i + 1, + title: 'Episode $i', + assetPath: 'test-$i.mp4', + isFree: i < 4, + ), + ); + + test('warm playback starts before old native disposal finishes', () async { + final release = Completer(); + final factory = _Factory(disposeGates: {'ep-0': release}); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + ); + await session.select(0, preloadIndex: 1); + await session.preloadSettled; + final selection = session.select(1, preloadIndex: 2); + await _tick(); + expect(factory.players[1].playing, isTrue); + expect(factory.players[0].playing, isFalse); + expect(session.loading, isFalse); + expect(session.livePlayerCount, 2); + expect(factory.players, hasLength(2)); + release.complete(); + await selection; + await session.preloadSettled; + expect(factory.peakLive, 2); + await session.close(); + }); + + test('failed native release blocks another route from allocating', () async { + final factory = _Factory(disposeFailures: {'ep-0'}); + final coordinator = PlaybackCoordinator(); + PlaybackSession createSession() => PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + coordinator: coordinator, + ); + final home = createSession(); + final series = createSession(); + await home.select(0, preloadIndex: 1); + await home.preloadSettled; + home.setRouteVisible(false); + await series.select(2, preloadIndex: 3); + expect(series.error, isNotNull); + expect(factory.players, hasLength(2)); + await series.select(2, preloadIndex: 3); + await series.preloadSettled; + expect(series.error, isNull); + expect(factory.peakLive, 2); + await series.close(); + await home.close(); + expect(factory.live, 0); + }); + + test( + 'next is prepared silently and promoted without another initialize', + () async { + final factory = _Factory(); + final prepared = []; + final activated = []; + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (e) => e.isFree, + prepare: (e, _) async => prepared.add(e.id), + activate: (e, _) async => activated.add(e.id), + ); + await session.select(0, preloadIndex: 1); + await session.preloadSettled; + final warm = factory.players[1]; + expect(prepared, ['ep-0', 'ep-1']); + expect(activated, ['ep-0']); + expect(warm.playCalls, 0); + expect(session.preloadedIndex, 1); + final loadingStates = []; + session.addListener(() => loadingStates.add(session.loading)); + await session.select(1, preloadIndex: 2); + await session.preloadSettled; + expect(session.active, same(warm)); + expect(warm.initializeCalls, 1); + expect(warm.playCalls, 1); + expect(loadingStates, isNot(contains(true))); + expect(activated, ['ep-0', 'ep-1']); + expect(factory.peakLive, 2); + expect(factory.peakPlaying, 1); + await session.close(); + expect(factory.live, 0); + }, + ); + + test('in-flight preload is reused when swiped before ready', () async { + final gate = Completer(); + final factory = _Factory(gates: {'ep-1': gate}); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + ); + await session.select(0, preloadIndex: 1); + await _tick(); + final warm = factory.players[1]; + final selection = session.select(1, preloadIndex: 2); + await _tick(); + expect(session.loading, isTrue); + gate.complete(); + await selection; + await session.preloadSettled; + expect(session.active, same(warm)); + expect(warm.initializeCalls, 1); + expect(factory.peakLive, lessThanOrEqualTo(2)); + expect(factory.peakPlaying, 1); + await session.close(); + }); + + test('rapid reverse and skipped selection discard stale warm work', () async { + final gate = Completer(); + final factory = _Factory(gates: {'ep-1': gate}); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + ); + await session.select(0, preloadIndex: 1); + await _tick(); + final stale = factory.players[1]; + final skip = session.select(3, preloadIndex: 4); + final reverse = session.select(0, preloadIndex: 2); + gate.complete(); + await Future.wait([skip, reverse]); + await session.preloadSettled; + expect(session.currentIndex, 0); + expect(session.preloadedIndex, 2); + expect(stale.playCalls, 0); + expect(stale.disposed, isTrue); + expect(factory.peakLive, lessThanOrEqualTo(2)); + expect(factory.peakPlaying, 1); + await session.close(); + }); + + test( + 'category adjacency and locked next never preload the wrong episode', + () async { + final factory = _Factory(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (e) => e.isFree, + ); + await session.select(0, preloadIndex: 2); + await session.preloadSettled; + expect(factory.players.map((p) => p.id), ['ep-0', 'ep-2']); + await session.select(0, preloadIndex: 4); + await session.preloadSettled; + expect(session.preloadedIndex, isNull); + expect(factory.players.any((p) => p.id == 'ep-4'), isFalse); + await session.close(); + }, + ); + + test('preload failure is isolated and selecting it retries once', () async { + final factory = _Factory(failOnce: {'ep-1'}); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + ); + await session.select(0, preloadIndex: 1); + await session.preloadSettled; + expect(session.error, isNull); + expect(session.loading, isFalse); + expect(factory.players.first.playing, isTrue); + await session.select(1); + expect(session.error, isNull); + expect(factory.players.where((p) => p.id == 'ep-1'), hasLength(2)); + await session.close(); + }); + + test('completion in background never starts either player', () async { + final gate = Completer(); + final factory = _Factory(gates: {'ep-1': gate}); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + ); + await session.select(0, preloadIndex: 1); + await _tick(); + final selection = session.select(1, preloadIndex: 2); + await _tick(); + session.setAppResumed(false); + gate.complete(); + await selection; + await session.preloadSettled; + expect(factory.players.every((p) => !p.playing), isTrue); + expect(factory.players.any((p) => p.id == 'ep-2'), isFalse); + session.setUserWantsPlay(false); + session.setAppResumed(true); + await session.playbackSettled; + expect(factory.players.every((p) => !p.playing), isTrue); + await session.close(); + }); + + test('closing during preloading disposes pending resources', () async { + final gate = Completer(); + final factory = _Factory(gates: {'ep-1': gate}); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + ); + await session.select(0, preloadIndex: 1); + await _tick(); + final closed = session.close(); + gate.complete(); + await closed; + expect(factory.live, 0); + expect(factory.players.last.playCalls, 0); + }); + + test( + 'home and series share a two-player budget across route handoff', + () async { + final factory = _Factory(); + final coordinator = PlaybackCoordinator(); + PlaybackSession createSession() => PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + coordinator: coordinator, + ); + final home = createSession(); + final series = createSession(); + await home.select(0, preloadIndex: 1); + await home.preloadSettled; + home.setRouteVisible(false); + await series.select(2, preloadIndex: 3); + await series.preloadSettled; + expect(home.livePlayerCount, 0); + expect(factory.peakLive, 2); + expect(factory.peakPlaying, 1); + await series.close(); + home.setRouteVisible(true); + await _tick(); + await home.playbackSettled; + await home.preloadSettled; + expect(home.active, isNotNull); + expect(factory.peakLive, 2); + await home.close(); + expect(factory.live, 0); + }, + ); +} + +Future _tick() => Future.delayed(Duration.zero); + +class _Factory { + _Factory({ + this.gates = const {}, + this.disposeGates = const {}, + Set? failOnce, + Set? disposeFailures, + }) : failures = failOnce ?? {}, + disposeFailures = disposeFailures ?? {}; + final Map> gates; + final Map> disposeGates; + final Set disposeFailures; + final Set failures; + final List<_Player> players = []; + int live = 0; + int peakLive = 0; + int peakPlaying = 0; + + PlayerPort create(Episode episode) { + live++; + if (live > peakLive) peakLive = live; + final player = _Player( + episode.id, + this, + gates[episode.id], + failures.remove(episode.id), + ); + players.add(player); + return player; + } +} + +class _Player implements PlayerPort { + _Player(this.id, this.factory, this.gate, this.fail); + final String id; + final _Factory factory; + final Completer? gate; + final bool fail; + bool playing = false; + bool disposed = false; + int initializeCalls = 0; + int playCalls = 0; + + @override + Future initialize() async { + initializeCalls++; + await gate?.future; + if (fail) throw StateError('Test initialization failed'); + } + + @override + Future play() async { + expect(disposed, isFalse); + playCalls++; + playing = true; + final count = factory.players.where((p) => p.playing).length; + if (count > factory.peakPlaying) factory.peakPlaying = count; + } + + @override + Future pause() async => playing = false; + + @override + Future dispose() async { + if (disposed) return; + await gate?.future; + await factory.disposeGates[id]?.future; + if (factory.disposeFailures.remove(id)) { + throw StateError('Test disposal failed'); + } + disposed = true; + playing = false; + factory.live--; + } +} diff --git a/test/playback/playback_queue_test.dart b/test/playback/playback_queue_test.dart new file mode 100644 index 0000000..461451d --- /dev/null +++ b/test/playback/playback_queue_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nontonki/core/data/repositories/catalog_repository.dart'; +import 'package:nontonki/core/data/services/playback_queue.dart'; + +void main() { + test( + 'home feed advances between dramas, series player between episodes', + () async { + final dramas = await DemoCatalogRepository().fetchDramas(); + final home = PlaybackQueue.home(dramas); + expect(home.length, 3); + expect(home.entryAt(0).drama.id, dramas[0].id); + expect(home.entryAt(1).drama.id, dramas[1].id); + expect(home.entryAt(1).episode.number, 1); + expect(home.nextIndex(0), 1); + expect(home.nextIndex(2), isNull); + + final series = PlaybackQueue.series(dramas[0]); + expect(series.length, 3); + expect(series.entryAt(0).drama.id, series.entryAt(1).drama.id); + expect(series.entryAt(1).episode.number, 2); + expect(series.nextIndex(0), 1); + expect(series.nextIndex(2), isNull); + }, + ); +} diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart new file mode 100644 index 0000000..4dcc547 --- /dev/null +++ b/test/playback/playback_session_test.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nontonki/core/data/models/drama.dart'; +import 'package:nontonki/core/data/services/playback_session.dart'; +import 'package:nontonki/core/data/models/player_port.dart'; + +void main() { + final episodes = List.generate( + 4, + (i) => Episode( + id: 'ep-${i + 1}', + number: i + 1, + title: 'Episode ${i + 1}', + assetPath: 'assets/media/episode_0${i + 1}.mp4', + isFree: i < 2, + ), + ); + + test('locked episode never creates or initializes a player', () async { + final factory = FakeFactory(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + ); + await session.select(2); + expect(session.isLocked, isTrue); + expect(factory.created, isEmpty); + await session.close(); + }); + + test('rapid selection drops stale initialize and keeps one player', () async { + final factory = FakeFactory(holdFirstInitialize: true); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + ); + final first = session.select(0); + await factory.firstCreated.future; + final second = session.select(1); + factory.created.first.releaseInitialize(); + await Future.wait([first, second]); + expect(factory.peakLive, lessThanOrEqualTo(1)); + expect(factory.created.first.playCalls, 0); + expect(factory.created.first.disposed, isTrue); + expect(session.currentIndex, 1); + expect(factory.created.last.playCalls, 1); + await session.close(); + expect(factory.live, 0); + }); + + test( + 'background and overlay pause without forgetting playback intent', + () async { + final factory = FakeFactory(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + ); + await session.select(0); + final player = factory.created.single; + expect(player.playCalls, 1); + session.setAppResumed(false); + await Future.delayed(Duration.zero); + expect(player.pauseCalls, 1); + session.setAppResumed(true); + await Future.delayed(Duration.zero); + expect(player.playCalls, 2); + session.setOverlayOpen(true); + await Future.delayed(Duration.zero); + expect(player.pauseCalls, 2); + session.setUserWantsPlay(false); + session.setOverlayOpen(false); + await Future.delayed(Duration.zero); + expect(player.playCalls, 2); + await session.close(); + }, + ); + + test('resume preparation finishes before video starts', () async { + final factory = FakeFactory(); + final prepared = Completer(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + prepare: (_, _) => prepared.future, + ); + final selection = session.select(0); + await factory.firstCreated.future; + await Future.delayed(Duration.zero); + expect(factory.created.single.playCalls, 0); + prepared.complete(); + await selection; + expect(factory.created.single.playCalls, 1); + await session.close(); + }); + + test('automatic advance respects visibility and playback intent', () async { + final factory = FakeFactory(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + ); + await session.select(0); + expect(session.canAutoAdvance, isTrue); + session.setRouteVisible(false); + expect(session.canAutoAdvance, isFalse); + session.setRouteVisible(true); + session.setOverlayOpen(true); + expect(session.canAutoAdvance, isFalse); + session.setOverlayOpen(false); + session.setUserWantsPlay(false); + expect(session.canAutoAdvance, isFalse); + await session.close(); + }); +} + +class FakeFactory { + FakeFactory({this.holdFirstInitialize = false}); + final bool holdFirstInitialize; + final List created = []; + final Completer firstCreated = Completer(); + int live = 0; + int peakLive = 0; + + PlayerPort create(Episode episode) { + live++; + if (live > peakLive) peakLive = live; + final player = FakePlayer( + holdInitialize: holdFirstInitialize && created.isEmpty, + onDispose: () => live--, + ); + created.add(player); + if (!firstCreated.isCompleted) firstCreated.complete(); + return player; + } +} + +class FakePlayer implements PlayerPort { + FakePlayer({required this.holdInitialize, required this.onDispose}); + final bool holdInitialize; + final void Function() onDispose; + final Completer _initializeGate = Completer(); + bool initialized = false; + bool disposed = false; + int playCalls = 0; + int pauseCalls = 0; + + void releaseInitialize() => _initializeGate.complete(); + + @override + Future initialize() async { + if (holdInitialize) await _initializeGate.future; + initialized = true; + } + + @override + Future play() async => playCalls++; + + @override + Future pause() async => pauseCalls++; + + @override + Future dispose() async { + if (disposed) return; + disposed = true; + onDispose(); + } +} diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart new file mode 100644 index 0000000..f13a976 --- /dev/null +++ b/test/playback/seek_progress_bar_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nontonki/pages/player/widgets/seek_progress_bar.dart'; +import 'package:video_player/video_player.dart'; + +void main() { + test('seek target stays inside playable duration', () { + const duration = Duration(seconds: 12); + expect(seekPositionForFraction(0, duration), Duration.zero); + expect( + seekPositionForFraction(0.5, duration), + greaterThan(const Duration(seconds: 5)), + ); + expect(seekPositionForFraction(1, duration), lessThan(duration)); + }); + + testWidgets('horizontal slider drag previews and seeks on release', ( + tester, + ) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 3), + isInitialized: true, + ), + ); + addTearDown(video.dispose); + var started = 0; + Duration? target; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + onScrubStart: () => started++, + onSeek: (position) async { + target = position; + }, + ), + ), + ), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byType(Slider)), + ); + await gesture.moveBy(const Offset(120, 0)); + await tester.pump(); + expect(started, 1); + expect(target, isNull); + await gesture.up(); + await tester.pumpAndSettle(); + expect(target, isNotNull); + expect(target!, greaterThan(const Duration(seconds: 3))); + expect(target!, lessThan(const Duration(seconds: 12))); + }); + + testWidgets('compact seek bar shows time only while dragging', ( + tester, + ) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 3), + isInitialized: true, + ), + ); + addTearDown(video.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + compactIdle: true, + onSeek: (_) async {}, + ), + ), + ), + ); + expect(find.text('00:03'), findsNothing); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(Slider)), + ); + await gesture.moveBy(const Offset(40, 0)); + await tester.pump(); + expect(find.text('00:12'), findsOneWidget); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.text('00:12'), findsNothing); + }); +} diff --git a/test/playback/watch_history_test.dart b/test/playback/watch_history_test.dart new file mode 100644 index 0000000..fbd5ffa --- /dev/null +++ b/test/playback/watch_history_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nontonki/core/data/repositories/watch_history_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('resume position clamps invalid and completed values', () { + const duration = Duration(seconds: 8); + expect( + clampResumePosition(const Duration(seconds: -1), duration), + Duration.zero, + ); + expect( + clampResumePosition(const Duration(seconds: 3), duration), + const Duration(seconds: 3), + ); + expect( + clampResumePosition(const Duration(seconds: 9), duration), + Duration.zero, + ); + expect( + clampResumePosition(const Duration(milliseconds: 7700), duration), + Duration.zero, + ); + }); + + test('watch history is scoped by profile, drama and episode', () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final history = SharedPreferencesWatchHistoryRepository(); + await history.save( + 'profile-a', + 'drama-a', + 'episode-1', + const Duration(seconds: 3), + ); + await history.save( + 'profile-a', + 'drama-a', + 'episode-2', + const Duration(seconds: 5), + ); + expect( + await history.positionFor('profile-a', 'drama-a', 'episode-1'), + const Duration(seconds: 3), + ); + expect( + await history.positionFor('profile-b', 'drama-a', 'episode-1'), + isNull, + ); + expect(await history.lastEpisodeId('profile-a', 'drama-a'), 'episode-2'); + expect(await history.lastEpisodeId('profile-a', 'drama-b'), isNull); + }); +} diff --git a/test/support/widget_player.dart b/test/support/widget_player.dart new file mode 100644 index 0000000..6e2ad1d --- /dev/null +++ b/test/support/widget_player.dart @@ -0,0 +1,19 @@ +import 'package:nontonki/core/data/models/drama.dart'; +import 'package:nontonki/core/data/models/player_port.dart'; + +// Widget tests exercise navigation and scheduling without a native decoder. +PlayerPort createWidgetPlayer(Episode episode) => WidgetPlayer(); + +class WidgetPlayer implements PlayerPort { + @override + Future initialize() async {} + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future dispose() async {} +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..94c734c --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,540 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nontonki/app.dart'; +import 'package:nontonki/pages/player/player_view.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 'support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets( + 'home swipe promotes next without recreating it or showing loading', + (tester) async { + final created = []; + await tester.pumpWidget( + LumaDramaApp( + playerFactory: (episode) { + created.add(episode.id); + return WidgetPlayer(); + }, + ), + ); + await tester.pumpAndSettle(); + expect(created, hasLength(2)); + expect(find.byType(CircularProgressIndicator), findsNothing); + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + await tester.pumpAndSettle(); + expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(created, hasLength(3)); + expect(created.toSet(), hasLength(3)); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('immersive home opens the series detail', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + expect(find.text('The Signal'), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); + expect(find.text('Home'), findsWidgets); + expect(find.text('Recommended'), findsOneWidget); + expect(find.textContaining('Playing now'), findsOneWidget); + expect(find.byTooltip('Episodes'), findsWidgets); + expect(find.byTooltip('Watch in landscape'), findsOneWidget); + expect(find.byTooltip('Picture in picture'), findsOneWidget); + + await tester.tap(find.byTooltip('Explore series')); + await tester.pumpAndSettle(); + expect(find.text('Watch episode 1'), findsOneWidget); + expect(find.text('Episodes'), findsOneWidget); + }); + + testWidgets('landscape control hides tabs and restores them on exit', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Watch in landscape')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-tabs-bar')), findsNothing); + expect(find.byTooltip('Exit fullscreen'), findsOneWidget); + await tester.tap(find.byTooltip('Exit fullscreen')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('home quick drawer opens current series and switches root tabs', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Open quick menu')); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(PlayerPage)).active, isFalse); + expect(find.text('Quick access'), findsOneWidget); + expect( + find.descendant( + of: find.byType(Drawer), + matching: find.text('Same demo clip · free'), + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + expect(find.text('Watch episode 1'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(PlayerPage)).active, isTrue); + + await tester.tap(find.byTooltip('Open quick menu')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-discover'))); + await tester.pumpAndSettle(); + expect(find.text('Trending Micro-Dramas'), findsOneWidget); + expect( + Navigator.of(tester.element(find.text('Trending Micro-Dramas'))).canPop(), + isFalse, + ); + }); + + testWidgets('interface language changes without a caption control', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Captions'), findsNothing); + await tester.tap(find.byTooltip('My')); + await tester.pumpAndSettle(); + expect(find.text('Continue Watching'), findsOneWidget); + expect(find.text('History'), findsOneWidget); + expect(find.text('Watchlist'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('App Language'), + 120, + scrollable: find + .descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), + ) + .first, + ); + expect(find.text('PREFERENCES'), findsOneWidget); + await tester.tap(find.text('App Language')); + await tester.pumpAndSettle(); + await tester.tap(find.text('简体中文').last); + await tester.pumpAndSettle(); + expect(find.text('我的'), findsWidgets); + expect(find.text('继续观看'), findsOneWidget); + expect(find.text('找剧'), findsOneWidget); + await tester.tap(find.text('找剧')); + await tester.pumpAndSettle(); + expect(find.text('热门短剧'), findsOneWidget); + expect(find.text('排行榜'), findsOneWidget); + }); + + testWidgets('home controls fit a compact Android viewport', (tester) async { + tester.view.physicalSize = const Size(360, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + expect(find.text('Recommended'), findsOneWidget); + await tester.drag(find.byType(PageView), const Offset(0, -500)); + await tester.pumpAndSettle(); + expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + await tester.tap(find.byTooltip('Explore series')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Watch episode 1')); + await tester.pumpAndSettle(); + expect(find.byType(Slider), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('My page fits a compact Android viewport and returns home', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('My')); + await tester.pumpAndSettle(); + expect(find.text('Continue Watching'), findsOneWidget); + expect(find.text('History'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Home').last); + await tester.pumpAndSettle(); + expect(find.text('Recommended'), findsOneWidget); + }); + + testWidgets( + 'Discover shows three mock playable dramas and one pending poster', + (tester) async { + tester.view.physicalSize = const Size(360, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discover')); + await tester.pumpAndSettle(); + + expect(find.text('Trending Micro-Dramas'), findsOneWidget); + expect(find.text('Filter'), findsOneWidget); + expect(find.text('Top Charts'), findsOneWidget); + expect(find.text('New Releases'), findsOneWidget); + expect(find.text('Watchlist'), findsOneWidget); + expect(find.text('Same demo clip · free'), findsNWidgets(3)); + expect(find.text('Video pending'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.enterText(find.byType(TextField), 'Vengeance'); + await tester.pumpAndSettle(); + expect(find.text('Vengeance Rising'), findsOneWidget); + expect(find.text('Same demo clip · free'), findsOneWidget); + + await tester.tap(find.text('Watchlist')); + await tester.pumpAndSettle(); + expect(find.text('No shows here yet.'), findsOneWidget); + await tester.tap(find.text('Home').last); + await tester.pumpAndSettle(); + expect(find.text('Recommended'), findsOneWidget); + }, + ); + + testWidgets('bottom tabs stay on one route and preserve Discover search', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discover')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Vengeance'); + await tester.pumpAndSettle(); + expect(find.text('Vengeance Rising'), findsOneWidget); + expect( + Navigator.of(tester.element(find.byType(TextField))).canPop(), + isFalse, + ); + + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + expect(find.text('History'), findsOneWidget); + expect( + Navigator.of(tester.element(find.text('History'))).canPop(), + isFalse, + ); + await tester.tap(find.text('Watchlist')); + await tester.pumpAndSettle(); + expect(find.text('No shows here yet.'), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + expect(find.widgetWithText(TextField, 'Vengeance'), findsOneWidget); + expect(find.text('Vengeance Rising'), findsOneWidget); + expect( + Navigator.of(tester.element(find.byType(TextField))).canPop(), + isFalse, + ); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + expect(find.text('No shows here yet.'), findsOneWidget); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + expect(find.text('Recommended'), findsOneWidget); + expect( + Navigator.of(tester.element(find.text('Recommended'))).canPop(), + isFalse, + ); + }); + + testWidgets( + 'home swipe changes drama, entered player swipe changes episode', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + expect(find.text('The Signal'), findsOneWidget); + + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.textContaining('Episode 1'), findsOneWidget); + + await tester.tap(find.byTooltip('Explore series')); + await tester.pumpAndSettle(); + expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + await tester.tap(find.text('Watch episode 1')); + await tester.pumpAndSettle(); + expect(find.byType(Slider), findsOneWidget); + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + expect(find.textContaining('EPISODE 2 / 3'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); + }, + ); + + testWidgets('home categories switch by video, top row and tap', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.tune_rounded), findsNothing); + expect(find.text('The Signal'), findsOneWidget); + + await tester.drag( + find.byKey(const Key('home-video-swipe')), + const Offset(220, 0), + ); + await tester.pumpAndSettle(); + expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.byKey(const Key('home-category-liveAction')), findsOneWidget); + + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + expect(find.text('Vengeance Rising'), findsOneWidget); + + await tester.drag( + find.byKey(const Key('home-category-swipe')), + const Offset(190, 0), + ); + await tester.pumpAndSettle(); + expect(find.text('The Signal'), findsOneWidget); + + await tester.drag( + find.byKey(const Key('home-category-swipe')), + const Offset(-190, 0), + ); + await tester.pumpAndSettle(); + expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-category-following'))); + await tester.pumpAndSettle(); + expect(find.text('No dramas in this category yet.'), findsOneWidget); + await tester.tap(find.byKey(const Key('home-category-recommended'))); + await tester.pumpAndSettle(); + expect(find.text('The Signal'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('right rail saves, comments, likes and invokes share in order', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + String? sharedText; + const channel = MethodChannel('com.stormg.lumadrama/share'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + sharedText = (call.arguments as Map)['text'] as String; + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + final keys = [ + const Key('rail-save'), + const Key('rail-comments'), + const Key('rail-like'), + const Key('rail-share'), + ]; + final tops = keys + .map((key) => tester.getTopLeft(find.byKey(key)).dy) + .toList(); + expect(tops, orderedEquals(tops.toList()..sort())); + + await tester.tap(find.byKey(keys[0])); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(keys[1])); + await tester.pumpAndSettle(); + expect(find.text('Only visible on this device'), findsOneWidget); + await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo'); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); + expect(find.text('Nice demo'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(keys[2])); + await tester.tap(find.byKey(keys[3])); + await tester.pumpAndSettle(); + expect(sharedText, contains('The Signal')); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'right swipe from leftmost category opens drawer and root stays', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + + for (var i = 0; i < 3; i++) { + await tester.drag( + find.byKey(const Key('home-category-swipe')), + const Offset(200, 0), + ); + await tester.pumpAndSettle(); + } + expect(find.text('No dramas in this category yet.'), findsOneWidget); + await tester.drag( + find.byKey(const Key('home-empty-swipe')), + const Offset(200, 0), + ); + await tester.pumpAndSettle(); + expect(find.text('Quick access'), findsOneWidget); + await tester.drag(find.byType(Drawer), const Offset(150, 0)); + await tester.pumpAndSettle(); + expect(find.text('Quick access'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + }, + ); + + testWidgets('system back steps categories then opens drawer without exit', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('The Signal'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('No dramas in this category yet.'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Quick access'), findsOneWidget); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + }); + + testWidgets('My watchlist lists saved dramas across the home feed', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + const LumaDramaApp(playerFactory: createWidgetPlayer), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('rail-save'))); + await tester.pumpAndSettle(); + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('rail-save'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Watchlist')); + await tester.pumpAndSettle(); + expect(find.text('The Signal'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); + await tester.tap(find.text('The Signal')); + await tester.pumpAndSettle(); + expect(find.text('Watch episode 1'), findsOneWidget); + }); +}