From b1f6ce4bf96a2fb11642e6aa78826c3c09681e0e Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Tue, 13 Oct 2026 16:23:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=20Nontonki=20=E7=9F=AD?= =?UTF-8?q?=E5=89=A7=E5=8E=9F=E5=9E=8B=EF=BC=8C=E5=87=86=E5=A4=87=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E5=88=B0=E8=BF=9C=E7=A8=8B=E4=BB=93=E5=BA=93=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .gitignore | 61 + .metadata | 33 + README.md | 17 + analysis_options.yaml | 33 + android/.gitignore | 11 + android/app/build.gradle.kts | 45 + android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 47 + .../com/stormg/lumadrama/MainActivity.kt | 78 + .../res/drawable-hdpi/android12splash.png | Bin 0 -> 3647 bytes .../drawable-hdpi/ic_launcher_foreground.png | Bin 0 -> 3171 bytes .../main/res/drawable-hdpi/splash_icon.png | Bin 0 -> 2585 bytes .../res/drawable-mdpi/android12splash.png | Bin 0 -> 2401 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 0 -> 1745 bytes .../main/res/drawable-mdpi/splash_icon.png | Bin 0 -> 1848 bytes .../res/drawable-v21/launch_background.xml | 9 + .../res/drawable-xhdpi/android12splash.png | Bin 0 -> 5404 bytes .../drawable-xhdpi/ic_launcher_foreground.png | Bin 0 -> 3643 bytes .../main/res/drawable-xhdpi/splash_icon.png | Bin 0 -> 3508 bytes .../res/drawable-xxhdpi/android12splash.png | Bin 0 -> 9271 bytes .../ic_launcher_foreground.png | Bin 0 -> 5367 bytes .../main/res/drawable-xxhdpi/splash_icon.png | Bin 0 -> 5361 bytes .../res/drawable-xxxhdpi/android12splash.png | Bin 0 -> 15231 bytes .../ic_launcher_foreground.png | Bin 0 -> 7760 bytes .../main/res/drawable-xxxhdpi/splash_icon.png | Bin 0 -> 8185 bytes .../main/res/drawable/launch_background.xml | 9 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 9 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1321 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 813 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 1535 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 2575 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 2584 bytes .../src/main/res/values-night-v31/styles.xml | 9 + .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values-v31/styles.xml | 9 + android/app/src/main/res/values/colors.xml | 5 + android/app/src/main/res/values/styles.xml | 18 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle.kts | 24 + android/gradle.properties | 6 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + android/gradlew | 160 ++ android/gradlew.bat | 90 + android/settings.gradle.kts | 26 + ios/.gitignore | 34 + ios/Flutter/AppFrameworkInfo.plist | 24 + ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Runner.xcodeproj/project.pbxproj | 647 +++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 119 ++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + ios/Runner/AppDelegate.swift | 34 + .../AppIcon.appiconset/Contents.json | 1 + .../Icon-App-1024x1024@1x.png | Bin 0 -> 14523 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 340 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 644 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 1001 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 499 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 958 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 1475 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 644 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 1299 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 2027 bytes .../AppIcon.appiconset/Icon-App-50x50@1x.png | Bin 0 -> 822 bytes .../AppIcon.appiconset/Icon-App-50x50@2x.png | Bin 0 -> 1649 bytes .../AppIcon.appiconset/Icon-App-57x57@1x.png | Bin 0 -> 889 bytes .../AppIcon.appiconset/Icon-App-57x57@2x.png | Bin 0 -> 1548 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 2027 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 2954 bytes .../AppIcon.appiconset/Icon-App-72x72@1x.png | Bin 0 -> 1321 bytes .../AppIcon.appiconset/Icon-App-72x72@2x.png | Bin 0 -> 2575 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 1312 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 2930 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 3034 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 2384 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 5085 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 9132 bytes .../LaunchImage.imageset/README.md | 5 + ios/Runner/Base.lproj/LaunchScreen.storyboard | 37 + ios/Runner/Base.lproj/Main.storyboard | 26 + ios/Runner/Info.plist | 69 + ios/Runner/Runner-Bridging-Header.h | 1 + ios/Runner/SceneDelegate.swift | 6 + ios/RunnerTests/RunnerTests.swift | 12 + l10n.yaml | 3 + lib/app.dart | 71 + lib/core/config/locale_controller.dart | 39 + lib/core/data/models/drama.dart | 35 + lib/core/data/models/engagement_snapshot.dart | 11 + lib/core/data/models/player_port.dart | 6 + .../data/repositories/catalog_repository.dart | 104 ++ .../repositories/engagement_repository.dart | 68 + .../watch_history_repository.dart | 69 + .../services/android_picture_in_picture.dart | 30 + .../data/services/catalog_controller.dart | 26 + .../data/services/engagement_controller.dart | 78 + .../data/services/platform_share_service.dart | 15 + lib/core/data/services/playback_queue.dart | 28 + lib/core/data/services/playback_session.dart | 360 ++++ lib/core/data/services/video_player_port.dart | 48 + lib/core/formatting/localized_content.dart | 27 + lib/l10n/app_en.arb | 134 ++ lib/l10n/app_localizations.dart | 878 ++++++++++ lib/l10n/app_localizations_en.dart | 404 +++++ lib/l10n/app_localizations_zh.dart | 396 +++++ lib/l10n/app_zh.arb | 129 ++ lib/main.dart | 9 + lib/pages/discover/discover_binding.dart | 13 + lib/pages/discover/discover_logic.dart | 32 + lib/pages/discover/discover_view.dart | 731 ++++++++ .../drama_detail/drama_detail_binding.dart | 13 + .../drama_detail/drama_detail_logic.dart | 19 + lib/pages/drama_detail/drama_detail_view.dart | 137 ++ lib/pages/home/home_binding.dart | 6 + lib/pages/home/home_feed_sections.dart | 30 + lib/pages/home/home_logic.dart | 36 + lib/pages/home/home_view.dart | 325 ++++ .../home/widgets/category_swipe_region.dart | 35 + lib/pages/home/widgets/home_feed_header.dart | 170 ++ lib/pages/home/widgets/home_quick_drawer.dart | 291 ++++ lib/pages/mine/mine_binding.dart | 15 + lib/pages/mine/mine_logic.dart | 26 + lib/pages/mine/mine_view.dart | 682 ++++++++ lib/pages/player/player_binding.dart | 10 + lib/pages/player/player_logic.dart | 38 + lib/pages/player/player_view.dart | 1501 +++++++++++++++++ .../player/widgets/drama_comments_sheet.dart | 125 ++ .../player/widgets/seek_progress_bar.dart | 147 ++ pubspec.lock | 503 ++++++ pubspec.yaml | 112 ++ test/catalog/catalog_controller_test.dart | 39 + test/catalog/demo_media_test.dart | 26 + test/catalog/home_feed_category_test.dart | 22 + .../engagement_controller_test.dart | 46 + test/playback/playback_preload_test.dart | 344 ++++ test/playback/playback_queue_test.dart | 26 + test/playback/playback_session_test.dart | 174 ++ test/playback/seek_progress_bar_test.dart | 98 ++ test/playback/watch_history_test.dart | 54 + test/support/widget_player.dart | 19 + test/widget_test.dart | 540 ++++++ 148 files changed, 11175 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-hdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-hdpi/splash_icon.png create mode 100644 android/app/src/main/res/drawable-mdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-mdpi/splash_icon.png create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable-xhdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xhdpi/splash_icon.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/splash_icon.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/splash_icon.png create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-night-v31/styles.xml create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values-v31/styles.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 ios/.gitignore create mode 100644 ios/Flutter/AppFrameworkInfo.plist create mode 100644 ios/Flutter/Debug.xcconfig create mode 100644 ios/Flutter/Release.xcconfig create mode 100644 ios/Runner.xcodeproj/project.pbxproj create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner/AppDelegate.swift create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 ios/Runner/Base.lproj/Main.storyboard create mode 100644 ios/Runner/Info.plist create mode 100644 ios/Runner/Runner-Bridging-Header.h create mode 100644 ios/Runner/SceneDelegate.swift create mode 100644 ios/RunnerTests/RunnerTests.swift create mode 100644 l10n.yaml create mode 100644 lib/app.dart create mode 100644 lib/core/config/locale_controller.dart create mode 100644 lib/core/data/models/drama.dart create mode 100644 lib/core/data/models/engagement_snapshot.dart create mode 100644 lib/core/data/models/player_port.dart create mode 100644 lib/core/data/repositories/catalog_repository.dart create mode 100644 lib/core/data/repositories/engagement_repository.dart create mode 100644 lib/core/data/repositories/watch_history_repository.dart create mode 100644 lib/core/data/services/android_picture_in_picture.dart create mode 100644 lib/core/data/services/catalog_controller.dart create mode 100644 lib/core/data/services/engagement_controller.dart create mode 100644 lib/core/data/services/platform_share_service.dart create mode 100644 lib/core/data/services/playback_queue.dart create mode 100644 lib/core/data/services/playback_session.dart create mode 100644 lib/core/data/services/video_player_port.dart create mode 100644 lib/core/formatting/localized_content.dart create mode 100644 lib/l10n/app_en.arb create mode 100644 lib/l10n/app_localizations.dart create mode 100644 lib/l10n/app_localizations_en.dart create mode 100644 lib/l10n/app_localizations_zh.dart create mode 100644 lib/l10n/app_zh.arb create mode 100644 lib/main.dart create mode 100644 lib/pages/discover/discover_binding.dart create mode 100644 lib/pages/discover/discover_logic.dart create mode 100644 lib/pages/discover/discover_view.dart create mode 100644 lib/pages/drama_detail/drama_detail_binding.dart create mode 100644 lib/pages/drama_detail/drama_detail_logic.dart create mode 100644 lib/pages/drama_detail/drama_detail_view.dart create mode 100644 lib/pages/home/home_binding.dart create mode 100644 lib/pages/home/home_feed_sections.dart create mode 100644 lib/pages/home/home_logic.dart create mode 100644 lib/pages/home/home_view.dart create mode 100644 lib/pages/home/widgets/category_swipe_region.dart create mode 100644 lib/pages/home/widgets/home_feed_header.dart create mode 100644 lib/pages/home/widgets/home_quick_drawer.dart create mode 100644 lib/pages/mine/mine_binding.dart create mode 100644 lib/pages/mine/mine_logic.dart create mode 100644 lib/pages/mine/mine_view.dart create mode 100644 lib/pages/player/player_binding.dart create mode 100644 lib/pages/player/player_logic.dart create mode 100644 lib/pages/player/player_view.dart create mode 100644 lib/pages/player/widgets/drama_comments_sheet.dart create mode 100644 lib/pages/player/widgets/seek_progress_bar.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/catalog/catalog_controller_test.dart create mode 100644 test/catalog/demo_media_test.dart create mode 100644 test/catalog/home_feed_category_test.dart create mode 100644 test/engagement/engagement_controller_test.dart create mode 100644 test/playback/playback_preload_test.dart create mode 100644 test/playback/playback_queue_test.dart create mode 100644 test/playback/playback_session_test.dart create mode 100644 test/playback/seek_progress_bar_test.dart create mode 100644 test/playback/watch_history_test.dart create mode 100644 test/support/widget_player.dart create mode 100644 test/widget_test.dart 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 0000000000000000000000000000000000000000..2f9eca74f4347c1c67ee9d33ba1b7c12aaa503b6 GIT binary patch literal 3647 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 DzD^)D literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b55cc70651021df9a88ad9837a2ba0a96b424a6 GIT binary patch literal 2585 zcmV+!3g-2RP)i^LZ!0R$jZp*kF=tM7HpwtH3*6*gCM8~qHHgcLTEK; zp>k}|Ok1awQZp;lLd&h$*LUOhe)r6~&ib6YytDki@R)ha+_~?3&hMP_`<~yu-uQVw z5>%oGcR*fJ2hPTPJOQ0-dwRNo7vSm0kE;Vj1uwu2?EpgsFTf4&0GSB?Or($eq`?bx zedQ-H)C+LShW=tAUZ9&NKPq^EZm#^e7k45HG!S@+cJt-$DtLizkNjf63v~O`i>b`< zj;bekiFSLn6I+RRM->Jy(Qd!G8B4@FDg|Dm-5upwOT;@WZNYn9f|Z6MJ;4+3whkVX z%CtN~^OezYe(&;u!>T#zcQgYTj~}Ag22z^hjQ3oi$bI~dsB3^aED*$j^cBFn&&2yD&=I=X=AM*?qM2RwZVP*V+T zZ35amr2|+Ao}}jXt{jg77fc4;zX5n?7O-Cxu%!v;>Vha{=7xM+6?gY~XP50PF>?MDV?^2QVq{0A`8@kZ&JYu3jF%N$dgKxa2jIxpWQhmhDEPrVfOj170GtHhp9gTXLmq%Nd3+KcK<#Q^zNH?3(-A+I z2XJ=C1F)?QK)@I20la5f9e{v$v<|?)Q`W2wpv+7Uz@Ssr)C0J&PzSKxpgI5pPSKn? zfLR8400Q38IsnPzQ>X(d&jO<~^8l=jc#1%JP$T={=fV3qALz^#VD)?+cO8I^9)MNg z6=)h>>7lN;?S39~f{e!i)DRqgPEnai9e`#YK-MPcDv~~21HAha@Y@y!ux}M``eDHN z6M>3hz_0%Ti@pQC+XytY0+l0xNe2QKP68&3;i!!|FZbmE%$f)+_#AltGhpKmP7Ns? zq|qjAdE@8lGV2r?Y+DoX#KpkH$8y6BmL4hS|26`*E&>++3{YDR3!e6b+|NbxF%k{wM;rtmjr{~va8SWr=yvY}4QU{0&e%J%Z zg*<@ErT~wB0=)h$(9jH2RunE7OIpUy@e0m;?*m`0=SHQWlNyiSQ|T7HM#KCbyYc5c z!|&7Lu8TWJbtUk?5@6-;@k1!44xqjTC`-WuFd95TqlWxr8_(H$jpUW{K%Wn0fe%y%a7GPKPaZ&fvL1l(;7Kdi@lYSfW=Sp>8ED~07A!Se*OY^wt>8azcM!9L{>;J`hA zmi8Eo7mshgbXE<|^~!eVYCt2QyPIEH1PR?B6;<_6&J z9lTo?-uX*I^j$ImaI%(PsblCrZvsXQ2gZyn*w9XbS85x;rj4>_Y>)Z4gGr56lv$A8 z{*k8~+IXTVC=Wa+8i^_^!0%rb-y+NdH!SUxRgW&bmZb8IT?qKnwOqEfd>yY_Xb(bF z3B@I&!K;<`$rE^9j$NU5G&|V`|vZRPq$;igh0+@W?kc0C%1ZOg&`h zXpFlOhk$R`&Wko$y#3e8?%asLElgU*gU?5)&-38RR{}S`27L4jzefg#nzIS`lySh6 zgMgRnxJ-+DaJ4H&rCC&&bMJZVh{Zj`s}eGmi$A*(c=2=29%wy51tO{%I1L#OK75Pw zeJc6>_Ec#39u-?|4T>!*x!98Keog7nrBk@viAu5RRk7s(z(tdRnz04{30(jvIGjs7Mat6GOxp)+1UCYH^tz(PT zhFm;~tOak%zIdsWwLKSKwib9~De&n!zOd4!Ts(@hR_}{bF20&yud*-~cN)CQxwwHfD8Fwp@X;FX2sZAEmrB}!C#ZDQD&5DOZfLZvnSo0#9=o=i0qJ!6No);yiJZp(~M->JyQE$Jx z8B4@Fs-EB_>h0A|Y$f6yRbSvG>g`i6ru{{{z_&+zvK(lr7x?C@UxqTrJF3CJOVpdI z0ofH3@dDpGd2#K|sRPNLncgZoNOyIPm+045yToQFi#vg@pS-jRF=Xi1Ltd#WaHIp2 vMxHLESJDB}k*90TUC$GfEC8{#XKeZ(NXY9Zjyu*b00000NkvXXu0mjfI{4Kb literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d55419e19b939815b085dccd9ff5bc8f18953716 GIT binary patch literal 2401 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 BH}(Jk literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..689d9984e01f5805c4a48694d431746bb75edb90 GIT binary patch literal 1745 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 mtC8 + + + + + + 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 0000000000000000000000000000000000000000..49cf10dfd5c0e76c4e839418ece26987fe83212c GIT binary patch literal 5404 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 N)@SX{l$jG_{|{{LjdTD2 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7d8eb3776f13f58b831db386173b0f03709cd7e1 GIT binary patch literal 3643 zcmc&%`#TeS*e6C#WsHq7TEm={(FsFa8Vfp1!?Kj12AZ{ zlaVIWp~~?^vOdlFMTx^yUFLa^!8d6fP)-gfR1QCfRdEdw5f&6QSV((%ZX3?^6VyGNAKSn6wE=)#;fGa*gv#>u0OcF=HYz6Uf z%|a>;07@<2H_H8=84t!&MarY)*#X;pled1-;y@`iG|gtQh-V-0d4Bs#iXKn|D41~( zJF(dOOYg@p)5zfLW8St@s-27sFfY9BIv|WkHG2B>r{wJpY4nuNxdd~ZD49?u67_}l zpr-q-He(o%ndU{qR8<0ChLHFS5;ycnew#?U9?rZtH;Bgdlw6s>aZKk{7b(Z2IDg=s z*f5!EK&9WEWjCB{n#MhlX0>5e{Lj22Fm>22%n+%juv?_jQtCD4+GZ?uBr+s?3=hX? zm)_P}4UDUKuX|07HBI zpv)=mE{nIJ@Gtd(&6HwPi~oynbBOt(voF4Sp(l76c2skXpI>ZQy6-O{HQeQY=G}q* z42B1Rc^vfo>gtVSn7(PLX~$@BJo*qh2(QG5hR9}Hj0BIL9x=OFNL5$47PP0@d>T-) zbj4Giy*j(XH3Y4f!qvYfa|W8CD2Jj->l+5$5OWnlc6x^!y2C5D`p(_6jE!btyf%~^ zgbDxbX!_Z`7+tdXvO#;Dz)YLt?tGBqvK8C)*A`%Aw`9cd+Dg5%-Swp()<>HtC@FoX zP~~(8!pm7#mv=9cY{7g|Jb zwt&cB%k04cov}J?0+X2n>IuxA&w9S&dB}b5$usX~_hD*jM61acM$cy7S3vf3HWU^# zP#{znB%U>nWwA|FJ&-5H%u5>XgIug&q+`9G7M2n-$x4SN6+p(9tzk- zsVBl(l2N_{*&bfk#Klp4C9URa6Ch~xI0Lg}A7<6%bWwGn{&C`~zVkq1RSmVwWNXP> zjT~|j)nvBxs$rQI)2ht?5WTUVT>t^cK@=q1s16Ff{Nr7QE`?cw294SOz%~~pcJT}zZ%!)*l1Dm#lc#XKA!3Gi_~f1NQfgEZ3obBI6b!f11KAo`TRuNe zzJ1ooYu1#v&A&e#2NQ|^)+5?K{YiMQzO+I6wk?zffiN&X2X^?4cBIo`A||=hO&7&(N>(i#&SpC#K|=B-jngnr9d6|nrt#jFKhlyQSs!dTDr@L z?D`tbtj&KPc}fmd!lqXGCa+kWzP}(`GF1SBc9AYDn;0)L7x=xNkEcJ%zn zy2rrt;Ow{6Qbxg?gOVK*|J<3Mdiw9Qe(8tNWuLw46*DoCc#woTk|nELQphx3Q;p39 z$Ul16TWGZB3|7j8?A$dBTevuA5 zk#_Swl;b%>ax*k@!}+fd;3m?WL^qysqsI1rn?{pI=CHS?FS%K&t-xwNCo{a=(TtoI zbysDP2E#ukG84Kp1`||62H9KfcQ^zlzCqsaHB?8e*Wz-_^*|l1l+cTAcB9YZj%Kc{ zS{0O_Q{{ng8Zz_UUqqYpkWMm;uAIzoHPCiN-r$>*<&JQ^xf^rA^j2iGoC`d#7y>DZWDVPfO(bv$~tcuh90PB^!Zr___40Wry4p6om_U?hWIx82T5t+$* zdZ>BPiEu^(^=<&UtY(c%0#C4a;wa*|mVIm~xavo8TC2X&ah+n6uk)}G(1pFp#U`;X z^f&ma0F?jaB@{%ohGbgM4_cu7hQl7IRdYVU1vpgo;~Hv9is3E9=UGyD`8%%mWB97e zLa$ua0Mr|yZkmdDfB);?%1YWme}-;1yjjbE7-*%2&)R}VA?T?Ki+imt-WAS(VGr=^ z!WuC*C)sB2P^_Phx^%~xi&%yn_Q(_7l~=kU8qS@~O}nEAuLFNM znnX=QD?1_{*!!D)M8WA^)0V`tSZt4>EjTzb|3@C>&DznN$8PnN;E9mK{;BmOtik4; zT~(zNm15)^Qx*E`*XG7L%S*vUmQclFw^O|Dom_=GVjk>+TwOMP=CjuNQdU>Akqcdk z5AW0qX#$;Z4*Rp8-jpoDFmd*4qs+a=jh3K+o-H*m^AK^Ej@Pu;Jd%fe64CYVvE@r*+ z>m~VBu4E_u%0J_PC*g#~OpnmdM1{R>IRz)bko8}QU_3WLJ!t?u!u0`{K^)~lrCz)3B>7lIR?cWjxCOzf=C74cem0kE~ za~4>~KK#0cLxD_HChXA_0~y8X^s>1E^D#VwRf=4k0j|2UNPd=@+22+jEGg>I8>QE; zHNOlFw*>=JCg;s3EyX$QiAB-oVV1#v$GT&4h5K9eRZliw=2T!nw!6_S@#s?9P#Iov zBV|Vu_=Pi3&f1kzL7qn4eBl_PvD(pPsPm4GYk^an)*YHfc-m z^|-mjyrYQW)^NAXm-90kb3jIF+Weu1YrPYu%H>YiOv>&F{E7n$>Ff~{2JYxDZyPEafE#%~622ZG@PzaBfQ;kjOFby&9 zI`+}~2LHE5UPz(x{@kz1JfABnyu0JNzZ7V2ivv=4NzGubn_rzeH)EWjHpNCOo zhqH2Fh194z$I0ybSKo1~+jd7Zx#xhVysGW(+cJ|5F9|OXE_{rGRnB_~&b&`8x`a6_ zl_gFqr&5l3EEY$$SRJM-f@}eRU<0eyv1%dVPEswR_#ZjVnQATW%OOYy>KPUb3ykR@ z?f!KsdH6j^!4IH>C_YT2|1=u)(d$K~$f4%`i^!(Vkt8AFJ-_!^FC__t1sZpg0%jhb zDVt5d#oG44OVhZ3^|NL}U9lbP@sBm)G%gYl{7d?KL861pFCB^|qS)nZb(pbQzo*g- z8a)xF235!CsmO8ypWjp6f64f(8f_0k3VftQfV9Vus?7NpBHr()IV0HtuLbdFx2nuI zX`!;OC9fIpGL&e1LnOuDlfl zR0ld+FB0u-G0w2n<*8W_m@>9-3|2_?5MEov|Jl_>K2iJNP*s--7HC6jB))bq13hwq ztGd{aI3VW4g04wz_wn&k<;NxnK+ zfmy=+39cYrWJyvvmCI=To`>{EI7vA66+BAHY2}=d?YV~YW_wDDy{0}8|0YT8xLg#=!Epmtp!25C&o=iI1cyayyx7Sr(y0jo|NpG#f0@>7tmb99 Ux3g%~f22_00^S-|gY~@kKYW&eCjbBd literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..af935a619f3ee6d5e7b492323c741b4dd434dc6a GIT binary patch literal 3508 zcmV;l4NLNgP) z4Ukq<8OQ&3S-zHU1O=jmz(@?tG&L@1V!X)VRzr#JomYWefP4vyu0`7+pp=s&g`c#bl#?WlpH<96NNUs~D61z)3qP))l#`^5-(5i|C&`Kq zUE>^-SWQQQvU-xNY1cVcP+~Rh3Cik8^5PixSV4)^LLM#Bu^I|W3Lz+mcVZiU=_+VluG?IZG$@sz`w#0sdz9)^xlLVJ#saCHpBbPv95`?oxNRBm#1{S8fIfil1C_#mN+CZnrBXImfQu#o4}Tl@!#9A5BY<}f0FBKms>h3|!cZzf z1vB?-@pbNU#bjX7O~CD60X{ew*nI$KY6(Tn`!7q}6V%YdQvq8lK(8LaH_ri<&IW#V z88Ea#MHPufRAMisKRpsT`Jt!-dIPgQ3oM%reE%Y#Pfs2C&2hv&oH#9BMAYH^fgfKA zJU$b+;r!NUO6H~%)h?V6^cYcn74_tyz%Q==9=j2kb_Q^`3D|px8LCY1@MpH&qlcC-t z8R|&Akdix9%uut3f_9RjUO5?9Y%Uo+3>|utQ8Wc26hI%FN*iFD!B|~jyhMEQxG^q^r24<*fKtYqrP{(M7y5KtC z+(?GXu)q6I(1bJ8DHDK&CPQT(YHTQI(iv){HA7uA6S#dE`%vRRK@&!X{Wg85XEQ^M z0|m_@L+!&1wYyNz6k&!M9|~$ihRQzF4xykK!amflLqRR;Lp^sa@ZKS2s0IpZSs&`c z8NfZ$Tl-KO9q&V>pqBQb9$m{n#O*#*3TpdW{#kCX<#*_#7JDuKQb*VFJMa<9k)fU# z=|dG<=tC{>M=N)R`sjDu$WSS0Q8Uz$Zmi{}pk-Xk-&CGHR0>+gwfrN>lc7@3<4_5Q z)aLLN^n3cXADi~p<8_^_Tv`s#7=cefo1T7{fg@kc7;l1+M2*S-Pl zZ`A4|KdbMda>|@<)&;;RCjzVf4a|KCc>Z;5^pN2__;Txj!41H9V^x&1K9r55UPM&0 zmj9Yh0&|}R{`R6~s3NLareZ7J*f|}|sEUJ}kRvU+&*$V1bWeidTkv$gcFRKG{?#g2 zL$B6l9HzR8fRa5*WxQY<@aP8Mz!9xjBCvUDIlj3C*nb4*SqJ>!V&J<|vXQhJ^B5je z={M~Je!BvA07^o=a(=xE54 zW9!0LNQT#2F41Zx6i`TUA-N+V+FAM6?>m4Rj?^@I+%*~>nDH**XrEST!pts zP#MFyrwrjjXU7IV^MMfqfL|>K7Oqn{vmRQ#F6@VDONQ#apg~3PfNi^hyPgJy_19s( z7R+6pnvM;B5&HE8ez8pN`bcHT{J^IJDm^MacD+s|zSde^5v{|c_fJYdCESVNEYGSr!NWT;LH zDq_ls+1m*0*sCYU&)_}feO`Y@|1872t!w#bS+F0fMPE;T$kvHRn!^i&3?_y0Hiw#8 ze?bbhmLJ*np;Ax)eR~4Edxpb%2FA$y^zW@F*`h?Jrasg~H)h+1>a?JNAif6G(pVu? z)XU&Kn<~J#VfyDhFOs^`hk9zp&y;dnP(ddD!iA^kP@hHqCvS1_1QZIdgA8@S_31uS z%6UPJkkU{we1PVEX=Q(MwY;wpm^4cF5v^`YDeQizAJGhT-gK?5Z|~P%^rXJSf_**7 zW11yK4bqE&;LAdT( zHSc5F6?~j|ydaG-a>1G8sNlSyW|;loKH%2HTG`UNvX+V?@zPHo(MFRZ3tFja(w5hf z&A?@IfvfKWUfmUD$_adt&i8``cStc>POo)JQsnA z-*)c&>^AL)dUC7Iv89*69Fqjj3u;>4p4^hntxiGSW7%dEv?%!1t$H4?{!JBg(I)*R z8x*$cCEcOrw4lMAobbx}KYMgPq^xdr$}lL{($-dsnDWc~+HT;^mD)2av66J<2Wzxa z8g-l&)FA7~8=J!W;u%y>k#gVR)?W}C@xz-4M$EVN0(UO6wt}GYnqrI@E*EKCD zD0FC-s&;e;%b=r4u6E6>PTL%9F=E!~8moJs2kv_wcyq6cc|t?y4c$rNw4g>1`O-%Y z(kuD2hxy=EXN%t3Mpc!vP0Yq7;DI&3yk~${ck6nqVf|Xy{bz;Kf>sOisj<2`Q%*#h z7fM0Hln-1m9@bC^yQ<8sOqpNGJoDupx@a&EGh8f~HO>oa=73*66ZqqE=?+^a2mSCc z;PVr;Qs>zZTJhUnsL4`mS?#%MyH0yf$Wcs#C4)&iQDDo}gFef6^AeTP)G&Ne_W?P@#s0ylpS_}VAhF8|Fd%Ut)SR?I~kwNlDW zgpQV4=Y{hX6lOOOXyugM;ijJj?B1`NF?j9lw7DaMS?kBmK!%fr8A#Jvdr&21YG`m;NkT;&lEYU_nWc>R?tY(gGeV0xtC7Vt#3B$ z(9Idld6P_VXg^@u8NkgK0;i1xR&LcTW!7xhkI{Pq0)-c}1zNk|w4ZKU`l?q-q9N?4FxhW*jJJ2bk(( zI&=K$tMSM}G4t>39V?Yse(q(MW%|Bu;dAFx!1^6PznrtoI8e~z5Xk2xe{8B*=1RTk zAX#Qn#WYaRf>Md}WlEO$n-#!{EtVe=Hr`yJ4%#g#({#`xW;^o{!F|h{WyXPm z=Do(;fh;o)6f`Bu)|Z(V6g2HM=3!r^4QtGc2nEfkFVluK=0${prj%uttuHe#C}`4s znX<+_%rev1FVi(BXj1!St^*DpPJh2l*Px&U+b`2KC}=mbOvy1N&y)wy4$OGJOxK{G zUCc72YQ7d`nZcDgv&>YXpj}vF&MY%~C}>pA)P6=9u{W4v{89{A&_7AC@(hJJI%wkjTQkMCIgbIW zw`*iHgjr@8@JjI@FE1S4|M4%Nxdj;7(E7MPQfx{!#P{#bWfo zxq(3c{QZN#KM4FkMWEs4pT`(8|#!OAD~lw8irSY6$50`#s18UGFaL(AS3%=wL8UiVN#)ABk+Z(-+C*4K)cF z$2oC~G_a!y+unNIoGmBeMG@sk*=pEO3FxqLDYn^mu)&dgij2K2`x}P9H0}(vo|oEl z2x|(#8QxF1l-?F|pux9yB$tvY7yJb~I%hQlsTXmk2Lhkt83K_`d^y=<0<1t9f5(YG zXBdno-c$7EWfR@QhU`>t5;}d?X`h=Uj2D7(%)pP=x894Ty*tTW`X_`ajS|#!lWCFZ zlmS!sm@ek@i9XY@=CTLY6ZRVchUxI?aX_CjzoR110ET{^gWyqV!!O?2`+Q`33uKB) z5>!JP$JyGa07U=2gzDAlP^1=h$3dC^gP@gI2n2~v6dMxl?_nD`I%0{4O8DQQYg_L{ zqaHXNMyjwOAxJ0t{|zk&P z+^0%UN~z*c2X`E&Ce2Zw-=>li4!?&8sgeX3yyM*lXT=_Kz25oH^wKd>M|QG}U$}3N z0d;wR*gi0GE9K|~LA9Pc4o3;FQe!K+EX>^k6_Z)~TjkmoeVSj&OI<4%XEgZg%x2Ny zUWhQXuK~L8>Ru7^vR*I4jX6&uiD;+U>{KW2P62HH$V)7=w1_Xqrziij%_o|&0^}!ki8bds`=j8Jx_ zXaR;7S_4Me`Gv3ez8{I_lYU*5Yjp36&#MpsAN@?sG52Goe@uH;;@N6ve6uE0noyww zxs+mNdvnV$vlH>s3k$fI+uaff9SvZvD|wh%2ED|OG^40LwD=rg~&elSyT>wyG48i6n#T-RZrnT|O3@8YV1bl%m z5_7B%_4U14f$<2=OnCwBBm<5_GJpudK(f2)3R)U!E!#c~{K3|+oNnw(44E7gpwXNK z;Md^?n3fJ@i8;2&=ZiU3*reb`3aD{|R6pyPwRiKa!jSR(ihWQo0KlM)WYl5e+57sJ zQ#{v);Dl{|fJD!nz@{MpHy_y6^9w*5vlI+jsj80U4xK>3*Q{W0!g2qY!IfhAAHaRC zvIt|Ps-5ja)A1MfVw_ji|%#v?dJv_lh!3st{08e8JGfcrcMjA1x5D?%S}h~oh0C1 zvmv0cd4cMsXVh_Ev!_m81`|VpVml`R_UD)WjlFLin5+UwZ$uySt z@hA^;BRrwEXh31gDjAOJb=s{ewcEm)4NeblueL$Bm~C^wR=`J#=)ypG7?y7;R*k;$ zcqa#QvJ)S?9Wpq2&~|WX-nsnU&Jib%{Na1M^}5pn8lF~p7!PM(EC9ak$2w2Lh!V#> zg7Edoug%cXvpiz8xIy^hgQ~&8dk@bq69wb+O@mFC&&Np8%QIm{$JEFV{TFOhmEHdu z+FcMgnGZg_v2+zs{d8EeygoMc8uR@a@oIe7hI#$-=;Y$htCGqwiL`wG7~2p}W@CkP zevh>Ke7@vd+e}w2KXez!Z2#W>tr_=T+AwL0qT&8S_Q}g{=8+da+rCWGkW<@b1yrF0{hjr7B4@U7xd$bpeA4MeMtaQ-gZ}NywCt72!dD_}>4hTA zRf2*>NO>_wk?_alpEpW&8h_pp)iM)+Bd@_1aqLAAsPK|0k@mDF?2m-7hM&}rgJ_h@ znUl(EHynC;qj^|*82=)?*3Hv3-(fPOu#g$ReZ*O66F6Mei}=h`n;>&r*w5v%fOq-4 zg(=#u!*^bteP1p{?GdBSB%{P5EPU5quC6h+`MN~x0(0{>Ylr@(7Ej*&3BC44hZu^^)5fq=?Npy5Pd$j{@coH5Wd)U)55i%p_Jwi-_mTg-bs!myY4K zZ^&`~Q5XavOjPkq{`Q%`+hRJ$Z@Ul zI_F$6`LFHu-E{m`l&J)Bp4UIxa-+sSrobxI29IM-zoT9qzfoly;J=-7--u8S?3aX<#e{k!oT<>!|fqT1{CoQB316=inj`V&P1f_$k3)J*<( zGjq{vl&=!Fne!m%bW97t-B`qx#lYIFrtYQ2XNignd}bg2{c%C)_{L)al`n98zVp$& z#YP%pjzThjhq%tuz?Z+6>*;XA&FWI{Q=dL5+f}EnHYGhOxrz1Nj3-idgSOIM>8h%f z-qaHa9sJBRH0(3EHaXFp(-fQ)qOuxB+XiKXlIWb3fMMDKOl$uK{V5V>o?-;SkIGKUKfbufs zGu{4gKRi$j@N`@B18ObqDp(u+GQ;?p91xR>aD!ap#9u=z&9kNp7GDA`b|&je++HyR z8(Q@6757P&wG?Q4KSCH<9y>^wnz~o`Zh6|UwbyMu!G*IZ_%-p_p>E2+nHa|ozT&Z@ z0QF{bnaL>uc*nRsWBiR@-r=|sLUyl`RY8C`=Y<6QRo~9p&|Mlz93HYs>6F5sQX3nuK|zrLe{B z#yZMwN0n+NN1UtxXEF`~bNYQIDrHzxO%0EA6>)?o=2>M8Xln$&x!7rj$}v6Rr@--v z7Jh8Q9WbvZ$b!+^swC6luOe{<;ma086GdgOG;HA3mcd0-ec-LUda^@D@k=pBY0Kf2 z;xPXhv1t?!+P-EL>b$zUlP%-t((PW5=I@JTJKtxx-r_}pV{odo8_Km9G9Ihm+(R~2 z&_V^Tey0m5N@>)_L7X5j&q2L6H^!mzj-+q(X^`X)~T zG9V_=K~5+mYpY({bZ20J=01f>bf0pW7U#GB00PmS@HKB9fi7| z%j~{X*n1X;Erzg(W+e4oV(WBw$~Tmx@`opEO{UKn8d&Xfk7S-rQqZppy2xB35&g{a z^VQ57Wj1~Wn7?VAxsI)HpEoRgBVV~bNhNXww7tBzxBs{ zfkW*k3fnh8fy;PCr;z)ltk|v?*@p*_X0qlyAQoR$%yIx=>(f&3-R-JfiLP#S`R+q0$H^ zf-VT~ov$HWTNvx*a`{18urKzIzaimAb*MY!-lcrYl5A}S_xy<(cs-jH%+4K`(Ce;+U$Q%K@0v7+{szm6($ zEY0O6YjBj{&%RcxoKB~94;vmCzD609qXj2!TCTD&zU_SZNyEFQ(vHr?f>R3Yv;oGMy>LT&WJ z(m=mJjSMchy=R=mn`NiQ)`5@SrOFX!7Ji?9btUk|nDx$OTMjwv`PP;CCv!|g-}R1L zi?SSj<2YL^6rJ=-Uls{N8&UKxb_Ei7?CGDQqKYDxo_`X;oQC;yC{V<;5^M_FcyfGz z@FDwewffi6P8bYr<_EkRXS}1v*|rD!7@>n5teNG4q}Qge?&MY*p8nt;4C6h-0yQqs zF4)|6P_yhqh4F=1>;wM~fA$nHv%{+qlwzM*NzB*rGU>``5y7gOLkuRH+f#`jgW`~h z6^9%wkBr}@MPzyO4fTzxwmr2bVxr1UNd_v+Zle&j5}5)~`*>H&?kG#}^V~ zBb?7O?(Rx$#MfWV9(I8LoVPF+tfshbXzfgVqT5GFmoyy|DP@|kwK?fqwM#-5n1j@92Fq&k6Tws7~LZ13L7US|u#XU({=PPE7X)3UbV znJCiRNom)tYuam@Nax=7AMEe3e6m&9%ellsxa9KtjpPw!FI`m7MgY7)2gLdmpTWk% zf)w||)wO~}GKCIt_n#+(@%J#I5@RbCR*{*W6{cJ+ z=F>q?K2?TR?QU_qu_!yhQ@=Pze{E+LzU99c>qOccSP@z+(SY9H0OiED?y*-rm*gGL zK1fZh=pL1FZgQGp%2uR%aG^7RQ=VbB#i|ablND^S{50yHPK|B)!GtFu3h0R6rA2X) z+JG~b0!N3!kjIDTm^b|FYHgE+eibdyn8tkL+glEKZTScEfpj|Httrf4hOAW5m8xo> zUSN%ors(nA*(AE(uzukVj`?F5j0dTK>R*UJO}U3zT~JF%v#bmQj1J`GCj&n2^ z18vyHConra3<`lmvH1P3HlAPgF}JeVM)#RDIuGEOB)~B_EnrdCngZoi?P7m5vA*}w zw@u3uH_yDbwwo9z#291?-2l*`belS@%B#sjP#7yBW+_lgzIStDMUEIN%=iblYn@Qj z`;XtG0=0?c8`(5I5&7Js8&G{;*$WB_+)`8`&PMb zYmi=9K<(O~)^KDVq|w++`Iy&L>-N zJJ7ym+5}ts4mT@M2FoUSpfsR3`wvuAahiG9U_M03s#d|uuO3K;cmTMk!9Q5@QdNzoQDHTzuj*OcV{zfjPk9?g zK*81xpG@n>`W1Y~H3EP_dI6AW1LI}?7gHafNVTIZ%dQK{T|p3FAk*u7GjhFLD13B) z>Z)Ojl@9vTMCCvYg!BeC{d1aGwKfkb70I+F&t%#QAVCzcHyufEdX=hGEfxUr)&zo& zJVH?RY8xtSrhys#fVgsYZLuKvkOJi=E5B^kAO;Ll0FB~*t8@(W9b5;(b!vel!lr(i zvZ9KV-A*BEA}G$=yHwYdOJ(m~U|I|+P&iml7IYaf zVBiIPRrH=slI=5U7sslWbF~CiqzX>EJI@Rmq0*UM;{owNt}{q*(p9kQ6^oh)c?LNs zppX*|t+6iOV2e#B<=3U!Xco8SEA@5aFi^@_utOMvQZPVL{FZ=Uo1w;o3pbPkw|N*C zoQt4j=C;ig(p&dZF8~khR01{Ywz=-{0I!meZQ!exvCJ3yogoGXt1R4@&r3U|a@O)< zYqBY&WDl;nNqxJB90h}FLEWqLBjeso%^zLk*9{-jzS&_?fv71UJ4G)Z`T6z3huG{B z?#iP2!3i$$Ul&*JqKI-mC>o%-LsF8G&kuLaI&ELl<|Ir36TEStt`+wdln!?ACSr2+ zzgw61hoAC_1KTk;ffUlMhew;#sI~FZfm>4G2~qpW2h ztt1~r6O#BwLFb@j4-$RACWHU)qW!;m5fDVs0$Jd{I}1_n=j_j@mEUmy>VW>Ub8yY$ zdgSQ$6hsv0Sp3~gV08}eIte2$08RW?=YZNHjr53fu(t&8{<|g723itmHQXHske^}N zV?}unwVXiD0%!PjJmJZt>NFZDny(`Q{PkZ;3Px0CIz8ep=g5PGiE_q>u9BL{9Z<(2 znEO2CZ2z9Z-hTmtLl8p{#_J|l?-T9feS+<_6zIXwoB2iWLeY_+wUQ)&h|^>}F$v~^ zO${eGJIz3kB}5Oo6794XxIc2NS->U9i8rT&4beHuzRxAfiN`(zLZI&mo@dCn9B60* z7YOxk;;J3BWa}OHAomTS(-NC4Dz@s_(73M#T4Obi!p|h!yE~Qw>(z$wjzAfgn&@ZD zA-<1?&Jpg~8~rwG09B{e!?i2>icAK=|mc_*e)yB55gryZ2$lO literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c1c47be1b35d9893bf1b9b764c3406b539770a07 GIT binary patch literal 5367 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..78ec1ce8fd2c6059f5732b126718f0fcab105157 GIT binary patch literal 5361 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..391fa1a9c8c520008af2f6245047b6d5fcff2454 GIT binary patch literal 15231 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 Fe*mS(OqKuu literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8fde7d44c04685fe1d6a07f5e629b4a721804280 GIT binary patch literal 8185 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 lLHZki`dZYNWGPzj0UHlgMBU + + + + + + 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 0000000000000000000000000000000000000000..4216099983f62559b4414ca3c90bc0764cc58b85 GIT binary patch literal 1321 zcmV+^1=jkBP)`C5h$i2 zJ`e$URizR|(p~zZwOHD1%ga`vg_e}2WtTqKwzRu@$IRN+O|y@?yLa~9z1i>A+&wdA z=IqY*oilS+Nc%{{BnSbeZrWjJ5};8;0t+_?Y-_s0^)Z}Hf=aj^5f7=*aA4}7M1*4z zjw!tWUeIu0L9;gVL0szAo_G+Hyg~lBOm~!{!=f$mh$6_z#Oe$bH)2->%+}yt$^h9o zCPd+tWmubquWPXHEQZJAavbP-WZB$g;xQoxTUL=x+4!~=Kc2&gEu1+95IXTl2y3Ur zj6lf^RLH-Ex9D*qHp7Qu1RczY#YeGFez;!r*8 z4mq8Nlw)p9Npbl21?ZQPFVCRlJnWN6&;Mm8$uUo+)Fhg}7H_S@jxzjy$)#s_am*9u zCZTXWHm|@|1L|&oNEqS?d;_x#Va$$M(^;ER+!{b^G?L?dj9NGcNA#$?ifyOS+7sZ^ zyaH>t;DZyW?_{ZXUNUl*;HU|meay5p6`!s_`U5^ZJe7_o({a3+Rb)MbelLKFc=QY+ z?|b%hIXOr=?x5iGxDkNqCi3>-Grd^*D+U=!9U?dV-Eji*~l@RR6p7xCDb%`Q*JPg(9p#p~F4Orm&*l$^ZGRDL`|D43vA6LD}Owv?dV zOaPiV4eJ)+cr)pD=GL?nrfGANB`HMJHIi?jS9;wmgaXxl)@&Hw!PQ=tNgtUB-F|Glq3sj`8JK-!MC09H=Ar?CwVePoz+D zYc8ij@fQwP^0;t>%K fr5C^pn&sHPNbY(=vaU?900000NkvXXu0mjfLN9v~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1807efed8d537c54aaf70df1b68cc0a7489a78a5 GIT binary patch literal 813 zcmV+|1JeA7P)fP?{bqn@k4~NUShtJNR?>XlxmY!h{ zNhrcC;7?SDkEjqIQ6WBp5Mmg}WKz2PBM70LE0L3i%dPnNJ2He+N*vpS!nM#f;g%79 zCc-VmqKU=n9XPxmx@O#e4Uae6LM&+-oZXG18&Fz@$Gs6C#FCi=eF5Hn!?`Cg_D6sa zK-xhA6Nv&P0ux$nIzQV;u43CCeh(#&K z{&mP-jT;@f+6G6Ep(WMU5g&u&c{q>@eKT%8hs!f3LK9v%#*r9DW&0S^v5?Q3QXxTw zQ`>Pc7nKdDegkg+r)A+nt)Ea_1FI9+DYVU*tw^4a^1ax&%)e%;8t3;=XjK>}v}U&w zDCG9w#666RQ~ARW-$SbtMKx$I_U|W_tQ6=9(EXi4tL^iQ`?Nrzp})T- zA4Hh}^(L@mHywdOF$$FLLs2!I9y2twlE02n>Xha~A?JR@Vn;Y`eM#o87 zZt1L$9g7m^R5=~nDD*9;GQu6eAv2_;Rw=P~p|89|%TdAUqV4Qj8%EuMwkg$K=JHUN z=v!z@2@3IrybMj0vU-|T0uWP(VNhp6xdEmj;f;tR#4vc#Ptnq83AHH^gv?fyJoQCO z=!7Kn#fHj8Ja`w-K}TAlVJBS_Z@(1({u;EUP{We)65WX`!Mo)!9zbVQi9$wl4HoPL>V-FgT^H#ZpU-8+~^b~${;(HY`P!Qnum;z z`n>H0c)8;nox<&s1&Q$Vs^Z~c)y9c)VDAh7i7!cGZgdTgORdj*`7z1dvVU!Mow1>? zV|I$G)XK6B^@>izTTDgr_nZoQ?s1S)g)SR&2^F((VE{HIIBT{Hi+MUo9v$;vb1hpS zKTRzktKz`r0+sw75=MC89TDy%uM{QlIfY&lp*BtYDdb9YzM4K!w5!GA!vVM#m%fU; zQ>Lop9F5i@y%WpJPOVQ{I5vRow2krPCzoTBHToq|e40J_Wb0Irc5&o8mc}bO(jr7M zSJ*CpNH9qD&zVnK?hKOttnUjn=r#@MPgOUpDGK|D;I)^MWVq=oN=K%BQM5rqoVx*UMx1()!?P>d@vkhCe?0u z!P@I~xCX@$ox<@^7JaG7D2Gx0Ou3$|M^RpVMFBpg^Wf0Eu83H~aCa6wYRbvQ{H%Z` zY9xy#4(wEU@86QGd5>+$QDygmlFY>3wsW~T$PuI=Sas)~9zeUa40>qQ4s7cK0}Cz|=qQ{?<3bA!kT?vp=i|38L9r`j14{?UXDf`W!Z* z275x)l)D)!!9Tnk9b9FaTm&S1%>zp$U3~B~bo(`Sec~0>2W|Ejb8lu+EaC{)nXpOetTIeqr;fw(~#$p)mEvx9f{*Z581bN|;%S^B&Ac}UT9 zy-DDJCg-I;ez2QL@Rz$?YB}50wBDNX2>{4^6Oks;wH)FzrkN-|H%hVbdG2rGq8;OL ziga0(-hP97sZ?Af#)94z z_Wthv^}zYvvAM9@)nIAfg)Cz5fX^?L9v)Xoyik3Dqwc^fh8JTG^=*`>Y0GrxH#lvw z1g9lKOj1GZ+G6zgnPm&ryPA-wP*wx`IVV@#UDqK>qFmHrriIbOu#uGTF!f1F5csC& z)}A-CO>_MqUwc`pzC!yLO_W!c5#d=R8!GgEwcVj(ygfOaxoZL{tLm#DP^l?VY81er z*x(7P8R*S`!WJ`3kmC4ZL@cY&y*QZ~mhUer`};|`R^%&H*U^QaZdN#iP!^w6m!QoqDgET#u$zW_BSb$)& z(0%%$!5M=Uxq48I8&N(!8Bf3i*H}kqm6+4tSQ@ST1i-vQ)7h&P^^eJE_HYu!mLHy3 z3_Xj^`*084atR<7kn=<9vDLEaG%hyP%i;d%ulG}s!hJNsjK(aR{|Dl~IQd<;DQGbe T@1jv&9{r6=A#X$;4 zKMZC=h=UIjfwCeu9H;YXEe!JUtjjh)78XTuN}UTV|A{V59y-)nZ(vq|q;XzVgj=&_ zo{P7w#dwO&L$5}3&eRU{9P{o-PHdU>FBQbPBjE@xMNR0?haLrEd$ z`m)%{Sso5G@!I#16ALZJDVbTScS1@9NR`J@Z@w$l1;Zvnh~q$4tg?CzM>|T9L^slS zbTi#;#+*w&pHz+(WX#>}KI*AzejaiCC_Cu#+jbb0&&0{~cAiGu!2`4fxZ(-_i|b|> zt~Qv#AMNS!PJ4U-(!xh(&npk%Noc{Cj&e5ci)l04#KVMxSZX*CAVg$b)gtU_7J0*FHPr#ubQQaUAM6FtC8$ z=NDK7Q0=xVH<#bfY}tvFB-BtZc`k{i8u*Hb2E!A(MK1}Zzq5H>v&Cg1>-e%RNZAl=VUPDQxgubj(|Yi#N2tt#1HFtP9?=)Ft}(5dVsRLw+qcde`UNtVn0mL3pN*~?a^=s$ItqjWe&S1+M z6t!tNz=_#dX69ERC3-y~OB~ZCp6Ka!YW&P45}m)E>+$mH1I(Jy92Zq;f7>&F4yIvt zX#Qi<59Pjl7N3J9K$*Z*-S(!_Ps!apk(!tf4``$7QPB z_o^92r>zeWefTTo`GC#M@RuJ)-Zjk)|L8jT_Cmkyg8AdUJ}n}tF=^V#uVyApy-ML% zh<0XUtU*r|>hSJaXvaMSHcBsrm~^1S=PdJctzV?*CMt!CFQRqUj3iz-y?@&_-ZBAu zTDB-R5gNegONR}PIo&UoM+Qo^Xro+~rjY?|vi`~ISlZMX|CKnctXmdzYfYKdE>gty z#<>vhXp@=MRbLfP>}enRq^gtG_L6>3wp87o!8gzIU)qZ5H?$hF?>a%I;M(lMej(&r zqlS;u_n+vC#;YxZo2&@D2OARU7qoyvZCsDneX@Gagm^`3)qabWk}a9+7kO-TU{kBI zRgghKFEj`uCvz6{sC#Lupk~t1aJj|)0BntH(wjYCaZ1BIBW~yXMo00uLu-0y%gtxJ zLuRU>e!=EYuqHkb*+&V%UcSoB&>~*e)~mJ`lbcq6j;qwyG0Ed7nh8^uvL?mq5OjAu zTg2HNyAFifz%ER|Oi9fGvJVzNer|Zh!(Sr5CSaQ^)YE+jVpAe~UU+Ot%bsnqjJ+<) z5@H@6&o=A^NxF1uf_UDyFi zBJ2<=t)a!H`ToyQ>8m;U4-^imnea?0BYtAF8H)SKWZCtrRqAuA;a&x$4II8Q-drZ> z8V?_s&h*as9!5p!cC0;1*q>uZjz!MAp1kq(wKK1y1)_XDpxXx5yEC(x`eq9Pz=Y?bAeCUWL;2*&Xo+a7ZjXX3`zogQjDC=d1F^N_v zpUe7{rtsNisJIMu_kx^aau(nBDmLjUePh{ejC!A`U~9qw$fzy4PY~Lh-x-W3PP$EK zM30~SZoivZBW!lRWR_5d-eoJ6xes3=1{o(9wp&p1tt;NhM%?p(6R6zSolt21unQzc zln{&aZ`j(_u|sPFP&8MzQ60zd2G+<(+(uK8DQ6eMwdF72?cR3Pv?GaFhT)6-(y5e@ z;grM};ch?r91n;a&%{vcd4l6wE;p(mpsEPF8oRX8|ES`?3jim!9HIU?i zy+5lv92Ha6v~1+MAKptqW>E$e!6sJaUln&d`2BoyM5(Yrig9pB@<&WciDtmTc+{iP zOqOkxL497!vdsg$E@))|2Tny4^a58!RMM-zv5MfQTimtJ55!IMDd_PP=O@U#sdC6Y zC*-B}0W|R?ui5%t?aROL>}701dOZEFMm{$eUal(8@n&gfN%kYi#5#@h!C8^&5Heaq z^?8+CHIh_H;OMHa<9b&s8~M@E5fcz47I|V08FG^;kG)K&QJCoav8U?|kT5jYqyNPY z0I z#BpaI;Q4&xQji7iBkijw8JW$WPB=UG5vsn8q{p0|(CJ#}Ni?2t16ix1-YaFCQ#aN> z38@OYb<0S3N7tc9qr}9VIqdlgj!KiEvX#c>RmiDB4L7H5s#w2|{4A>UnTmD&wd;=H zMxH`>xo4eX`Ttsa7AFqGb_qXt(3h%~swRnNr}d&JiAAHM^e|B+sm=E zot=B`gCJ#&({Yk9kmQne8W;4vO>ae+;mraoV$|9#l<|d~^glnpj)JAo-D;*pDyV9B z=3}pp{9MtJ*Sp}N^i(x~&a?+X#25=fkY9-5`D=i?1LCr3L#BA7hvwd*(6DPZ8 z%9WGA>veFp1uW=CZGFQ%wQPD`jpDaS4W;PoI+jyhm$rRr0F6ItrRb0+s|e4gnspV_ zG!m(pq&vn!9e2)qh#DVmv!8O-Xz$?l=(r!596Y?yM;jFf-)GFTzV~_bbwC@$BWnKd zY0irAm49}ku7Xk*)~NAP?EI@E^+UQVqXtS~$G8JWo^4936;ORqm7Q<=lee^@pmn7_ znpP(*DF>B`q)I!N7cH4s=v0jR>kPJ`RpU?c@MWUl!m#c5A-OWT4@cNXz*U%3G2fk= zMC%qTMj%I~ON+~0@u8XiqtC1S32eGNc&5^a6JheEi!UZ;pB9n*S3@nTGvL+fVZ`Wt z!b%b&(Bz@ObnZ{+6bzod#eKf!Zt8w{RNVpufX6yUyTqRb)wt1#~fr0ZcC zkK{=Hx^O^FjgnMYi>M|uV(kh*fyFeSh4FCb^=XnrM~i>}POt;mJAw&0LDM z%=C@_>9Xh@we?HUrJ;tcd%f}NK~x@t{(K}-^Z^q>JnO7wbBtDThtynw%d>g-@!9)1 zu@^kMw3`rrL-MXFzmG{kyRDiht+DeRQ}@swR75lA-d#rpds1veb-g`2kU@f6c0e3$f0qrLrv@`)+Ss1QQ0M($#a*1`6DU&9tqUc# z3CnHGTkP9)QqMg(oR@0XEhQb&X=trz9&?fi;E%M>3x+9bf(`gvbVF+@V?6e{b#>v< z+%&uHB~mjl*LNUn;*(jo(AkdmH5n+pv|m* zZ)cLpHQu^4=C2xF+}nA=1=%_qn@VjY+6t-pERXt_VOnYJgvQ@i>gA95*^WB9yQH)@oVwNlex|R?$e$`1F;W)f+N+|gx7k?$HYnp_DA+R=LaPa(x}AqIxWwVgz3NZ$UcPe#Dx8Po?dr; zi7}HS+$}e4@_=`Fq5FYYPlfc*tVI5vB+Y|Q?bo)4|3POI1w2wRZ{J4`{8@l#OYsMG zrm-FIVMyzdtob}fH^m4QW<5AU3dPqDYeZH5PkkF`3|;t_aH+G}GbV)>lb zwzl|Y$+x$U$6k9T3)l8%=lMt)LTDiMe}n7T!*jG2ulC|NPvAE@y3X=wm;=F%?dnD{W#$j;8bg9*1L_@yC>?h!~GMd)sE z-}pU^s zto}FkXYS68(tQS)-$w^^2Vjf;A=v>&=NZ|>^S}^eMSc^2-Zj*h%CTT$$w=L;7@Lks zl0=uEkVOYE&;Zj8DWd_5Xglh|2IGXBh5-T=*nJ7mmT0SNSox&G + + + 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 0000000000000000000000000000000000000000..13372aef5e24af05341d49695ee84e5f9b594659 GIT binary patch literal 53636 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ⅅ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 H-+%o-(+5aQ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4395a4f732dfa7db4ae6ee98093c42e22a1a5f44 GIT binary patch literal 14523 zcmeHu`9IX_`~Pi8o2BIxMMkGZi>;6)nK`GT4yDBj5feu!Yh*Vwv`YTecji6b@j+$ zONAwCmmq``4j$Nd3?W$%WziyO_~#q7@(n`9mk#dx!8Y{v*Pg|Xb3MY|^M-Cm_)Wy! z^F4Ur$EAxhqE>}|x9olG()ZDe<79gK$pcGTjDJnKd2X9+{qa>EO=$MLV!~o$nayPH zrWLxiDoH+9_j~^OA%4M%uI1TL=l;y~y}$~*-#c}x$M?>atD_p`;qYL#h-)jOj%%xC8s(59xb6&K1kyPtq0mL)(Xkzwvwcqp=*i zqO1Sy`d1*lSk-0A+uKXB#BC&zy zAYLLxyS51BGn}Z?KQmmln(7L&RuN}Ehjw}l2vh15Lh7C=6#EuNS|Q|bCW~y{3SC42 z0m~danRNckxgoKYfZNzlKefV$wNlgj8m}6Lmv9J?+z02`Ev@8byPwz6g-p(59`mDa z0+D~VyZFT~G_Sje(Y_ev+Z4T`wQFTFzg`g!t>RcI*X|~&QU!H8wSzv!oOd|@{xWtG z%05x$+%%KCvoFHtDl2k-*#5Z?-GsQpZ7d~kmc6I~W32~^J$glZ`S>PdO5IU3H~rS# zx-GBp%1kmqBx<1SrSnzicjTK4)B6J0iA3J9^6!4}g83|xLOn-!YPbfd2(Q@fDX1iP z|J!;>zw;Lc<26Ekda@|!My?cMoL-EEcUs9H+TKNI*cc1Zi_w9X&?kF6IBIA>QfS0} zEkbF~-T>`?A4H_HFapvpY4q4Y;^G-Apr3|}6jCFDrl3rO6y33q3PLMRSqlLB#ZR+z>pbDixIgCYh44- z-WW)KJ)${00igQ;RN?_Z*4F{`F91rFmO`a*0Hk+R8hNe*Ao6)x)OHL5ZCr%1-(n!n z3Pf6fg@EOVWQc{QD`A;Pgk^|y3kz@Ir#dXa%t$?0SOqQ0SUvL~LOpo+YB2nYdeCaT zMCu=E{OzUxp{8#c2hMCa|z{-Fb@=sWq0w}Wu zt{L8o5zPw-#mt}*tPGezXI=piW~M0^FpezL|gB?oZVqvEgO1ca~T=Wkm2M#0j3+zSGNol096+q4d{-4k>38nxW zP!bGT0o30lh~@MEJSYS2N@yA02D@@ObZy%sjc9H#?rbZFoS0_-%>xdLgcMM-4j8px z1Go=1gP;JywoM@H!7Up=*p6Eu)KXExEzns-6NHm6D!ntHtP~a!`Fl|2sR_ztu`<}q zw5wo%>^-3DxGN}A1!ebP!_z*%*qdmewFD4TvDW)G0AvL~));6OfKt~=A(A63I=uw| z6bwct{{cWgU<1al0Myk8K*eDH(mMd;wh^KHXe^{ z?J%=^Og|H}wk-#(YD%Ci50v=_figuPvhM&W>%q#vpP>p^e%PO(3UEF&cj%)9Lj=z6 z!2(UX4FTu|D;ZiTd=dsz-w1>GyX7^~SS5uJpyRmI9t@Cl5rWoOn91-i_#qaSWZTaW z(k6h9$&P?;)nEdo6zFV>S7HwsnT#F5S(phK?|)1U8G8gw4S5E)z?8_3a0?icY=&F# zSGPgH)G+2?G^NR)^&n{VTn}1T!jv#IXfq5@LX9H-j^rY#tNFg4kVSXd9jW)RNd z7IP2^U_#kHVFBim?Fs^>COaGiOby8r7{Sz_J77Ib4Kf8=NvH`*24x$78hpgy6Gr0D zFf|f~2Gk@ai&J5U1a>f{9;S022Prx_{0`_Q(Q0V+K z{>m6yw1H6?2lmEE2o}%{m|STR2%BL^d*U@?4GW=d1!zr$V-R~Zr1x{O9QJ64EC=cx zFk%xRc3_}20CfK#eE$gCrZt|>$=cT_)c{t8k8{MoK#*Qt4jeAw!GCg3XF zV~#o>)S|}6q;oBX`Um&dTb_0HF`Hvm3aGE;pyRN*l??WPjABEZ?_+an0_aAjGvWFVCOckF#J*}BIXJmBzP_iiJet{Kn=^7Z68w@JJ(@UN=ENJhxO1QFc zw(j{v!bJDF6qc>;hDhQ4N!^}cm+l~&rdI7cF&PVQI2zq#CM%661_sx9eeCDt)#uo3 z5l4QEkzo!GqMp$`Um0T_7x!XhU{CR_*#PU?0qbE(`>Sf%aTwTe4- zDtu)?HGMj9X`s>z6INW=l&I{>yUEstEG>^=UiqY&@aCg{@s`j@FHV#PQ@=$kb_5g% zsHU?+!!T)Zqbf_`^de0y3n-*d#|!SCVs9O|Y**V^t=h?2uB4M(!>QoyOz`K&+Qgn9t?IGr$u?n8k(OnerB7VmZFQD-4=0+X$_i|__({_( z>`HHL)&fQFybpgv=t}ImlnAzeWV_Kow97TCqBzHK?V%osUEQNhg5?Pn!%vLOJZ)Px zh+pkp9UCi}xmQ-;^DXm<~dHPsWt8@YEOf@0| z9;J~;ctf|&k+`a}+qvFpyxXa;ymj_3hQA8m(&((BxvBW|)BrVtV$x>hw?}>F$vl|E zL)gSPBu}ym^oc4YxbT7n^O_A21sU(^UOAqjI+^dAit|cn@e}P6)OGgTQMNeGzwyj0 z;u6+&b);o2s0W{8Yzh(4Ycs8h{eoFmy`S)qL-f;}1vU+dfAB8fsWdFC+T+X@?c{vx zbUB&ny31%y1*haIL2Haa!*Nf-K7o#o zUDJX@yTt}AyGsN!juUZIM}F(%%79*noDh}pUimX{u+l6c(pwVUJ|fodtflk(yZt@F z6syYYJ5(=5x95xQI5rGSrBvzn3SNgcwS)>|`yFqIwRFY8IU-fdnV)E3xAU(H8k@mY z8$$?f>O0pqB37aw$xJEANW8J@)=-;a7u~*4cS5zcNH8)?>e${YDiAry)l3?(A2<$( z!$)4{`Y13y!yZ(CSkGa~DXfs-H$Qcr%l-9`RmOuaPmQ$}vL{y$c#famuOuRP1q4kE zy?MmpXT@#zyUG}-N;R;OA%QP{TDh9b5{Fd|bg&;-8*N64ZX?Zg^VFNUNAJQV zsLqaQL)SF_Hno=K_1J*+8RFwZ3lEE_t0(5HZ$EkTu3+byuZ~(BR=QQrGT197!SQA~ z+}0sJktcrk@wt6{>REIjPU#h3uTFW(l*XZq3}W*L%Z_K*hMJOcrS5%?XoYLyS6c~V z>04UA9lkJ?x9yNjq^M#pjd(?=>RFQ~A;I~(m=RFE5tMIjepW=B|09F=wClNj?_yDt zA%|eTn$DSQXC0AZ1UalF4J}65yVE7e!s9RDx^|m(_l1HCrrliP zxYYHOSrZ-U9TCZLZ3@yTzt?{{Oh6#2D}9YyX%sGCZH=pWU$MS-@>M8jTdRUuPRXJp1#G5xLKGSHdXPhn)mCh{bB$iUQ>V(jg~&`nr8je>-uws`h?5in zwdPL16Q=l7r)pIoZ#J$!(nY!r;*28;q>!zbaTouinTYj;LzRw;J|8-N&PDo&jFpV1 z3H~uaWol2NIFPdmT>#eJ!D@<%$!R1;%rY(MD>B~kWSd>#a{rDwRCu+(H*UhD-0a{O ze+BJbi2-dj?%XgYc6K`N<3tI(-6yiyJS8j>issr<`VZ+|`{CCq;!{{&C*aN&l|D)! z%)Qt`NUi!-oW|^bY_>yKLQgcR^dFJC{DNc)C;3U3%Pp__)ZSp1NKx1O(q3VfQ+gev zvuAnADT1z`H+SMvHUx}%fk21#)ci!9U0w5w{(H@GUvfLFj|v(atq`e7OMx5%hD#f4 zU|zN>?&dQs%=T{>mr8}0t~y{VO) z@wxg#oF@Gq=|3Pi(~AO42LrqKT_FP%yBlX52-i%SXI#0xWpiIvAj;A<1?Y1K_nG2^ ze3)VLIh0oB>0>op_S#na*SuE!)V2mXtos*a@_9;c)`q;7kIhzA^(Vg^@;Gy+_XKoN@eR%9;iIN4p0O<}F4QaGTp~iF>t#v8s1hN7(T8 z1&K9F$|>DbB!>mUdmRsD?>EBSSHRq_4>G6&rvg~LlgJwV@=o=XY{hJXgV>b34LVf* z+o4Xon5D`XQP?mz)ZdWd#{0`4C?i)Y9xu$m(W)GqAJ3~M_#R*tzv!kuUPyz8o2(`I z@`s5|zipk)QH)JSYV{Sovhjo`%gB6-38@=!uf~V{w&R21ruj~i>P<(I@ci;c=bsqR z;2KOa3_p5?)>~Q-Pn*75AE_HWb~PiUu6%dn#6lO81PPUgmk|2W*3EfeqU*c18O_*l zFaPwoa#{H~8=mlpG)XE6hWGFaLT6u(hDp(6=5ts|B#2%UAXwivFx~o6K%qhC#Ml9` zBkeG($KdKcnB2^N;++9YaZ<{-rZmSqJh`SMJBhD(P}s5UVTy(LIdtz60g5G z`gE97-_|_XQsLtnc16k&+>{RStOENx*Tu;CQm)jx&4`w6pjkE5x;4u>lbHK9aRnN> z^K-9LH}OiC6k6%#CGql`n^*qnVP<;xQAJ6Q4aRiS+^ zjVBftUx;2s*7Hf03yOwSp+Enld*iJU zZsSmciRLXMDV_3XW^%KoTN9m&%-qc`IP#Ww@@DptH)|>6r@$KMke7*iThh?6S(9Nq z7#_gQ4E@m2|5=;Op(9#ai9xmnjNFFz#iW0~jh5rZiT9^sI(R_`UG?HGOmS|sstaGW z$!p*Q%ojME<_&by?v!nH7TlY>?KL#`ej<9)MG7%^;(A`DdaFsnMi>2*Wu6d^o`nS4 zwi%07DwR{td8wOVel(q!*D=2wZV;WPjwFcUWVqC9SR|ehFWUY|G?mVLq3_IV3gvDp zu1P_sIw$I)A{sMTMA4hcIo3CVr-_ve&MZAWt3EGo9Aa8lf3>2Gk8)inM04y3-RFxM z2B-iC*KJR2DfH$6Z$)46?#RV$QH_aWm&xxM>DfDzGK!oo+dK?bZqQw2x@X7j#D%!bBo;t;y*qSkLX*;U{8NxG|}A3 znOiy6QB?ahq_%!RYf{CTfFpnXO@=kT#lIIT-jjD`AA0pg3 zOpByL8vb(X4)?44beZ$HqmsWA?#&E&*v;Y1wsVB2V}ds&14{H|ntlPx^EV962TWv! zHjHq#6Ai-chd&U5;KGcPmpYz0(!#uy+7!R|bAB!r2Yz~+&Z7ki=X&gJDa$UbnY5!F zequmwfY@k3m9xC%z-Lio!2nfQHK{<-k4z7ri3k{t;_N0aw;|@3N#73PEi@hb%}T~O zs@VU?HZX!WjA@prYe@|8-z4|pRHUN&;5CfxqEjim28J^dOjA9jqo$m%Vv zYVUviX}F)&L0D(Ar^;Kw(Mjugw7`#VkM%WNdQa)D-c1=vZ1k{b9opBtdLiWe=Vs{F zJnlHGmP-@Mgx$PAsG4-)Ma8+B4Lh1USeRv%y%Il21b=G!N7#w!dL`!dkh+5z@(KCQ z<%{){i^MnPK99OF2aooJEmkpoRQ_4tD4fl$aY+b?D%4u^v2Jw}E6yKno%TU|V=63p3fsM;Ldcf((!SpAc?0bjUE|c&z zB&YvZ>}IaLI_hBeD*i&Y-!QX!^@R9G!Ph`1*-MINd$~TM0^vx(0G+Z~H2Yw()u#8D zXj2DK6Rsd{RTC^M1k6OH48OnjdgiJN4gGr0e7B^~Tm<`+FJ>Q4znaA!X2tpSuB8Va zjU2XOkF(y*v`-UnY(TUMLz~OQ`L}a#Ruu=9w8j^%QFN<`j2y}g9lgwXmvYN508V*v{kH@-~PItP6+v$z;!+;%Huo2E6jCvFiUiCq4+pNohP-4A}V*t?W0_x_=R`C zvX4f!ET=Uo&V-??0V|Z>v_4133O?-==onVz!5o})@o4qM`L2IMM|bC66O(*LuRG6B z`1=E@b)~Y^E!U1wCzFpY5QRjX0C$`F$#cSQYK~gK6ff3NY~Y$v#{l-%c99S8y-i!u zt$K(<)v1=HZBrRzQLr4Sb3-4t|N>kFrz@%yJb>+jEui$A~ClB*t1DEm4e`iZL(r7YWYtz86J zYm$3$SR}C{ns2_xzX;1shx5 z@ne!FH38p60y;AA53zb(S#B_NxH9;a-rb=#FGLGf##*s!HjFtcNAo>J2T_H+MU?6Z{|pQ?AMU`FUOaf(1z@i|m}wc9)x|zX%+$S+5EG z@Gb3sg^&K#>-C{^9kw%1Yj=dDe6`7_%aW3Jw?#G|)m2w}U)}&bIY0`}Hs`}^_~2D` zN%Qr@&*4?)8%*5KJ2(u&$p^Q;(Fq4ljO!_rmddT z`u+cemmXQRy=a%O~PB=>|fuNyyT)@}XRpB&-Vb9+K+@}ydQ3=h(Z z5JC2wDJ9;}K(w<4H%LZBam?{6suSCWh%A}Uw$8|2%P2ErO|~=2z5!V0ftnjHA6KGp zH#-LY+P;U|T+kU;#cv#;@?*{)cp^(n*wK}B5O&<5^c%grp_{SQE$eA?0(K{ zEFQb@o^yyd9nR@@E##V(Gj+>1RbyRr)db_hL3m)7^?(@RQk_arGLhZzhr>-~WbZ!sXc(~_8we;D{j2|z60S3VUS!Yy+3o=(JpJJ=hocHbcaa*U_eQ*JW5lqNebd|zdh86Ivn^ialVOSUk6HqQrp?cU+NQ_5MY1VR6B#8jqI z`}nA(Ss4*R6cejkD{nEyrJ3y_{K)v=4Pl*BYtNdZ$TOAv-`n&4M8l;N-SLl(y#f&@ zFPSNh5x1SU;CGw>@A~spo7lyiXg=xgA|2#Hhr0k%mr8w@;TZm4$=ENYMEt;f($GcP zEI3wIs=|-Q5nE-t@|4HiONsD$NUBSw%g%jEi7X3tfNvEc((TNnT>cY1Ue5LVG!kF;gLi78 zi?F?&HG1)bxb$+n=nXtPTo}Esco{Mn8P00ruT|`dM|Kxq{Bl~yx5}vZ+h4Gldxg3C z0ro7wI_s|zS!R7locTIeqkGfpxsLRO8YjA$O_U^jwAB%Za#MDJJAemXlCpvFMNx-U zf1^?T)91{=8S|KePNzRoN0}4V+jbRgr&1%NX=$)ZT5#X#@EvM5Cs<;LAU92HNx9VE z!qJXw9^|tpTm7nbW>PFnPBy;~)O=MLv%!bK23S=)1k?Kcoz5%7^Lsh@KJTP$ysBojaK}9CS!b-#5nGf$r+-auGiGvhpf8R=8R{Z^kWxN_K zK#c{*e|*qFSEiDe8^G2MX#d%HQDLhSF_>tt^D})a56VBBWPTop2D|fPGSNKW$NIlv`w-at&P;Jb((Ia5qBFg3F1o~#16Mo zB2-soHzuq0Mbnqj=Zoirt#w-+-FPK*i>mhe=m_;K9yeF+x~aSp(!7I~QzBhsBg(|v zfZ2oWErZjo_tuOlL5V=weW_hVaZpQgJIu9m<~SQwdEISD-@qTt{V6WcY0ay_wI$)(@sLO+&T+EhH`yLOFU%0ealMB#vIk|$erCm zROXIt70+epCU%-X7u@nNtC-JX`zQ&uqVIMPSHf;lgB#h_>2UU6uS7po_62b4=uR!o z)UYsb2ZHM0_PMWq>d7pMiy5zUZp^Me%;Oeuo#KnBlIaL5?hf+T_}N&TF8_Wir(@`q zW4};r)*I+0c*m*@8BbVg^pvaCb~}E8qOnV-R+p|J;u3Tg3fToa;ROf>46!xvsZO9U zca%h=QUvdB4ESVSgB1N5g;JlrFA`dW&b*ng6usH;I6`EkfUjJqPgoc0bZg{wtMmZu zWi<%gUV9bFKvvSWjTO4TQm}^eLYcYW?iT%ZOwQEBfk{zl{Tfci(*B=YuMLtqCJ5m_ z--kzvc-WQx*3#&}gRn?*m3myBW3RsWiSZV@;|=b2N2*VJY~rdZHvZUM6~`_U=;~at zGpmY!<)=PHV4n`d?<^2Vd@_c5jUwlw+pE&|)+|-G6L}`Iq&V66%KJGhtFCqp3+nq! zQD~0-78MuJDui04%60s>R!37EUDhwggC&zXLaoQgEci80<)raqu+9HlJ5SY!8B1S5 z;Gc8~vE+@aD!cYXnmDKAeccfw6EtzRWRk3+q*E`$Z=eoBZX(M}+nE<1+FQ;R7e(vt z7!o-2g{q7x6=)#T--iA%^Hp{tJU&8VPDxoj?#Q|B*dWj6b8E>*X6@mI zPN4+-S!Gw)ML6&4+rq4Bzm+P?d9D<3R3zN}tkx~~X36A~x@xO=LDRUmmSS?|{Y17z zk0KoKIH6~3Yhs$1>91s=dG-2IcbB)Y2&vWQY|5El zcge@Hmq(l@tor zm`~Rhv$mHQ!VQ_{_Mu1VCY>@`6);sOqtp_Sy>hZry;gsEHNlz)YlX-wTKQx0X=Mb!tYZK2Np)qEg0EicsV{4o|E%!z2?YI7e+kp z2E%Ik3a8SfTq=HD>f$$D^=%=mG?;=QErAuHp}cqL-vUW_0ICKdC~aJ?i-4N|>;Et{3vsyyrsnhdf2hIr9GDuJ z#bAw{K+ORNaFXHx|Ge}+)BrlBh6D$bgc^LnNT@Lba0xZWD{v7IfJ%yh62Mx0pns0K zld1;FFg1FRACXW)HU&#yY8VDE2TToZ1fVfB|I|rs1E4c7Exk;yT`*0Gr0wmpOmB8771Gt0@ zhJ{PmFzoLzDhK$21_l0LHkB@=y6BZ!Zk!_9zh+VTcV*z60Y()^TbIl%u!I0x|c^|B3NqOHc ztZXq*I|g?Sv~m-!*92wvNkHT!P=>2&Qg?u|RM@O~`WOh_z{qkK2+}|dTMPu{Ikc@{ z3ED2;e6+a$PSHaM zlO8~30pY9o7uc969$LD<%OVx<-=!&_Tn{Gc2{(EvT#3}CgO%-rpjI0z+X{8NUttR9 zIAkXsfIftaI`uKMHE2D<0H9QG3f6eB>p4Q*?g{|HwN!f8F_R(|A@U^*^b9J2_u)~G zZG-|~JZk(HFn|YJ3Q6{QY{>je2)zS;jPhaoo`3`a%7=NJko*KQI{|wY<%5e&z@u)H z!WGF_8Lmji4h`4zNgNt&9F*Z*gHCPwXV*vyXsz%#)-GqK&1_= zhsOXw!S!{dI?#(w`~)?{62TH~?O1sCBP4Jd+F5`=_JKlnvawTmhY{Wn%1-{Y^c zG>|<_Wk2G;ok)uIbUA7}TXVXpweAi#<&m?P_s9FE;1~bt-02@QAQmkgV2aN{#fmoz zPx0{yX>{@tD{c=T@^tKBBA$#rR2=(sj@JFiIu2i&VXeazM5MPF4wwJ_m!JPy;J+65 yj|FIAy3iDk7=)I6_S^Hf`*H?&CG`Kzp9AXpi|$o2-S6QU9^8L;U*V7BOaBkB@t1M{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0c177eb2ba925b997602b9dfb37f237582263526 GIT binary patch literal 340 zcmV-a0jvIrP)q6I_$K8B5#8UAC769NGyE6C6n%#dcy(7l&o|1IJ)|NGC-x&!3rKYtk- zycskk80JJVoO;O6xtrm_6GEEbePvj1f&t>vP$LF&1qMb&5V4zDXoMQO-2dB|kG=KfYF!=z(>|+dHe&Tl)Hq8OE m7(V|Xpbe*HB6SXEVE_PtW?NB#L+&a70000oKG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cd7eb5eb4f93b767a5e8f510fc04ae56afd969f9 GIT binary patch literal 644 zcmV-~0(%oW7l-z{*&@x<^7Oi6L;lyb&*_w{u+|F+HxjOvL_xxV`IKS`j9No4vGTDeO zD{2nT!C8kZ4LGK=0iQieFvJBjZzhl=6=9&dl#Y}1xoNX7{8>d9l(p_RQoEh!A80c( zIj0sJK!`_< z7&yw;8L2Jj=?Ist37TFEE-O-&7zo7-8aFIkuhCFn$)O7F4=GLW{h2B(N&NrTw8>6y zt;8A~n%FWic;Xuiq4+_kciDPb#^T~qcY+!*xT=KGLOk<}lS-1Znj^aslS3ZPbtyYP zVZ`7(JrA3CHbT)_4FB56w3Xe4_%fGQ`G{FuD@`Tb$HnVtG%a55*%E(uYK|*bo(vOC z_P3t4lWW$e?Awk36^~4xpIbKCZTwmgcxqK}AjG|X<=Zg-Tlm=&;ZT^z4&`-vzNa>U zI4sMUom}i@>`RKIFf7Xqd{kP~{%KXD80?&()q-^_4Wt0v@{<0k4q?Cvk}}iA`{cjICR6)P|TL>&)D8Bv7!Eo12?+PbkjEJRF8I|M@ug-2eaQ zURVEZI{!CZ9W7vmsI)4g(yE9`t0F3`icd*;-UteO_ye+a%wbV__AqwL;lwQt=kvZ^ zwp43aKOXV1tWRdi7}C7_beo2tR4+r?d#1Z5@a=R~eJRw>+@&!j$;%KqH)8LuY@f;6 zB=+T!Q9{_}ZdYdZ=-Z85^H`I_o*c3sNDTZj@>AZxo}?|J=o#NCypbH=#^R!BO#hszgUKx62RD_~(jwbl5uFYej%Mx%e!fFm9#wS`Vk#=* zaV}PW#iG$1$>;b@YMa^>O=<7(StmA6W%)P`dN_TTVAx`u4rrKqcHz5OtQt@1buRsZ zg`6%^TGx3Kpi3MHv105G(6tlsF_9T$jDn}^%%Z$ft@QhPR-U4&R*ZvTGE1AkZwzs?fW!p8 zp4j4VHI1YJ{JxIMW$eAqUw^BTc0X=5j!0%H>m~>}0~{K2hBIRbXNx%C5y#8`Inqsr zd7#O3>lfo>*%!Csty3{;+1iGp(RzBoeW553IZ z!{ua9ujX-#lfk`>Cch1EFi-S)=Ck;}3t2U^PM|?_)yHm9+h{R@Qt5^uzZ7yP~` zL6LMQ%x|Tn=JHC$KSkT6b)B3?q~uWHmlo4TX(+FwKZ4Ne!?{@7QJbafr+UQXVkUddDH_Da_^3DQqO`k$9ank!PPUFYEJ~j$lr7R0KBf1F9ZOVN6;WwbM5R>`l~%<+ X3*RWmnNr8{00000NkvXXu0mjf{CMom literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9e3a54ab3ebb84d92d01e5ce6d3422143a28a3fe GIT binary patch literal 499 zcmV-jOs|*wNG2DAgJ#!ft7?>Ctd~_H* zv>4W$W0-J&;nim&E_gOw7)`c#IBjSQ>L6K(FD*C^b(Zx{?@P(=R! zXE^^DP8!CYjsAmH6shG~ZxmYibv^%tiW((=WZpA7SkGt4;#V&DhFn)~Yy!_w0X zQx7q``$m*{!sh<_&#>tdL+?I@ryq!P10i$&|7X~H6BIc&UXoxU0dqfnX9%6kaPBe5 pCK53B{db1<f=FbE&$UOii>dt0DJ%d002ovPDHLkV1ipB-KYQn literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0bda915c8d23f7cbff95025d1db2a03f36735818 GIT binary patch literal 958 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 Uj5zVGRS@C;q#X*;XcL(BKazOTEC2ui literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e408931e0cb76a1441caf7e36147e14663eddc9f GIT binary patch literal 822 zcmV-61Ihe}P)hcK!*=#^&d#ZW>~l57^E-U@ynLVEnJ(iJ5AC7* z-vZ)9wWJW$l0sBV3hP4a=x%gbvE)@vX9a0mWx2Rqh&%0g+7Itf)ud!BV$xPzEylTA zRJP*vCv~J`@2!mOs4c1!?wc{^(uS5m+y<2Ap)?zHW;DEq z+oKIFfz7d~IEFJhsO>=02Y7ti&=T0Wh2pm?7dMS`Cc><7*kn`=P9lpbkpbfI4t|%S|*{2%N8IxeLTL-iEFLO6=dN= zX0VWxua%vKSRLGcO-_6qvXVlV3n5o*Kw~fJI{(afq*@$DzuO7Vv z<__`k)*q;8!%GW=NN}e}Q(ourKW2x@7$ zJ!EsQ6Hd3Ba^07*qoM6N<$g5T+e A6951J literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b35d3d89fb2ad3ec90d353f784310dc10c7ae0d0 GIT binary patch literal 1649 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..54ea5944fc799cba9ffc2ab949299c46756894f1 GIT binary patch literal 2027 zcma)7`#Teg8($nLcRO+!<}%_?Y;ws&VJ^!tmt0Ej9POA(vSsc)?h`7PTvu&oCAZYr zTvDti>0s`;Mwm+@Ihl6Cci%tYJkR;zecvD6A3mSY^S;mLLvTeoNQ!~P004j_(h=b< znE3zKF(E;3Jxa<107Rc75!N2~DsENcb!Tt6?9_iw+5qp!JTbaX#Q=UyFBF;)_8zrV00xNsZC{&^x~l2p7LdQ!hqa>$1~a$s#0#xD7!* z19$3m$&9tjn;(O<$nhzfX%UDC1pU z(xgWAkaoO8Jz~FT_@u)!=&$*GlWw;}YIPZ*xCSs ze)i6ET!YkV%w8Hge?=0;jpW}ttjc(E?%n-g_yy>TPPaI=&}g}ncIxmG7I&_y4%zM% zqa|5GwIcUZa)jTOwJM1m`lz~^Sm_FAVlkPosDZIo!R~I@kBj&uFIv_^VP+4E0x?x3 zS?paJU)zmggHogNHU;jz@;L=@EDgVY*AA#AkSPcxid=~h;IZ66`6v5ALH5@qf z2;7A)8juejY1UJY^GzFXz>!TrBJTQw3~-K9Z&vyfqgO8x{_H5!L}S013@A^b(mlqO zxyZ z<>TTbeFsd{#Jq*VB2=5Z*gdFJ_YuCrSm9>NuXmG#&PSSQix)c?bIRHm9xOY=y9b0u z!Mc32{|M>ahZH0KHQr`ujYMU8atuEYYSUDmtNk8OU|+pv5F4ZMYv~&%}iDU;?z?hB5jxS`iX=6>g zUps*l6G|L^F&0LoR<<8-oeAry*HH?E@qO5`Pd{*O0<|Nj%Qw#@Y`Dpin>=03rYclC z#-4TQ-9rO|`_N_jGfnYVVQVUm0vt2dp%t8qcN9DxI zNTHK)$te&dMocwU3mosNbHihv=u+^gobjep^baCDWz_2HcYrbG(-^sGL~iY;+ z6^g+y;pjV`QO6`TWNJ?h3J(1;2XanZ<=p`hqD3`05~|9kbq5KKkLdo5-ob8guh7cr zE&XaVw`cO~Ds0*yWXbG#HLPxi;cT1NZ?r|+t;nU2|6(mu+jipn7Q>9fiRe=0tKZf( zKU1vZE3-#6*->VHrL!mt2!cr6gqYx`=9PY*zqs?rYvD85ymvvom|nAH$$*Ik$S0Gk z26zVpba+8{(j_dR=%n<-lB@DVt-XrXT#I4_!&&FUyJGXQepl=)4Q`(S2k11b1juk# zCX2Jpk!KCJFExERT@ec(%tzI9I)8h-^3l_n^EbYD?P~L31P(c}j(?E5kBCKGW3Ydl zOp)MKjxG0wk)4QTE$L5LtxQvL(;Q{tbwFNjKr>uU;MK;Q$>iK`tyM2tkyGa! z1d>1FkB3Fn$eJ>h^|ZQ!1&=nfG!weM2yIaqy-_dc=Vh&fuj7p|CMF9mL-+~W&PdiZ zCDT&9^JF{kwkP2#L{0KFJH3ots|49}1-_4ffsI|_8S|hxisM$e@P7)oe~rU!wGK55 Uj5zVGRS@C;q#X*;XcL(BKazOTEC2ui literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..db3cbb9ae16eb5f512f03dd1825b29022e4acc40 GIT binary patch literal 2954 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 VDgArrRgT66pb*yZIunn?{{UjUZ2|xQ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4216099983f62559b4414ca3c90bc0764cc58b85 GIT binary patch literal 1321 zcmV+^1=jkBP)`C5h$i2 zJ`e$URizR|(p~zZwOHD1%ga`vg_e}2WtTqKwzRu@$IRN+O|y@?yLa~9z1i>A+&wdA z=IqY*oilS+Nc%{{BnSbeZrWjJ5};8;0t+_?Y-_s0^)Z}Hf=aj^5f7=*aA4}7M1*4z zjw!tWUeIu0L9;gVL0szAo_G+Hyg~lBOm~!{!=f$mh$6_z#Oe$bH)2->%+}yt$^h9o zCPd+tWmubquWPXHEQZJAavbP-WZB$g;xQoxTUL=x+4!~=Kc2&gEu1+95IXTl2y3Ur zj6lf^RLH-Ex9D*qHp7Qu1RczY#YeGFez;!r*8 z4mq8Nlw)p9Npbl21?ZQPFVCRlJnWN6&;Mm8$uUo+)Fhg}7H_S@jxzjy$)#s_am*9u zCZTXWHm|@|1L|&oNEqS?d;_x#Va$$M(^;ER+!{b^G?L?dj9NGcNA#$?ifyOS+7sZ^ zyaH>t;DZyW?_{ZXUNUl*;HU|meay5p6`!s_`U5^ZJe7_o({a3+Rb)MbelLKFc=QY+ z?|b%hIXOr=?x5iGxDkNqCi3>-Grd^*D+U=!9U?dV-Eji*~l@RR6p7xCDb%`Q*JPg(9p#p~F4Orm&*l$^ZGRDL`|D43vA6LD}Owv?dV zOaPiV4eJ)+cr)pD=GL?nrfGANB`HMJHIi?jS9;wmgaXxl)@&Hw!PQ=tNgtUB-F|Glq3sj`8JK-!MC09H=Ar?CwVePoz+D zYc8ij@fQwP^0;t>%K fr5C^pn&sHPNbY(=vaU?900000NkvXXu0mjfLN9v~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..48357583fe323e20b308ce4f293ca410a82df0a3 GIT binary patch literal 2575 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 WDZx$_bf<{`0000FX=qUZZE{Ns zlxGV>vh!#`FOgfaauH=bw+-)wqIeQwuC%fiP~~#oYZ8MBu2VCKrKhDa)N6>u#%cta zg&*rhE;pohh3{&HUo;QqFK=J2`0uu3`@+A6eoAAoR)CY8M9zfVQ^fJD|M=SSD><`9 zWpaWU(no$xIzDWd!FdDSym^KsENg_*qqud`@zH$ZAUcJyS*hn3VLCj9APgIYYbS$S znMDC`Z83o4a|X;0H4B+niMQkT$yYZw=Al-t9`d&$~QiGYH^yF#LqFu?M75lu zb}!hi<%Y)JFXSX#3hK8{<4hm%k!v;FRaU}z>xZYEj)l86%jG;d0|kM4XhQSB!qx3@ zrvY>AeL!td*2!znYk}!_S+C=e3I6z5Fg7hNk)Bd$C5?0%@4E<2|69VRVtxUyUSB7? zmxO=>_+yw@LWxRG@Z)|=4qGJVO^v(s%zxzPJ$pJDnt;L^#XF(3Ju@dn2>@9g#}}(L zsqdq>%m4cz$I)IAoYrvG6wfhGO&3B|@R=NnNSvN44J!DAj0BTxPkSGBH#0UWw0O&b zrG4tS=~6Agr0dB6*1ZvYOUD|dUcLON#3_Y#U_m-QkMxP@?1f_r2Yb;Et`WMV#Plf2 zE9Tr0Y$op8vhLI8cNQ%_G=#1EZ5&&1b8U2YCIyXpnOH+{J^?BX!sB5y_JVIgoktSl z>rHK27n-W+_%ZJVXt&bTC#4`pr}Yw}J2=x^J57Z+4uaM>o+g-0wf5;6fNs%2i6~YnpW4 zq0Y>=5y@9`$LIj9ze6nqoYnmtG*ukTDz@-xG41~ntF>Mc>~u9QQIjK0E(fUgt$ijii9 zwt~;5{P#ld+NookvR6oM!tLLRi>#w|u#c~dmvAZ|qfAthD2bK)nCl+S{=98&UpnQw zhJO&@!+T_l!w9M97+gm)QSjQ1)LJ((k@Brvihsn1V)t`T5RDbOV3PIgzqC#61WO_2 zK6zh63Go+|4mMm=a03VD%MX(5iMYu~S4?1%_{Nw|UAbQFPKT(dvEZ{<9u>qWumEdX z@fi5QNtnlXR{BZO8y-FV1Dj*pm23pE!dT^qIP%_Q9#aXo(toxbnkxIW-{A|IWBN4; z0`dGyioc#t5JY4p2FIH}sR3Kze!V)v*#!qjfZ5CrKezf=^1cqpkz1pQ#cL16%l5u3 zfm*XN4@^BRGOp{k@QfH5cJ|_K!?@V9f9%pwRXnHTdc$%j4H>DjDAG(itmtxE%6r@=cvwLsId{Zbn=re%HdDmb} zZE$2u({~axUJ_E##~TI(G_+sYrAictyIB2}-Acu4 z$9}bFQ8>I?8PY<0Yx!ga=jJLi+CBZWZt&SLk6O(yqdF?b@aTFW)Xery5G4W#7GucK zVwsq;wL1?tOWdl@p zH=8vu6fWUH=eRN%-@MZ6Hr(I|HgznjzR3`dVLiO%e9jenYUjJG-Js3E*+>MzYw zFa%{-q_^EdJOdu-Qvm)JG-D1%1X?K{mZ=97%AeSJ$32@Av#g1E?7{ICxVJmF@p&q@ z0as#ivOY4qnbp|L;KVbXB@EQ5%ITBj<+v zro{?L;w%M{4K$(OAJ^ zZ5ZoZS7Hh#MTHq-Z976}`9J!oa7z~)XXQj~E4Z9Ix;Qg~=`&NV#(jhp5Mjl9wm}JNV3c9i;pELZN;oz7St;&`BP_>bz4<%Eml>KmbCubGdHONT zjJ#?YDS*ZK(GOE5BVPwQAE~n(C8(w1Ix(1bJlEkOJuQEIe@}VwCM6}kEZXr}HU9oL zH))H!HnIXT>;PY;sPAXE7CM)`i8c@g9O3N_ADnU}hR$Ie{A`Y@y{9UVX6>-AIQrnf zvu+^}zC8~6gk@-!! zFMcZ#sJ5{R{{w61W!>XdWy@!Ir`s6 CvWMva literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..43a5317f4c87e84ed210625418db70276a2d4771 GIT binary patch literal 3034 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1244d6962db8725dd2adfcd6ddfa85fb83a3c4ef GIT binary patch literal 2384 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 a*L6&lom}ph%PrgSpe|WotTMUv@P7bgSK6Tf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..20d201f0ec8f671715371829a8075f3dc7714738 GIT binary patch literal 9132 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 Sfv<2tPN&>XzOcP?=YIeQ*!Qde literal 0 HcmV?d00001 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); + }); +}